In React apps, keeping track of state for async requests can require a lot of boilerplate code. At work Tejus showed me a library react-query
that reduces this boilerplate with query and mutation hooks.
It did not immediately click. I thought react-query
was only for fetching immutable data which is not very helpful. It turns there are multiple hooks that when used together provide a complete solution for fetching and modifying data.
In most applications there is a helper method that wraps the Fetch API to make HTTP requests with auth, convert errors, and return JSON data. In this article the example code will assume this helper method is called
apiFetch
.
The default way to fetch data in React is with state and effect hooks.
const [data, setData] = useState<ModelJSON | null>(null);
useEffect(() => {
apiFetch(`/models/${id}`).then(data => {
setData(data);
});
}, [id]);
Most apps need to keep track of a loading state to render a progress indicator. Some apps need to keep track of error state to render inline error messages. In addition, the effect needs to keep track of effect cleanup for a couple reasons.
If the effect has a resource identifier in the hook deps, if the page changes and the request for the previous identifier takes longer than the request for the next identifier, it could result in setting state for the wrong page.
Visit page
A25
→ RequestA25
→ Visit pageB14
→ RequestB14
→ ResponseB14
→ ResponseA25
Updating state for unmounted components triggers a warning. See article React state update on an unmounted component.
Visit page
A25
→ RequestA25
→ Visit contact page → ResponseA25
Example code for keeping track of async state and aborting.
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<ModelJSON | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
setData(null);
setError(null);
let aborted = false;
const effect = async () => {
try {
const data = await apiFetch(`/models/${id}`);
if (!aborted) {
setData(data);
}
} catch (e) {
if (!aborted) {
setError(e);
}
} finally {
if (!aborted) {
setLoading(false);
}
}
};
effect();
return () => {
aborted = true;
};
}, [id]);
The majority of this code is boilerplate that applies to fetching data for any resource.
The react-query
library provides a useQuery
hook for fetching data. It includes loading and error state. See the documentation for useQuery for a full list of features.
const modelQuery = useQuery(['model', id], () => {
return apiFetch(`/models/${id}`);
});
// in the render function, for example
if (modelQuery.isFetched) {
<ModelPage model={modelQuery.data} />
} else if (modelQuery.isError) {
<ErrorPage error={modelQuery.error} />
} else {
<CircularProgress indeterminate />
}
It significantly reduces the amount of boilerplate code for making a request.
The default way to update data in React is also with state and effect hooks.
const [field, setField] = useState<string>('');
useEffect(() => {
setField(data.field);
}, [data]);
const onTextChange = event => {
setField(event.target.value);
};
const onSubmit = async () => {
const newData = await apiFetch(`/models/${id}`, {
method: 'PATCH',
json: {
field,
},
});
setData(newData);
};
Most apps need to keep track of the loading status to disable the submit button to prevent submitting the form multiple times and to render a progress indicator. Most apps need to keep track of the error state to render inline errors if the form data has validation errors from the server. It ends up looking like the same boilerplate for fetching data.
const [field, setField] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setField(data.field);
}, [data]);
const onTextChange = event => {
setField(event.target.value);
};
const onSubmit = async () => {
let mounted = true;
try {
const newData = await apiFetch(`/models/${id}`, {
method: 'PATCH',
json: {
field,
},
});
if (mounted) {
setData(newData);
}
} catch (e) {
if (mounted) {
setError(e);
}
} finally {
if (mounted) {
setLoading(false);
}
}
return () => {
mounted = false;
};
};
The problem with useQuery
is that it only covers fetching data. In most web applications we also need to update data after submitting a form or clicking a button. The react-query
library provides a useMutation
hook for wrapping the update or delete method with loading and error state. It also provides a useQueryClient
hook for invalidating or replacing data.
const queryClient = useQueryClient();
const updateMutation = useMutation(() => {
return apiFetch(`/models/${id}`, {
method: 'PATCH',
json: {
field,
},
});
}, {
onSuccess: newData => {
queryClient.setQueryData(['model', id], newData);
},
});
If there is a list view for the same type of resource it would also make sense to invalidate those queries to prevent stale data in the interface if the user changes pages.
{
onSuccess: newData => {
queryClient.setQueryData(['model', id], newData);
queryClient.invalidateQueries(['models']);
},
}
The react-query
library includes a query client that caches and dedupes requests based on query keys. It works kind of like hook deps except the prefix matters for invalidation and objects are compared by value (not by reference). In terms of naming there are a few strategies work.
URL Paths
If you are fetching a resource at '/users' the query key might be ['users']
. If there are query params in the request it can be included too ['users', query]
. The only downside is the for REST invalidating the collection will invalidate the individual resources too. For example if you invalidate ['users']
it will invalidate ['users', 'aj']
. It could result in making extra requests after updating individual resources depending on the specific code.
Function Name
Some apps have helper functions or API libraries for fetching data. For example there could be a searchUsers
method that takes a query and searches all the users. In that case it can make sense to use the function name and arguments as the query key, for example ['searchUsers', query, options]
. The only downside is that if there are different functions for the same resource it could require invalidating all relevant query keys if the data changes.
Resource Type
The query keys in the documentation for react-query
look like resource types with additional items that correspond to URL paths or function arguments. In this case the collection is plural and the individual resource is singular, for example ['users', query]
and ['user', 'aj']
. The only downside is that programmers need to be careful to include parent resources in the query key for scoped resources. For example if a team has projects then a query for projects within a team needs to include the team identifier, for example ['projects', teamId, query]
. Otherwise projects for the wrong team would get fetched from the cache with an incorrect query key, for example ['projects', query]
.