React Query: How to organize your keys
Red Ochsenbein (he/him)

Red Ochsenbein (he/him) @syeo66

About: A senior at work, a beginner at heart.

Location:
Burgdorf, Switzerland
Joined:
Oct 5, 2021

React Query: How to organize your keys

Publish Date: Aug 1 '22
16 19

If you're using React Query you certainly know how the useQuery hook works. Some example similar to the ones you find in the React Query documentation.

// a simple string.only key
useQuery('todos', ...)

// an array key
useQuery(['todo', id], ...)

// other, more complex keys
useQuery(['todo', id, 'comments', commentId], ...)
useQuery(['todo', {id, type: 'comments', commentId}], ...)
Enter fullscreen mode Exit fullscreen mode

These keys are used to identify a specific query and is most important in combination with react query's caching mechanism. It allows react query to fetch the same query only once even if it is called multiple times in various components, and it identifies the cache to be used when fetching again or invalidating the cache.

In larger applications you'd have to make sure the keys are identical in all components or hooks using the same query or even more important if you want to invalidate the cache (aftera mutation, for example).

The react query documention does not provide a solution to this problem. My solution for this is pretty straightforward. By creating an object with a key and query function for each query.

const todoQuery = {
  key: (id: string): ['todo', string] => ['todo', id],
  query: (id: string): Promise<...> => {... fetch the todos ...},
}
export default todoQuery
Enter fullscreen mode Exit fullscreen mode

Using useQuery would then look like this:

const { data, isLoading } => useQuery(todoQuery.key(id), () => todoQuery.query(id))
Enter fullscreen mode Exit fullscreen mode

I think this is a simple but effective way to make sure the keys are always the same. Even when the keys need to change for some reason, you always alter them for all the places they have been used.


Photo by Joshua Aragon on Unsplash

