Skip to content
TkDodo's blog
BlueskyGithub

Leveraging the Query Function Context

ReactJs, React Query, JavaScript, TypeScript4 min read

context

Last Update: 2023-01-03

  • 한국어
  • Add translation

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:

inline-query-fn
1type State = 'all' | 'open' | 'done'
2type Todo = {
3 id: number
4 state: TodoState
5}
6type Todos = ReadonlyArray<Todo>
7
8const fetchTodos = async (state: State): Promise<Todos> => {
9 const response = await axios.get(`todos/${state}`)
10 return response.data
11}
12
13export const useTodos = () => {
14 // imagine this grabs the current user selection
15 // from somewhere, e.g. the url
16 const { state } = useTodoParams()
17
18 // ✅ The queryFn is an inline function that
19 // closures over the passed state
20 return useQuery({
21 queryKey: ['todos', state],
22 queryFn: () => fetchTodos(state),
23 })
24}

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:

sorting-todos
1type Sorting = 'dateCreated' | 'name'
2const fetchTodos = async (
3 state: State,
4 sorting: Sorting
5): Promise<Todos> => {
6 const response = await axios.get(`todos/${state}?sorting=${sorting}`)
7 return response.data
8}

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

useTodos-with-sorting
1export const useTodos = () => {
2 const { state, sorting } = useTodoParams()
3
4 // 🚨 can you spot the mistake ⬇️
5 return useQuery({
6 queryKey: ['todos', state],
7 queryFn: () => fetchTodos(state, sorting),
8 })
9}

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:

useInfiniteQuery
1// this is the QueryFunctionContext ⬇️
2const fetchProjects = ({ pageParam }) =>
3 fetch('/api/projects?cursor=' + pageParam)
4
5useInfiniteQuery({
6 queryKey: ['projects'],
7 queryFn: fetchProjects,
8 getNextPageParam: (lastPage) => lastPage.nextCursor,
9 initialPageParam: 0,
10})

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:

query-function-context
1const fetchTodos = async ({ queryKey }) => {
2 // 🚀 we can get all params from the queryKey
3 const [, state, sorting] = queryKey
4 const response = await axios.get(`todos/${state}?sorting=${sorting}`)
5 return response.data
6}
7
8export const useTodos = () => {
9 const { state, sorting } = useTodoParams()
10
11 // ✅ no need to pass parameters manually
12 return useQuery({
13 queryKey: ['todos', state, sorting],
14 queryFn: fetchTodos,
15 })
16}

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):

query-key-type-inference
1export const useTodos = () => {
2 const { state, sorting } = useTodoParams()
3
4 return useQuery({
5 queryKey: ['todos', state, sorting] as const,
6 queryFn: async ({ queryKey }) => {
7 const response = await axios.get(
8 // ✅ this is safe because the queryKey is a tuple
9 `todos/${queryKey[1]}?sorting=${queryKey[2]}`
10 )
11 return response.data
12 },
13 })
14}

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:

typed-query-function-context
1const todoKeys = {
2 all: ['todos'] as const,
3 lists: () => [...todoKeys.all, 'list'] as const,
4 list: (state: State, sorting: Sorting) =>
5 [...todoKeys.lists(), state, sorting] as const,
6}
7
8const fetchTodos = async ({
9 queryKey,
10}: // 🤯 only accept keys that come from the factory
11QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
12 const [, , state, sorting] = queryKey
13 const response = await axios.get(`todos/${state}?sorting=${sorting}`)
14 return response.data
15}
16
17export const useTodos = () => {
18 const { state, sorting } = useTodoParams()
19
20 // ✅ build the key via the factory
21 return useQuery({
22 queryKey: todoKeys.list(state, sorting),
23 queryFn: fetchTodos
24 })
25}

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:

weird-destruct
1const [, , state, sorting] = queryKey

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:

destruct query key
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:

object-keys
1const todoKeys = {
2 // ✅ all keys are arrays with exactly one object
3 all: [{ scope: 'todos' }] as const,
4 lists: () => [{ ...todoKeys.all[0], entity: 'list' }] as const,
5 list: (state: State, sorting: Sorting) =>
6 [{ ...todoKeys.lists()[0], state, sorting }] as const,
7}
8
9const fetchTodos = async ({
10 // ✅ extract named properties from the queryKey
11 queryKey: [{ state, sorting }],
12}: QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
13 const response = await axios.get(`todos/${state}?sorting=${sorting}`)
14 return response.data
15}
16
17export const useTodos = () => {
18 const { state, sorting } = useTodoParams()
19
20 return useQuery({
21 queryKey: todoKeys.list(state, sorting),
22 queryFn: fetchTodos
23 })
24}

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:

fuzzy-matching-with-object-keys
1// 🕺 remove everything related to the todos feature
2queryClient.removeQueries({
3 queryKey: [{ scope: 'todos' }]
4})
5
6// 🚀 reset all todo lists
7queryClient.resetQueries({
8 queryKey: [{ scope: 'todos', entity: 'list' }]
9})
10
11// 🙌 invalidate all lists across all scopes
12queryClient.invalidateQueries({
13 queryKey: [{ entity: 'list' }]
14})

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 bluesky if you have any questions, or just leave a comment below. ⬇️