React Query Auto Sync Hook

July 26, 2021

In this post, I'll show you how to build an auto-sync hook with ReactQuery. The hook can be used to build user interfaces that require an autosave feature. Autosave is useful for preventing accidental data loss and has become the norm in document-based applications. Despite the increasing prevalence of autosave interfaces, I found very little information about building one with ReactQuery. Most ReactQuery examples require the user to manually save with a save button, or provide separate interfaces for viewing data from the server and editing it locally.

As a general rule, when using ReactQuery you should keep server and client state separate. Server state is the data returned by a useQuery call. Client state is locally modified server state. When you want to modify server state with ReactQuery, you create a copy of the server state and modify the copy.

ReactQuery assumes that the data object only changes based on updates from the server. ReactQuery uses structural sharing (a deep comparison of the object's values) to memoize query results. Changing the data object directly breaks a lot of the assumptions ReactQuery uses to handle caching, updating, and memoizing query results.

Here is an example hook that can be used to implement a separation of server and client state while maintaining a simple external API.

import { useState } from "react";
import { QueryKey, useQuery, UseQueryOptions } from "react-query";

export function useReactQueryAutoSync<
  TQueryFnData = unknown,
  TQueryError = unknown,
  TQueryData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>({
  queryOptions,
}: {
  queryOptions: UseQueryOptions<
    TQueryFnData,
    TQueryError,
    TQueryData,
    TQueryKey
  >;
}) {
  const [draft, setDraft] = useState<TQueryData | undefined>(undefined);

  const queryResult = useQuery(queryOptions);

  return {
    setDraft,
    draft: draft ?? queryResult.data,
    queryResult,
  };
}

The consumer of this hook does not have to think about the separation of server and client state as long as they only modify the draft with setDraft.

However, modifying server state isn't very useful unless we have a way to save that state back to the server. We'll start by adding a manual save function to the hook. To create the save function we need to provide mutation options to the hook and return a mutation result.

import { useCallback, useRef, useState } from "react";
import {
  QueryKey,
  useMutation,
  UseMutationOptions,
  useQuery,
  UseQueryOptions,
} from "react-query";

export function useReactQueryAutoSync<
  /*...*/
  TMutationData = unknown,
  TMutationError = unknown,
  TMutationContext = unknown
>({
  queryOptions,
  mutationOptions,
}: {
  queryOptions: /*...*/
  mutationOptions: UseMutationOptions<
    TMutationData,
    TMutationError,
    TQueryData, // input to mutate is the same as the output of the query
    TMutationContext
  >;
}) {
  const [draft, setDraft] = useState<TQueryData | undefined>(undefined);

  // create a stable ref to the draft so we can memoize the save function
  const draftRef = useRef<TQueryData | undefined>(undefined);
  draftRef.current = draft;

  const queryResult = useQuery(queryOptions);

  // we provide options to useMutation that optimistically update our state
  const mutationResult = useMutation(mutationOptions);

  const { mutate } = mutationResult;

  // return a stable save function
  const save = useCallback(() => {
    if (draftRef.current !== undefined) {
      mutate(draftRef.current);
    }
  }, [mutate]);

  return {
    save,
    setDraft,
    /*...*/
    mutationResult,
  };
}

The consumer of this hook can use the save function to save the draft to the server.

To make this concrete let's look at an example of how to use the hook in its current state.

import React from "react";
import { useReactQueryAutoSync } from "./useReactQueryAutoSync";

function HookDemo() {
  const { draft, setDraft, save } = useReactQueryAutoSync(
    {
      queryKey: "foo",
      queryFn: () => {
        /* omitted */
      },
    },
    {
      mutationFn: async () => {
        /* omitted */
      },
    }
  );
  return (
    <>
      <input
        type="text"
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
      />
      <button onClick={() => save()}>Save</button>
    </>
  );
}

We treat the draft and setDraft values just like the result of a useState hook and when we want to save the draft value to the server we call save.

Before we go on I have to admit that the previous hook omitted some important implementation details from useMutation. Whenever we perform a mutation we want to use optimistic updates. Optimistic updates assume the save succeeds but roll back the state if the save fails. They allow the user to ignore the delay between sending data to the server and receiving a response.

export function useReactQueryAutoSync</*...*/>({}: /*...*/
{
  /*...*/
}) {
  /*...*/

  const queryClient = useQueryClient();
  const queryKey = queryOptions.queryKey!;

  // we provide options to useMutation that optimistically update our state
  const mutationResult = useMutation({
    ...mutationOptions,
    onMutate: async (draft) => {
      // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
      await queryClient.cancelQueries(queryKey);
      // Snapshot the last known server data
      const previousData = queryClient.getQueryData(queryKey);
      // optimistically set our known server state to the new data
      queryClient.setQueryData(queryKey, draft);
      // optimistically clear our draft state
      setDraft(undefined);
      // Return a context object with the snapshotted value
      return {
        previousData,
        ...mutationOptions.onMutate?.(draft),
      } as any;
    },
    onError: (err, draft, context) => {
      // reset the server state to the last known state
      queryClient.setQueryData(queryKey, (context as any).previousData);
      // reset the draft to the last known draft unless the user made more changes
      if (draft !== undefined) {
        setDraft(draft as any);
      }
      return mutationOptions.onError?.(err, draft, context);
    },
    onSettled: (data, error, variables, context) => {
      // refetch after error or success:
      queryClient.invalidateQueries(queryKey);
      return mutationOptions?.onSettled?.(data, error, variables, context);
    },
  });

  /*...*/

  return {
    /*...*/
  };
}

These changes look complicated but if you check out the optimistic updates guide you'll see that they are essentially boilerplate. We augment the boilerplate with a couple lines to manage our draft state.

As a nice side effect, we can assume that if the draft value is undefined then all local changes have been saved to the server. If you look at our save function you can see that we use this guarantee to avoid calling mutate unnecessarily.

const save = useCallback(() => {
  if (draftRef.current !== undefined) {
    mutate(draftRef.current);
  }
}, [mutate]);

Now that we have a way to manually save the draft to the server we can implement an autosave interface. Because autosave interfaces are often used for documents we can assume that our draft value is a document and may change frequently and be relatively expensive to save. We don't want to clobber the server with changes and save on every keystroke so we'll use a debounce function to only save after a certain amount of time has passed between changes.

import debounce from "lodash.debounce";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  QueryKey,
  useMutation,
  UseMutationOptions,
  useQuery,
  UseQueryOptions,
} from "react-query";

