Recently, we were adding some new functionality to our dashboard, and we wanted an experience like this:
The basic features are:
The toggle should make an external request when clicked to change the setting
While the request is being made, a loading spinner should appear next to the toggle
If the request succeeds, a check mark is displayed
The toggle should update optimistically, meaning it assumes the request will succeed
If the request fails, a red X is displayed and the toggle switches back to the current state
Using useQuery and useMutation
If our whole dashboard was just this one toggle, this would be a simpler challenge. However, we also fetch and update other values.
To manage our state, we use React Query, specifically useQuery and useMutation.
If you haven’t used it before, useQuery
enables a straightforward interface for fetching data:
const {isLoading, error, data} = useQuery("config", fetchConfig)
and it comes with caching, re-fetching options, synchronizing state across your application, and more.
useMutation
, as you probably expect, is the write to useQuery
's read. The “Hello, World” of useMutation
looks like this:
const { mutate } = useMutation({
mutationFn: (partialConfigUpdate: Partial<Config>) => {
return axios.patch('/config', partialConfigUpdate)
},
})
// later on
const handleSubmit = (e) => {
e.preventDefault();
mutate({new_setting_value: e.target.checked});
}
In this UI, we are using a patch
request to update some subset of our Config
. The only problem is that with that code snippet alone, our UI won’t immediately update to reflect the new state.
Optimistic Updates
useMutation
has a few lifecycle hooks that we can use to update our data:
useMutation({
mutationFn: updateConfig,
onMutate: (partialConfigUpdate) => {
// this is called before the mutation
// you can return a "context" object here which is passed in to the other
// lifecycle hooks like onError and onSettled
return { foo: "bar" }
},
onSuccess: (mutationResponse, partialConfigUpdate, context) => {
// called if the mutation succeeds with the mutation's response
}
onError: (err, partialConfigUpdate, context) => {
// called if the mutation fails with the error
},
onSettled: (mutationResponse, err, partialConfigUpdate, context) => {
// always called after a successful or failed mutation
},
})
You can combine this with a QueryClient, which lets you interact with cached data.
To solve our issue where the UI wasn’t updating to reflect the new state, we can just invalidate the cache after it succeeds:
useMutation({
mutationFn: updateConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['config'] })
},
})
While this does technically work, it relies on us making an additional request after the mutation succeeded. If that request is slow, our UI might be slow to update.
If the mutation request returns the updated config, we have another option:
useMutation({
mutationFn: updateConfig,
onSuccess: (mutationResponse) => {
// on successful mutation, update the cache with the new value
queryClient.setQueryData(['config'], mutationResponse)
// not strictly necessary, but we can also trigger a refetch to be safe
queryClient.invalidateQueries({ queryKey: ['config'] })
},
})
where we just set the data in the cache directly with our response.
One thing to note here though, is we do actually know the change we are making. If our config was: {a: 1, b: 2, c: 3}
and we wanted to update a
's value to be 5, we don’t really need to wait for the mutation response. The thing to be careful about, however, is we need to make sure to undo our change if the mutation fails.
useMutation({
mutationFn: updateConfig,
onMutate: async (partialConfigUpdate) => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['config'] })
// Snapshot the previous value
const previousConfig = queryClient.getQueryData(['config'])
// Optimistically update to the new value
queryClient.setQueryData(['config'], (oldConfig) => {
...oldConfig,
...partialConfigUpdate,
})
// Return a context object with the snapshotted value
return { previousConfig }
},
onError: (err, partialConfigUpdate, context) => {
// roll back our config update using the context
queryClient.setQueryData(['config'], context?.previousConfig)
},
onSettled: (mutationResponse, err, partialConfigUpdate, context) => {
// Other config changes could've happened, let's trigger a refetch
// but notably, our UI has been correct since the mutation started
queryClient.invalidateQueries({ queryKey: ['config'] })
},
})
(see here for the original source)
This is a little more involved, but it does update immediately and this doesn’t depend on the mutation’s response. Next, let’s add Loading, Success, and Error icons.
Adding Loading/Success/Error Icons with useTimeout
useMutation
does actually come with status information that we could just use directly, however, we want to control how long the ✅ and ❌ icons stay on the screen for.
We use this pattern enough times that we’ve turned it into a hook — let’s first look at the version with no timers:
type FeedbackIndicatorStatus =
| "loading"
| "success"
| "error"
| undefined;
export const useFeedbackIndicator = () => {
const [status, setStatus] = useState<FeedbackIndicatorStatus>();
const setTimerToClearStatus = // TODO: how?
// default is to display nothing
let indicator = null;
if (status === "loading") {
indicator = <Loading />;
} else if (status === "success") {
indicator = <IconCheck />;
} else if (status === "error") {
indicator = <IconX />;
}
const setLoading = () => setStatus("loading");
const setSuccess = () => {
setStatus("success");
setupTimerToClearStatus();
};
const setError = () => {
setStatus("error");
setupTimerToClearStatus();
};
return { indicator, setLoading, setSuccess, setError };
}
setTimeout
can be a little tricky to use in React because you have to make sure to clear the timeout if the component unmounts. Luckily, we don’t have to worry about any of that as there are many implementations of useTimeout - a React hook that wraps setTimeout
. We’ll use Mantine’s hook to complete the code snippet:
const { start: setupTimerToClearStatus } = useTimeout(
() => setStatus(undefined),
1000
);
And now, when setupTimerToClearStatus
is called, after a second, the status is cleared and indicator
will be null.
Combining it into a re-usable React hook
We now have all the pieces that we need. We can use useQuery
to fetch data. We have a version of useMutation
that lets us optimistically update the data that useQuery
returns. And we have a hook that displays Loading, Success, and Error indicators.
Let’s put all of that together in a single hook:
import { useState } from "react";
import { QueryKey, useMutation, useQueryClient } from "react-query";
export function useAutoUpdatingMutation<T, M>(
mutationKey: QueryKey,
// the mutation itself
mutationFn: (value: M) => Promise<void>,
// Combining the existing data with our mutation
updater: (oldData: T, value: M) => T
) {
const { indicator, setLoading, setSuccess, setError } = useFeedbackIndicator();
const queryClient = useQueryClient();
const mutation = useMutation(mutationFn,
{
onMutate: async (value: M) => {
setLoading();
// Cancel existing queries
await queryClient.cancelQueries(mutationKey);
// Edge case: The existing query data could be undefined
// If that does happen, we don't update the query data
const previousData = queryClient.getQueryData<T>(mutationKey);
if (previousData) {
queryClient.setQueryData(
mutationKey,
updater(previousData, value)
);
}
return { previousData };
},
onSuccess: () => {
setSuccess();
},
onError: (err, _, context) => {
setError();
// Revert to the old query state
queryClient.setQueryData(mutationKey, context?.previousData);
},
onSettled: async () => {
// Retrigger fetches
await queryClient.invalidateQueries(mutationKey);
},
}
);
return { indicator, mutation };
}
That’s a lot of code, but let’s see what its like to use it:
// Our query for fetching data
const { isLoading, error, data } = useQuery("config", fetchConfig)
// Our updater which performs the opportunistic update
const updater = (existingConfig: Config, partialConfigUpdate: Partial<Config>) => {
return { ...existingConfig, ...partialConfigUpdate }
}
// Our hook which returns the mutation and a status indicator
const { indicator, mutation } = useAutoUpdatingMutation("config", updateConfig, updater)
// ... later on when displaying settings
<div>
{indicator}
<Toggle type="checkbox"
checked={data.mySetting}
onChange={e => mutation.mutate({
mySetting: e.target.checked
})}
disabled={!!indicator} />
</div>
Pretty straightforward, we just supply our API call and our update function and we are done. When we click the toggle, it will:
Update the toggle’s checked state
Disable the toggle until the request is complete
Make a request to update
mySetting
If it fails, revert the toggle back to it’s original state
Wrapping up
React Query provides some powerful abstractions for fetching and modifying data. In this example, we wanted a version of useMutation
that both updates the server state immediately and provides a status indicator for the request itself. By using useMutation
's hooks, we were able to make a hook specific to our use case that can be reused for all our config updates.