React Query is a library for fetching and mutating server state via React hooks. In addition to the perk of caching, it also neatly returns metadata representing the various lifecycles of a network request for both querying (read operations) and mutating (create, update, delete operations):
const {
data,
error,
isError,
isFetched,
isLoading,
...etc,
} = useQuery('todos', getTodos);
const {
data,
error,
isError,
isIdle,
isSuccess,
mutate,
...etc,
} = useMutation(deleteTodo);
This cuts down on the boilerplate when using React local state to track this metadata manually.
As shown in the example above, the useQuery
and useMutation
hooks both have an argument for a function that will presumably make the network request (getTodos
and deleteTodo
respectively in our example).
In this post, I'd like to ponder on potential ways to improve the signature of the useMutation
hook.
First, there is currently no way of enforcing that all mutation functions go through the same API client.
Imagine you wanted to set a pattern in the codebase to make all API requests through a wrapper around the native fetch
API. That way, some common logic can be encapsulated (like stringifying the request body
).
Let's say this wrapper is called fetcher
.
We would want to avoid one mutation function using the native fetch
API and the other using fetcher
.
Of course, this could be enforced via code reviews, but what if there was a way to "document" the expected behavior through a design pattern?
A slight improvement to inlining the functions in the file where useMutation
is called would be to colocate all the operations for an API endpoint in a single file, exporting each function individually.
Then, each "operations" file (the file with all the operations for an API endpoint, including queries and mutations) would have a pattern of importing the fetcher
module and consuming:
/* /api/todos.js */
import fetcher from './fetcher';
export async function getTodos() {
await fetcher('/api/v1/todos');
}
export async function deleteTodo(id) {
await fetcher(`/api/v1/todos/${id}`, {
method: 'DELETE',
});
}
/* some-component.js */
import { useQuery, useMutation } from 'react-query';
import { getTodos, deleteTodo } from '../api/todos';
function SomeComponent() {
const todos = useQuery('todos', getTodos);
const removeTodo = useMutation(deleteTodo);
// not necessary, but wanted to showcase the `.mutate` in action
function handleRemoveTodo(id) {
removeTodo.mutate(id);
}
if (todos.isLoading) {
return '...';
}
return '...';
}
Looks fine, but there's something else to consider.
It is very common to "refresh" (or "requery") after doing a mutation.
In this example, you would want to refresh the todos after deleting one (you could do optimistic updates, but I'm ignoring that for the sake of simplicity).
To do this, you have to obtain access to queryClient
via the useQueryClient
hook, and then "invalidate" the todos query using queryClient.invalidateQueries
function:
const queryClient = useQueryClient();
queryClient.invalidateQueries('todos');
The name of invalidateQueries
captures the technical sense of what's going on.
To "refresh" your todos, you mark the todos as "stale" (effectively saying, "Hey! I may need to update the cached query results via an API request.").
Here's what that would look like in our previous example:
/* some-component.js */
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { getTodos, deleteTodo } from '../api/todos';
function SomeComponent() {
const todos = useQuery('todos', getTodos);
const removeTodo = useMutation(deleteTodo);
const queryClient = useQueryClient();
async function handleRemoveTodo(id) {
await removeTodo.mutateAsync(id);
queryClient.invalidateQueries('todos');
}
if (todos.isLoading) {
return '...';
}
return '...';
}
We can potentially improve this by encapsulating useQueryClient
and the query invalidation into a custom hook (and it provides an opportunity to come up with a preferred name to describe this logic):
/* /api/index.js */
export function useRefresh() {
const queryClient = useQueryClient();
return (query) => queryClient.invalidateQueries(query);
}
/* some-component.js */
import { useQuery, useMutation } from 'react-query';
import { useRefresh, getTodos, deleteTodo } from '../api';
function SomeComponent() {
const todos = useQuery('todos', getTodos);
const removeTodo = useMutation(deleteTodo);
const refresh = useRefresh();
async function handleRemoveTodo(id) {
await removeTodo.mutateAsync(id);
refresh('todos');
}
if (todos.isLoading) {
return '...';
}
return '...';
}
Lastly, if we wanted to inline the mutation function (deleteTodo
) while ensuring the same fetch client is used every time, we could expose a hook from the same file as useRefresh
that returns the fetch client for mutations:
/* /api/index.js */
import fetcher from './fetcher';
export function useRequest() {
// Add any mutation-specific request logic here
return fetcher;
}
/* some-component.js */
import { useQuery, useMutation } from 'react-query';
import { useRefresh, getTodos, deleteTodo } from '../api';
function SomeComponent() {
const todos = useQuery('todos', getTodos);
const request = useRequest();
const refresh = useRefresh();
const removeTodo = useMutation(async (id) => {
await request(`/api/v1/todos/${id}`, {
method: 'DELETE',
});
refresh('todos');
});
function handleRemoveTodo(id) {
removeTodo.mutate(id);
}
if (todos.isLoading) {
return '...';
}
return '...';
}
Conclusion
Maybe you like these changes, maybe you don't. Either way, I hope this gets the brain juices flowing to consider ways to wrap React Query's useMutation
to fit the needs of your codebase.
Top comments (0)