/**
 * Empty function used to avoid the overhead of `lodash.debounce` if autoSaveOptions are not used.
 */
const EmptyDebounceFunc = Object.assign(() => {}, {
  flush: () => {},
  cancel: () => {},
});

export interface AutoSaveOptions {
  wait: number;
  maxWait?: number;
}

export function useReactQueryAutoSync</*...*/>({
  /*...*/
  autoSaveOptions,
}: {
  /*...*/
  autoSaveOptions?: AutoSaveOptions;
}) {
  /*...*/

  // return a stable save function
  const save = useCallback(() => {
    if (draftRef.current !== undefined) {
      mutate(draftRef.current);
    }
  }, [mutate]);

  // memoize a debounced save function
  const saveDebounced = useMemo(
    () =>
      autoSaveOptions?.wait === undefined
        ? EmptyDebounceFunc
        : debounce(save, autoSaveOptions?.wait, {
            // only pass maxWait to the options if maxWait is defined
            // if maxWait is undefined it is set to 0
            ...(autoSaveOptions?.maxWait !== undefined
              ? { maxWait: autoSaveOptions?.maxWait }
              : {}),
          }),
    [autoSaveOptions?.maxWait, autoSaveOptions?.wait, save]
  );

  // clean up saveDebounced on unmount to avoid leaks
  useEffect(() => {
    const prevSaveDebounced = saveDebounced;
    return () => {
      prevSaveDebounced.cancel();
    };
  }, [saveDebounced]);

  // call saveDebounced when the draft changes
  useEffect(() => {
    // check that autoSave is enabled and there are local changes to save
    if (autoSaveOptions?.wait !== undefined && draft !== undefined) {
      saveDebounced();
    }
  }, [saveDebounced, draft, autoSaveOptions?.wait]);

  // create a function which saves and cancels the debounced save
  const saveAndCancelDebounced = useMemo(
    () => () => {
      saveDebounced.cancel();
      save();
    },
    [save, saveDebounced]
  );

  return {
    save: saveAndCancelDebounced,
    setDraft,
    draft: draft ?? queryResult.data,
    queryResult,
    mutationResult,
  };
}

We provide an autoSaveOptions parameter which is used to set the delay and max delay for our debounce1. We then use these parameters to create a debounced version of our save function 2. If the user does not provide an autoSaveOptions parameter we use the EmptyDebounceFunc which is a no-op function that replicates the lodash.debounce api. We make sure to clean up our debounce function when the component unmounts to avoid memory leaks. Most importantly we call saveDebounced when the draft value changes.

