DEV Community

Cover image for Wrapping React Query's useMutation (A Use Case for Wrapping External Libraries)
Michael Mangialardi
Michael Mangialardi

Posted on

Wrapping React Query's useMutation (A Use Case for Wrapping External Libraries)

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);
Enter fullscreen mode Exit fullscreen mode

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).

I have previously written about alternatives to the signature of this hook that you can achieve by wrapping it.

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 '...';
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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 '...';
}
Enter fullscreen mode Exit fullscreen mode

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 '...';
}
Enter fullscreen mode Exit fullscreen mode

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 '...';
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)