DEV Community

Cover image for Leveraging the Query Function Context
Dominik D
Dominik D

Posted on • Originally published at tkdodo.eu

Leveraging the Query Function Context

We all strive to improve as engineers, and as time goes by, we hopefully succeed in that endeavour. Maybe we learn new things that invalidate or challenge our previous thinking. Or we realise that patterns that we thought ideal would not scale to the level we now need them to.

Quite some time has passed since I first started to use React Query. I think I learned a great deal on that journey, and I've also "seen" a lot. I want my blog to be as up-to-date as possible, so that you can come back here and re-read it, knowing that the concepts are still valid. This is now more relevant than ever since Tanner Linsley agreed to link to my blog from the official React Query documentation.

That's why I've decided to write this addendum to my Effective React Query Keys article. Please make sure to read it first to have an understanding of what we are talking about.

Hot take

Don't use inline functions - leverage the Query Function Context given to you, and use a Query Key factory that produces object keys

Inline functions are by far the easiest way to pass parameters to your queryFn, because they let you closure over other variables available in your custom hook. Let's look at the evergreen todo example:

type State = 'all' | 'open' | 'done'
type Todo = {
  id: number
  state: TodoState
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}

export const useTodos = () => {
  // imagine this grabs the current user selection
  // from somewhere, e.g. the url
  const { state } = useTodoParams()

  // ✅ The queryFn is an inline function that
  // closures over the passed state
  return useQuery(['todos', state], () => fetchTodos(state))
}
Enter fullscreen mode Exit fullscreen mode

Maybe you recognize the example - It's a slight variation of #1: Practical React Query - Treat the query key like a dependency array. This works great for simple examples, but it has a quite substantial problem when having lots of parameters. In bigger apps, it's not unheard of to have lots of filter and sorting options, and I've personally seen up to 10 params being passed.

Suppose we want to add sorting to our query. I like to approach these things from the bottom up - starting with the queryFn and letting the compiler tell me what I need to change next:

type Sorting = 'dateCreated' | 'name'
const fetchTodos = async (
  state: State,
  sorting: Sorting
): Promise<Todos> => {
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}
Enter fullscreen mode Exit fullscreen mode

This will certainly yield an error in our custom hook, where we call fetchTodos, so let's fix that:

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  // 🚨 can you spot the mistake ⬇️
  return useQuery(['todos', state], () => fetchTodos(state, sorting))
}
Enter fullscreen mode Exit fullscreen mode

Maybe you've already spotted the issue: Our queryKey got out of sync with our actual dependencies, and no red squiggly lines are screaming at us about it 😔. In the above case, you'll likely spot the issue very fast (hopefully via an integration test), because changing the sorting does not automatically trigger a refetch. And, let's be honest, it's also pretty obvious in this simple example. I have however seen the queryKey diverge from the actual dependencies a couple of times in the last months, and with greater complexity, those can result in some hard to track issues. There's also a reason why React comes with the react-hooks/exhaustive-deps eslint rule to avoid that.

So will React Query now come with its own eslint-rule 👀 ?

Well, that would be one option. There is also the babel-plugin-react-query-key-gen
that solves this problem by generating query keys for you, including all your dependencies. React Query however comes with a different, built-in way of handling dependencies: The QueryFunctionContext.

QueryFunctionContext

The QueryFunctionContext is an object that is passed as argument to the queryFn. You've probably used it before when working with infinite queries:

// this is the QueryFunctionContext ⬇️
const fetchProjects = ({ pageParam = 0 }) =>
  fetch('/api/projects?cursor=' + pageParam)

useInfiniteQuery('projects', fetchProjects, {
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})
Enter fullscreen mode Exit fullscreen mode

React Query uses that object to inject information about the query to the queryFn. In case of infinite queries, you'll get the return value of getNextPageParam injected as pageParam.

