Using useMutation to make an advanced toggle in React
Tags: webdev, react, frontend, javascript
Published: 2023-05-25 16:29:02

Recently, we were adding some new functionality to our dashboard, and we wanted an experience like this:

![Gif of a toggle](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tf3a7aalx95azdfelc7j.gif)

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](https://tanstack.com/query/latest), specifically useQuery and useMutation.

If you haven’t used it before, `useQuery` enables a straightforward interface for fetching data:

```javascript
const {isLoading, error, data} = useQuery("config", fetchConfig)
```

and it comes with caching, re-fetching options, synchronizing state across your application, and [more](https://tanstack.com/query/latest/docs/react/overview).

`useMutation` , as you probably expect, is the **write** to `useQuery`'s **read**. The “Hello, World” of `useMutation` looks like this:

```javascript
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:

```javascript
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](https://tanstack.com/query/latest/docs/react/reference/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:

```javascript
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:

```javascript
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.

```javascript
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](https://tanstack.com/query/latest/docs/react/guides/optimistic-updates) 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:

```javascript
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](https://mantine.dev/hooks/use-timeout/) - a React hook that wraps `setTimeout`. We’ll use [Mantine’s](https://mantine.dev/) hook to complete the code snippet:

```javascript
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:

```javascript
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:

```javascript
// 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.