Using useMutation to make an advanced toggle in React

Using useMutation to make an advanced toggle in React

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.