However, the context also contains the queryKey that is used for this query (and we're about to add more cool things to the context), which means you actually don't have to closure over things, as they will be provided for you by React Query:

const fetchTodos = async ({ queryKey }) => {
  // 🚀 we can get all params from the queryKey
  const [, state, sorting] = queryKey
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  // ✅ no need to pass parameters manually
  return useQuery(['todos', state, sorting], fetchTodos)
}
Enter fullscreen mode Exit fullscreen mode

With this approach, you basically have no way of using any additional parameters in your queryFn without also adding them to the queryKey 🎉.

How to type the QueryFunctionContext

One of the ambitions for this approach was to get full type safety and infer the type of the QueryFunctionContext from the queryKey passed to useQuery. This wasn't easy, but React Query supports that since v3.13.3. If you inline the queryFn, you'll see that the types are properly inferred (thank you, Generics):

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  return useQuery(
    ['todos', state, sorting] as const,
    async ({ queryKey }) => {
      const response = await axios.get(
        // ✅ this is safe because the queryKey is a tuple
        `todos/${queryKey[1]}?sorting=${queryKey[2]}`
      )
      return response.data
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

This is nice and all, but still has a bunch of flaws:

  • You can still just use whatever you have in the closure to build your query
  • Using the queryKey for building the url in the above way is still unsafe because you can stringify everything.

Query Key Factories

This is where query key factories come in again. If we have a typesafe query key factory to build our keys, we can use the return type of that factory to type our QueryFunctionContext. Here's how that might look:

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (state: State, sorting: Sorting) =>
    [...todoKeys.lists(), state, sorting] as const,
}

const fetchTodos = async ({
  queryKey,
}: // 🤯 only accept keys that come from the factory
QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
  const [, , state, sorting] = queryKey
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  // ✅ build the key via the factory
  return useQuery(todoKeys.list(state, sorting), fetchTodos)
}
Enter fullscreen mode Exit fullscreen mode

The type QueryFunctionContext is exported by React Query. It takes one generic, which defines the type of the queryKey. In the above example, we set it to be equal to whatever the list function of our key factory returns. Since we use const assertions, all our keys will be strictly typed tuples - so if we try to use a key that doesn't conform to that structure, we will get a type error.

Object Query Keys

While slowly transitioning to the above approach, I noticed that array keys are not really performing that well. This becomes apparent when looking at how we destruct the query key now:

const [, , state, sorting] = queryKey
Enter fullscreen mode Exit fullscreen mode

We basically leave out the first two parts (our hardcoded scopes todo and list) and only use the dynamic parts. Of course, it didn't take long until we added another scope at the beginning, which again led to wrongly built urls:

Image description

Source: A PR I recently made

Turns out, objects solve this problem really well, because you can use named destructuring. Further, they have no drawback when used inside a query key, because fuzzy matching for query invalidation works the same for objects as for arrays. Have a look at the partialDeepEqual function if you're interested in how that works.

Keeping that in mind, this is how I would construct my query keys with what I know today:

const todoKeys = {
  // ✅ all keys are arrays with exactly one object
  all: [{ scope: 'todos' }] as const,
  lists: () => [{ ...todoKeys.all[0], entity: 'list' }] as const,
  list: (state: State, sorting: Sorting) =>
    [{ ...todoKeys.lists()[0], state, sorting }] as const,
}

const fetchTodos = async ({
  // ✅ extract named properties from the queryKey
  queryKey: [{ state, sorting }],
}: QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  return useQuery(todoKeys.list(state, sorting), fetchTodos)
}
Enter fullscreen mode Exit fullscreen mode

Object query keys even make your fuzzy matching capabilities more powerful, because they have no order. With the array approach, you can tackle everything todo related, all todo lists, or the todo list with a specific filter. With objects keys, you can do that too, but also tackle all lists (e.g. todo lists and profile lists) if you want to:

// 🕺 remove everything related to the todos feature
queryClient.removeQueries([{ scope: 'todos' }])

// 🚀 reset all todo lists
queryClient.resetQueries([{ scope: 'todos', entity: 'list' }])

// 🙌 invalidate all lists across all scopes
queryClient.invalidateQueries([{ entity: 'list' }])
Enter fullscreen mode Exit fullscreen mode

This can come in quite handy if you have multiple overlapping scopes that have a hierarchy, but where you still want to match everything belonging to the sub-scope.

Is this worth it?

As always: it depends. I've been loving this approach lately (which is why I wanted to share it with you), but there is certainly a tradeoff here between complexity and type safety. Composing query keys inside the key factory is slightly more complex (because queryKeys still have to be an Array at the top level), and typing the context depending on the return type of the key factory is also not trivial. If your team is small, your api interface is slim and / or you're using plain JavaScript, you might not want to go that route. As per usual, choose whichever tools and approaches make the most sense for your specific situation 🙌


That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below ⬇️

Top comments (0)