You may have noticed that we created a draftRef so that the save function does not have to list draft as one of its dependencies. This is important because otherwise the debounced save function would have to be recreated whenever the draft value changes which is both expensive and breaks debounce functionality such as maxDelay.

When the user manually saves the draft value with save we cancel the debounced save function to avoid double saves.

At this point, the hook can already be used to create autosave interfaces. However, there are still user experience issues we can address. We may want to prevent the user from leaving the page if there are outstanding local changes.

/*...*/

export function useReactQueryAutoSync</*...*/>({
  /*...*/
  alertIfUnsavedChanges,
}: {
  /*...*/
  alertIfUnsavedChanges?: boolean;
}) {
  /*...*/

  // create a function which saves and cancels the debounced save
  const saveAndCancelDebounced = /*...*/

    // confirm before the user leaves if the draft value isn't saved
    useEffect(() => {
      const shouldPreventUserFromLeaving =
        draft !== undefined && alertIfUnsavedChanges;

      const alertUserIfDraftIsUnsaved = (e: BeforeUnloadEvent) => {
        if (shouldPreventUserFromLeaving) {
          // Cancel the event
          e.preventDefault(); // If you prevent default behavior in Mozilla Firefox prompt will always be shown
          // Chrome requires returnValue to be set
          e.returnValue = "";
        } else {
          // the absence of a returnValue property on the event will guarantee the browser unload happens
          delete e["returnValue"];
        }
      };

      // only add beforeUnload if there is unsaved work to avoid performance penalty
      if (shouldPreventUserFromLeaving) {
        window.addEventListener("beforeunload", alertUserIfDraftIsUnsaved);
      }
      // document.addEventListener("visibilitychange", saveDraftOnVisibilityChange);
      return () => {
        if (shouldPreventUserFromLeaving) {
          window.removeEventListener("beforeunload", alertUserIfDraftIsUnsaved);
        }
        // document.removeEventListener("visibilitychange", saveDraftOnVisibilityChange);
      };
    }, [alertIfUnsavedChanges, draft, saveAndCancelDebounced]);

  return {
    /*...*/
  };
}

We use the beforeUnload event to warn the user if they are leaving the page with unsaved changes3.

As one last step, let's see if we can handle merging background updates from the server with the outstanding local changes to the draft. We can do this by passing a merge function and merging the server and local state if they are both defined.

export type MergeFunc<TQueryData> = (
  remote: TQueryData,
  local: TQueryData
) => TQueryData;

export function useReactQueryAutoSync</*...*/>({
  /*...*/
  merge,
}: {
  /*...*/
  merge?: MergeFunc<TQueryData>;
}) {
  /*...*/

  // merge the local data with the server data when the server data changes
  useEffect(() => {
    const serverData = queryResult.data;
    if (serverData !== undefined && merge !== undefined) {
      setDraft((localData) => {
        if (localData !== undefined) {
          return merge(serverData, localData);
        }
      });
    }
  }, [merge, queryResult.data]);

  return {
    /*...*/
  };
}

With this change, ReactQuery can be used to poll the server for updates (see refetchInterval in the useQuery API) and the merge function can be used to merge the server state into the client state.

You may be wondering if the merge function would enable concurrent editing. Concurrent editing is outside the scope of this post, but there are probably better ways to implement concurrent editing than polling. If you are interested in more robust solutions for concurrent editing you could check out CRDT implementations such as yjs or Automerge or real-time collaboration APIs such as replicache

The hook is now complete and you should be able to use it to build your own autosave interfaces. While it may seem straightforward the code shown here is the result of several iterations and redesigns. If you want to see all the code for this hook in one place you can check out the project on GitHub or just the code for this hook with some minor changes and updates.

Footnotes

  1. See lodash.debounce for documentation on maxDelay and delay parameters.

  2. We could have used useCallback to create the debounce function but then we would call lodash.debounce on every render. useMemo is more performant and only calls lodash.debounce when one of the dependencies changes. Check out Kent C. Dodd's article comparing useMemo and useCallback if you're interested in learning more about this.

  3. Note that best practices recommend that you only add a beforeUnload event listener when the user has unsaved changes. Our hook follows these practices by adding the event listener when the user has unsaved changes and properly cleaning up the event listener in useEffect.