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.