Writing · Data

TanStack Query, visualised

The cache you never see, drawn: query keys, fresh and stale data, one request for many components, retries, mutations, optimistic updates and pages, each played on a network you can slow down and break. 13 minutes, 7 demos.

Try it: an app and its cache
example.com/
Inkwell…

Welcome back

What'sonyourmind?

—

devtools

Queries1

  • inactive['user', 1]0 readers

Network0 requests

    On this page

    01 · The idea

    Data you don't own

    Some of what an interface shows is its own: whether a menu is open, what’s typed in a field. The rest lives on a server and is only borrowed: a user’s profile, a list of orders, the number of likes. Borrowed data has problems the first kind doesn’t. It takes time to arrive. It can fail to arrive. Two parts of the page may ask for the same thing at once. And the moment it lands, it starts going out of date, because someone else can change it.

    TanStack Query is a library for that second kind. Underneath, it’s a cache that sits between your components and the server: components ask the cache, and the cache decides whether to answer from what it has, ask the server, or both. Most of what it does happens on its own, which is why it can feel like magic and why it’s worth watching.

    So every demo here is a small app, Inkwell, running the real library (version 5) against a pretend server that lives in the page, with the devtools a developer would keep open under it: every entry in the cache, and every request on the network. You can slow the network down and make it fail. The one at the top is the whole idea, small.

    02 · The cache

    A store you can see

    Every piece of data in the cache is filed under a query key: an array that names it, like ['user', 1]. A component asks with useQuery, giving the key and a function that fetches the data. If the cache has nothing under that key, it calls the function; if it has something, it hands that over at once.

    The cache: pages, keys and entries
    example.com/
    Inkwell…

    Welcome back

    What'sonyourmind?

    —

    devtools

    Queries1

    • inactive['user', 1]0 readers

    Network0 requests

      Profile.jsxjsx
      1function Profile({ id }) {
      2 const { data, isPending, isFetching } = useQuery({
      3 queryKey: ['user', id],
      4 queryFn: () => fetchUser(id),
      5 staleTime: 10_000, // fresh for 10s
      6 gcTime: 8_000, // kept 8s once no page reads it
      7 });
      8}

      Open Alan’s profile and it asks for ['user', 2]: the cache has nothing under that key, so a request goes out (watch the Network pane) and an entry appears under Queries. Go home and open Alan again: his profile is there at once, with no request, because the entry is still fresh (this app keeps data fresh for 10 seconds). Meanwhile Ada, the signed-in user, is read by the header and the home page together: two readers, one entry, one request.

      Now go home and leave Alan. With no page reading his entry it turns inactive, but it doesn’t vanish: it stays in case someone comes back, and is thrown away only after gcTime has passed (5 minutes by default, 8 seconds here). Open him again before then and his profile is waiting; after, it loads from scratch.

      UserCard.jsx
      function UserCard({ id }) {
        const { data, isPending, error } = useQuery({
          queryKey: ['user', id],
          queryFn: () => fetchUser(id),
        });
        if (isPending) return <Spinner />;
        if (error) return <p>Couldn't load this user.</p>;
        return <h2>{data.name}</h2>;
      }

      The key is the whole contract: anything the fetch depends on belongs in it. ['user', id] with the id inside means a new id is a new entry, fetched on its own and cached on its own.

      03 · Fresh and stale

      When it refetches on its own

      Cached data is either fresh or stale. Fresh data is trusted: the cache answers from it and doesn’t bother the server. Stale data is still shown (stale doesn’t mean wrong, it means it might be), but at certain moments the cache fetches it again in the background and swaps the new data in when it lands. How long data stays fresh is staleTime, and by default it’s 0: everything is stale the moment it arrives.

      The moments are three: a component that reads it mounts, the tab comes back into focus, the network comes back after being offline. Each one refetches only if the data is stale. In the app, moving between pages mounts them; the two buttons under it stand in for leaving the tab and losing the network.

      Fresh and stale: when the library refetches on its own
      example.com/
      Inkwell…

      Welcome back

      What'sonyourmind?

      —

      Movearoundtheapp,orpressabutton:eachisamomentthelibrarymayrefetch.

      devtools

      Queries1

      • inactive['user', 1]0 readers

      Network0 requests

        Profile.jsxjsx
        1useQuery({
        2 queryKey: ['user', id],
        3 queryFn: () => fetchUser(id),
        4 staleTime: 0,
        5 // refetch when stale, on each of these
        6 // (all three default to true):
        7 refetchOnMount: true,
        8 refetchOnWindowFocus: true,
        9 refetchOnReconnect: true,
        10});
        staleTime

        With staleTime at 0, every moment sends a request: open Alan, go home, open him again, and each visit asks the server, while his profile is already on screen with “updating…” beside his name. At 5 seconds, come back quickly and the cache answers on its own; wait 5 seconds and the next moment refetches. With 'static', nothing ever refetches by itself. Notice what the app shows while a refetch runs: what it already had, never a skeleton. That’s the point of stale data: show what you have, fetch what’s new, swap it in.

        So choosing staleTime is choosing how often the server is asked. Data that changes every second wants 0. A user’s name can be fresh for minutes. A list of countries never goes stale at all.

        04 · One request, many readers

        Deduplication

        Build a page out of small components and several of them will want the same data: the header shows the user’s avatar, the sidebar their name, the settings panel their email. Fetched by hand, each component sends its own request. Through the cache, a request already on its way is shared: every component asking for the same key while it’s in flight waits for that one answer.

        Deduplication: four components, one request
        example.com/
        Inkwell…

        Welcome back

        What'sonyourmind?

        —

        ThehomepagereadsAdainfourplaces:eachfetchesforitself,0requests.

        devtools

        Queries0

        • nothing cached

        Network0 requests

          Greeting.jsxjsx
          1function Greeting() {
          2 const [user, setUser] = useState();
          3 useEffect(() => {
          4 fetchUser(me).then(setUser);
          5 }, []);
          6}
          Each fetches

          Inkwell’s home page reads the signed-in user in four places: the header, the greeting, the box to write in and the numbers under it. Reload it with each one fetching by hand and count the requests in the Network pane: four, all for the same user at the same moment. Switch to useQuery and reload: one. This is why you can call useQuery wherever the data is needed instead of fetching once at the top and passing it down through every layer: the cache makes asking twice cost nothing.

          05 · Retries

          When a request fails

          Networks fail, often briefly: a server restarting, a phone between towers. So a query that fails isn’t given up on straight away. By default the library tries 3 more times, waiting longer before each: 1 second, then 2, then 4 (doubling, never more than 30). Only when the last try fails does the component see the error.

          Retries: trying again, a little later each time
          example.com/
          Inkwell…

          Welcome back

          What'sonyourmind?

          —

          devtools

          Queries1

          • inactive['user', 1]0 readers

          Network0 requests

            Profile.jsxjsx
            1useQuery({
            2 queryKey: ['user', id],
            3 queryFn: () => fetchUser(id),
            4 retry: 3, // the default
            5 // the default wait: 1s, 2s, 4s… at most 30s
            6 retryDelay: (attempt) =>
            7 Math.min(1000 * 2 ** attempt, 30_000),
            8});
            retry

            Visit Grace with two failing requests: the third try gets through, and the page never shows an error, only a skeleton that stayed a few seconds longer (the devtools count the failures as they happen). With five, it runs out of tries and gives up, and the page says so, with a button to try again. Set retry to 0 and the first failure is final. While it’s retrying, the page knows: failureCount says how many tries have failed so far, which is how it can say so under the skeleton.

            The wait growing each time is deliberate. If a server is down because it’s overloaded, a thousand clients retrying every second keep it down; waiting longer each time gives it room to come back.

            06 · Changing data

            Mutations and invalidation

            Reading is half of it. To change something on the server (post a comment, rename a project) you use useMutation. A mutation is just the request; the hard part is what happens after. The server now has new data, and the cache still holds the old.

            Mutations: after a save, what does the page know?
            example.com/@ada/notes-on-the-engine
            Inkwell…
            Ada Lovelace

            comments

              Thesecommentsnevergostaleontheirown(staleTime:Infinity).

              0 on screen · 2 on the server

              devtools

              Queries3

              • inactive['user', 1]0 readers
              • inactive['post', 1]0 readers
              • inactive['comments', 1]0 readers

              Network0 requests

                Comments.jsxjsx
                1const add = useMutation({
                2 mutationFn: (text) => postComment(text),
                3});
                After saving

                With Nothing after, post a comment on Ada’s post and watch the counts under the app: the server has three, the page still shows two, and nothing in the cache knows it’s out of date (these comments never go stale on their own: their staleTime is Infinity). Press “Refresh” and the new comment appears. That button is what Invalidate does for you: when the save succeeds, invalidateQueries marks the entry under ['comments', 1] stale and refetches it, since it’s on screen.

                Invalidating by key is why keys are arrays. invalidateQueries({ queryKey: ['comments'] }) matches ['comments', 1], ['comments', 2] and ['comments', 1, 'newest']: everything that starts with it. One line after a save, and every list that might show the new comment is fetched again.

                07 · Optimistic updates

                Show it first, take it back if it fails

                Invalidating is safe but slow: the comment appears only after two round trips, the save and the refetch. For actions that almost always succeed (a like, a checkbox, a new comment) you can show the result before the server has answered, and undo it in the rare case it says no. That’s an optimistic update.

                Optimistic updates: show it first, take it back if it fails
                example.com/@ada/notes-on-the-engine
                Inkwell…
                Ada Lovelace

                Presstheheart:onewaywaitsfortheserver,theotherdoesn't.

                devtools

                Queries2

                • inactive['user', 1]0 readers
                • inactive['post', 1]0 readers

                Network0 requests

                  LikeButton.jsxjsx
                  1const like = useMutation({
                  2 mutationFn: (liked) => sendLike(liked),
                  3 onMutate: async (liked) => {
                  4 await queryClient.cancelQueries({ queryKey: ['post', 1] });
                  5 const before = queryClient.getQueryData(['post', 1]);
                  6 queryClient.setQueryData(['post', 1], (old) => ({
                  7 ...old, liked, likes: old.likes + (liked ? 1 : -1),
                  8 }));
                  9 return { before };
                  10 },
                  11 onError: (error, liked, result) =>
                  12 queryClient.setQueryData(['post', 1], result.before),
                  13 onSettled: () =>
                  14 queryClient.invalidateQueries({ queryKey: ['post', 1] }),
                  15});
                  After saving

                  Press the heart with Invalidate: nothing moves until the server has answered and the post has been fetched again. Switch to Optimistic and press it: the heart fills and the count goes up at once. Turn on “Server refuses it” and press it again: it changes, the save fails, and it’s put back. The code does four things, in order. It cancels any refetch of the post already in flight, so an old answer can’t land on top of the new like. It keeps a copy of the post as it was. It writes the like into the cache. And if the save fails, it puts the copy back. Either way, it ends by invalidating, so the post settles on what the server really has.

                  For the simplest cases there’s a lighter way: don’t touch the cache at all, and draw the pending item from the mutation itself (variables holds what’s being saved, isPending says it’s on its way). It disappears by itself if the save fails, so there’s nothing to roll back.

                  08 · Pages

                  Pages, and lists that grow

                  Long lists come in pages, and the page number goes in the key: ['feed', 2] is a different entry from ['feed', 1]. That gives you something for free. Go to page 2, then back to 1, and page 1 is still in the cache: it’s on screen at once.

                  Pages: one entry per page, or one list that grows
                  example.com/feed
                  Inkwell…
                  loading…

                  devtools

                  Queries2

                  • inactive['user', 1]0 readers
                  • inactive['feed', 1]0 readers

                  Network0 requests

                    Feed.jsxjsx
                    1const { data, isPlaceholderData } = useQuery({
                    2 queryKey: ['feed', page],
                    3 queryFn: () => fetchFeed(page),
                    4 placeholderData: keepPreviousData,
                    5});
                    Show

                    Turn keepPreviousData off and press “Older”: the feed empties to a loading state while the new page arrives. Turn it back on and the old page stays up, dimmed, until the new one replaces it. isPlaceholderData says when what’s on screen is the previous page.

                    Switch to Load more for a list that grows instead. useInfiniteQuery keeps every page under one key, adds a page with each fetchNextPage(), and asks getNextPageParam where the next one starts; when that returns nothing, hasNextPage is false and the list is complete.

                    09 · Worth knowing

                    The defaults, and when to change them

                    Most of what the demos showed happens without a single option. These are the defaults behind it, and the usual reasons to change them:

                    • staleTime: 0: data is stale on arrival, so it’s refetched at every chance. Raise it for anything that doesn’t change by the second; most apps set a project-wide default of a minute or so.
                    • gcTime: 5 minutes: how long an entry with no readers is kept. Rarely worth changing.
                    • retry: 3, waiting 1s, 2s, 4s. Lower it for requests a person is staring at, where an honest error beats a long wait.
                    • Refetch on mount, window focus and reconnect: on, but only for stale data. Turning focus off is common in dashboards that open many tabs.
                    • Mutations don’t retry, and don’t update anything unless you tell them to.
                    • The cache is the model: components ask it, it decides whether to ask the server.
                    • The key names the data: everything the fetch depends on goes in it.
                    • Stale isn’t wrong: it’s shown at once and refreshed behind it. staleTime decides how often the server is asked.
                    • Ask wherever you need it: requests for the same key are shared.
                    • After a change, invalidate: by key prefix, so everything that might show it refetches.
                    • Optimistic for actions that rarely fail: cancel, snapshot, write, and roll back on error.
                    • Pages in the key, and keepPreviousData so the screen doesn’t empty between them.