Frontend Handbook
04Built

Custom Data-Fetching Hook with Caching

A reusable fetch hook with caching, deduplication, and stale data handling.

How it works

This is a hand-built version of what SWR and TanStack Query give you: a useQuery hook that fetches data by key and keeps it in one shared cache, so every component asking for the same thing gets the same answer from a single request.

It solves the messy parts of fetching in components: duplicate requests, slow responses arriving out of order, data that goes stale, flaky networks, and edits that should feel instant but roll back if the server says no.

  • One cache, many readers: components subscribe to a key; the same key means one request and one value.
  • Stale-while-revalidate: cached data shows instantly while a background refresh brings it up to date.
  • Only the latest answer wins: every request carries a token, so a late or cancelled response is thrown away.
  • Optimistic edits: changes appear everywhere at once and roll back only their own fields on failure.
Read more

Where you'd use it

  • Master-detail screens: a list of orders or users where opening an item you've seen before is instant.
  • Dashboards where several widgets read the same data (current user, team, settings) without each fetching it.
  • Small edits that should feel immediate: starring, renaming, toggling a setting.
  • Apps people leave open for hours, where data should refresh when they come back to the tab.

How we built it

One module-level cache holds an entry per key. Keys are hashed with object properties sorted, so {a,b} and {b,a} are the same key. useQuery reads an entry with useSyncExternalStore, and subscribing is also how the cache learns who uses what: React mounting, unmounting or switching keys drives fetching, cancelling and cleanup. The cache lives for the session, so it survives moving between practices.

Concurrent readers of a key share one promise. A new request bumps the entry's token and cancelling aborts with an AbortController; a response is only stored if its token is still current. Failures retry with growing waits (about 1 s, 2 s, 4 s, with ±25 % jitter, capped at 30 s), 4xx errors are not retried, and requests pause while the browser is offline.

When a key loses its last user, its request is cancelled and a removal timer starts; a new user cancels the timer. Optimistic edits cancel refreshes for the affected keys, remember the old field values, write the new ones into the list and the detail, then send the request. On failure only the fields that still hold this edit's value are restored, and the last edit to settle refreshes from the server.

Best practices

  • Do: Tag each request and store a response only if it is still the newest one for its key.

    Avoid: Letting whichever response arrives last win, so a slow old request replaces what the user is looking at.

  • Do: Cancel in-flight refreshes before writing an optimistic value.

    Avoid: A background refresh landing after the edit and silently putting the old value back.

  • Do: Show cached data with a small “Refreshing” hint, and keep it on screen if the refresh fails.

    Avoid: Swapping the content for a skeleton on every revisit, or blanking it because a refresh errored.

  • Do: Share one request per key and drop unused entries after a removal time.

    Avoid: One fetch per component, or a cache that only ever grows during a long session.

  • Do: Retry server and network errors with backoff, and show a clear message with a Retry button at the end.

    Avoid: Retrying a 404 that will never succeed, or retrying in a tight loop.

Try it with the demo controls

Latency
Slow it to 1500 ms to see skeletons and the “Refreshing” badge. Pick Random and click quickly through users: the detail always ends on the last one you clicked.
Read failure
Loads fail and retry on their own: watch “Retrying (attempt N)…” and the inspector's “fetching (try N)”. If every attempt fails, an error with a Retry button appears; a failed refresh keeps the old data.
Save failure
Set it to 100 % and edit a user: the change shows instantly, then rolls back in the list and the detail with a “Couldn't save the change” toast.
Stale time
At 0 s every revisit shows cached data and refreshes it in the background. At 30 s or Never, revisits make no request until the data turns stale or you invalidate it.
Removal time
Leave a user and watch its “Removal in” countdown in the inspector. Open it again before it ends and the entry is kept. A new value applies to countdowns that start afterwards.
Retries
How many retries follow a failed request (0–5). At 0, one failure shows the error straight away.
Refresh on focus / reconnect
Switch to another tab and back: stale keys in use refresh, at most once every 5 s per key. Untick it and nothing refreshes.
Detail panels (same key)
Show 2 or 3 panels of one user: the inspector shows several users for that key but a single request, and an edit updates every panel.
Hover a user (prefetch)
Rest the pointer on a name for 150 ms, or Tab onto it: its detail key appears in the inspector before you click, and opening it shows no loading state.
Favorite star / Display name + Save
Optimistic edits: the list and detail update at once, the star or Save button is disabled (Save reads “Saving…”) until the server answers, then the keys refresh.
Cache inspector
One row per key with status, request activity, age, freshness, users, total requests and time until removal. Refresh starts a new request for one key.
Invalidate all / Clear cache
Invalidate all marks every key stale and refreshes those in use. Clear cache drops everything; keys still on screen start a fresh load.
Ghost User
Listed but deleted on the server: its detail answers 404 and shows “User not found” straight away, with no retries.

Trade-offs and limits

  • No Suspense mode: the hook returns status flags and each component renders its own loading and error states.
  • No paginated queries, request batching, normalized entities or server-side cache hydration; the cache also doesn't survive a full reload.
  • Rollback is per field, not a full snapshot, so an overlapping edit survives; a failed edit while offline rolls back right away and isn't queued.
  • Known gap: a focus refresh that starts while an edit is still saving can briefly show the server's old value until the edit's own refresh lands.

Demo controls

Users

Select a user. Hover a name for 150 ms to prefetch it.

Cache inspector

0 keys
Cache entries and their state
KeyStatusRequestAgeFreshnessUsersRequestsRemoval inActions
Cache is empty.