Comments 19 total

  • Jack
    JackAug 1, 2022

    Best thing to do is to create specific functions for your queries:

    export const useTodo = (id: string) => {
      return useQuery(['todo', id], () => fetchTheTodos(id));
    }
    
    Enter fullscreen mode Exit fullscreen mode
    const { data: todos } = useTodo(props.id);
    
    Enter fullscreen mode Exit fullscreen mode

    and so on.

    Hooks are composable so make the most of it.

    • Red Ochsenbein (he/him)
      Red Ochsenbein (he/him)Aug 1, 2022

      This will not help you when trying to invalidate the query. Of course you could create a invalidate function/hook for each query, too. But then the pattern starts to look not much different from what I'm proposing.

      Or you can even use a combination of both: Create custom hooks PLUS use the key/query objects.

      • Jack
        JackAug 1, 2022

        Invalidation is a whole other problem for sure. I usually have an invalidate hook that invalidates all queries within a certain domain i.e.

        const invalidate = useInvalidateBasketQueries();
        
        return useQuery(key, fn, { onSuccess: invalidate });
        
        Enter fullscreen mode Exit fullscreen mode

        I've been using react query (and vue query) since day 1 and it's important we get some good/consistent patterns in place

      • Ivan Jeremic
        Ivan JeremicAug 2, 2022

        Invalidating is easy why not just,

        const queryClient = useQueryClient();
        
        queryClient.invalidateQueries(["key"]);
        
        Enter fullscreen mode Exit fullscreen mode

        Works for me with normal queries and custom hooks.

        • Red Ochsenbein (he/him)
          Red Ochsenbein (he/him)Aug 2, 2022

          Yes sure. But how would make sure that keys stay consistent in your mid-size to large React application? What if the key used by the query at another place of the application (by another person)? How do you make sure the invalidation also gets updated?
          This is what my pattern tries to solve.

          • Ivan Jeremic
            Ivan JeremicAug 2, 2022

            I don't understand what you mean by "invalidation also gets updated" what you mean by updated? Or do you mean other devs on your team will accidentally use an already existing key for a different query?

            • Jack
              JackAug 2, 2022

              This is fine in simple cases but it doesn't scale well.
              Often for a single"area" of my application I might have 15 different queries that are all interlinked and need to be invalidated together.
              Having to manually invalidate every one of these in every place that updates my data, getting all of the keys correct, making sure I'm passing in the correct parameters every time, etc. It gets messy fast in a larger application.
              And then if you add another query later that also needs to be invalidated, you'll be searching all across your codebase trying to find all the other invalidateQueries calls in order to add it. And I 100% guarantee you'll miss one!

              • Ivan Jeremic
                Ivan JeremicAug 2, 2022

                Seems to me some organization with context wrappers could help here.

                • Red Ochsenbein (he/him)
                  Red Ochsenbein (he/him)Aug 2, 2022

                  So, let's just assume this (slightly silly) example. In one part of your application you do have this:

                  const { data } = useQuery(['the-key'], () => doSomething())
                  
                  Enter fullscreen mode Exit fullscreen mode

                  Now, in a different part you have this invalidation code:

                  queryClient.invalidateQueries(["the-key"])
                  
                  Enter fullscreen mode Exit fullscreen mode

                  So far so good. Now let's say someone new in your team is working on a change in the first part and has to change the key for whatever reason. Maybe to this:

                  const { data } = useQuery(['the-key', id], () => doSomething())
                  
                  Enter fullscreen mode Exit fullscreen mode

                  Now the invalidation would no longer work (well, technically it still does, because it would invalidate all 'the-key' cache entries, but it would no longer be as targeted). The new team member wouldn't know about it and nobody might notice it (well, hopefully you'd have some tests in place to catch it, but well, sometimes tests don't catch everything).

                  By using a unified "key generator function" as I propose in my article the keys would stay in sync everywhere they were used for a specific query function.

                  • Ivan Jeremic
                    Ivan JeremicAug 2, 2022

                    I'm pretty sure the invalidation still works even after adding id dependency no matter how manny deps you add you can always just invalidate them with just the key, I'm pretty sure I have a query in my app

                    useQuery(["my-key",  {  id  }], () => doSomething())
                    
                    // invalidate with just key works
                    queryClient.invalidateQueries(["my-key"]);
                    
                    Enter fullscreen mode Exit fullscreen mode
                    • Red Ochsenbein (he/him)
                      Red Ochsenbein (he/him)Aug 2, 2022

                      Yes, this works. queryClient.invalidateQueries() would also work. But I'd rather invalidate as little as possible which means I have to be specific with the keys. Also you might want to update the cached data after the mutation (Updates from Mutation Responses) instead of just relying on. In those case you will have to use the exact key.

                      In the end there are a lot of ways to get to the desired outcome (using prefix matching, using the exact option, using predicate functions...). You'd have to choose the solution that works for you. I usually tend to think of solutions that are easy to follow and prevent as many undesirable side effects as possible by design (i.E. they require lower cognitive work).

                      • Ivan Jeremic
                        Ivan JeremicAug 2, 2022

                        I'm sure your use-case requires what you describe, for me creating custom query hooks work fine for now.

                        • Jack
                          JackAug 2, 2022

                          It really does

                          138 instances of useQuery calls

                          That's 138 unique queries, most of which are used dozens of times across the codebase 😅

                          • Jack
                            JackAug 2, 2022

                            From my experience I think you have to explicitly pass { exact: false } to invalidate partial keys. I could be wrong.

  • dikamilo
    dikamiloAug 2, 2022

    I prefer to create a separate key map per API namespace. It's looks like this:

    export const myzoneKeys = {
      all: ['myzone'] as const,
    
      profile: (profileId: string | undefined) =>
        [...myzoneKeys.all, profileId || 'no-profile'] as const,
    
      myList: (profileId: string | undefined) =>
        [...myzoneKeys.profile(profileId), 'my-list'] as const,
    
      recommendations: (profileId: string | undefined) =>
        [...myzoneKeys.profile(profileId), 'recommendations'] as const,
    
      recommendationsDetail: (profileId: string | undefined) =>
        [...myzoneKeys.profile(profileId), 'recommendations-detail'] as const,
    
      recentlyWatched: (profileId: string | undefined) =>
        [...myzoneKeys.profile(profileId), 'recently-watched'] as const,
    
      recentlyWatchedChannel: (profileId: string | undefined) =>
        [...myzoneKeys.recentlyWatched(profileId), 'channel'] as const,
    
      recentlyWatchedProgram: (profileId: string | undefined) =>
        [...myzoneKeys.recentlyWatched(profileId), 'program'] as const,
    
      recentlyWatchedRecording: (profileId: string | undefined) =>
        [...myzoneKeys.recentlyWatched(profileId), 'recording'] as const,
    
      recentlyWatchedVod: (profileId: string | undefined) =>
        [...myzoneKeys.recentlyWatched(profileId), 'vod'] as const,
    };
    
    Enter fullscreen mode Exit fullscreen mode

    In complex apps, this have several advantages:

    • I can invalidate whole namespace (since in this case is per user profile) using all key
    • I can invalidate all recently watched queries using recentlyWatched key, since all other recently watched keys are based on this
    • I can invalidate single query be specific key name
    • It's constant and easy to use and understand in react query devtools

    Also, I don't use useQuery across the app. I create custom query hooks in single API packages divided to API namespace and use it in app.

  • vkostunica
    vkostunicaAug 4, 2022

    this topic requires much deeper elaboration than this

Add comment