A common pattern found in many web apps is to automatically save after every user action. For instance, Google Docs saves changes after the user edits the document. Albeit a common pattern, there are different ways of implementing it, and each has its pros and cons.
Here, we discuss a solution that encapsulates the autosave feature in a reusable React component, recently implemented in our software platform. This component allows autosave to be triggered on (debounced) user’s actions. By autosave we mean saving the changes to the database by firing off an async GraphQL mutation.
A Possible Implementation: setInterval
In one of our products within the Synthace platform, the Workflow Editor, we had an autosave feature, which was implemented with the JavaScript function setInterval
. This meant that at a fixed interval (e.g. every second) we would check for changes and, should there be any, we would then fire off the GraphQL mutation to save those changes to the database.
However, this solution was not the most elegant. The timer event had to happen frequently to make the autosave feel instant enough. This meant performing unnecessary work whilst the user interface (UI) was idling. Plus, an extra variable had to be used to store the last state we sent to the server. This was then deep compared to the Redux state of the UI to determine whether there were any changes.
When the need to implement autosave arose in a new product we were building, the Cherry Picker, we had a chance to try a different solution. We will discuss this solution in detail next.
But before jumping into the implementation, let’s do a quick refresher on React Hooks, specifically focussing on conditionally firing a useEffect
hook. Feel free to skip this section if you are already familiar with the useEffect
hook.
The useEffect
Hook
“Hooks are functions that let you “hook into” React state and lifecycle features from function components.” (from the React Docs)
useEffect(() => { // Do something })
This hook is used to perform side effects inside function components. Our side effect will be a GraphQL mutation which saves the changes to the database. The side effect logic that needs to run is the first argument passed to the Hook.
This hook can also be triggered conditionally. This is achieved by passing a list of dependencies as a second argument to the Hook.
useEffect(() => { // Do something }, [someValue, someOtherValue]);
With the dependency list, the side effect will only run when at least one of the values in the dependency list changes. In the example above, the side effect will run when someValue
, someOtherValue
, or both change.
Autosave with React Hooks
Our solution is a React component that does not render anything on the screen, but listens to the user’s actions and fires a debounced query to persist the changes in the database. We debounce the query in order to limit the rate at which we send requests. For instance, if the user is typing the experiment name, we don’t want to send a GraphQL mutation at each keystroke. The component looks like this:
function Autosave({ experimentData }) { useEffect(() => { if (experimentData) { debouncedSave(experimentData); } }, [experimentData, debouncedSave]); // Do not display anything on the screen. return null; }
In the code above, experimentData
is our client-side state (e.g. Redux, React Context, or simply a component’s state, depending on your architecture). This object matches what is stored in the database. The experimentData.name
is a string that users can edit using a text input. The above useEffect
will fire only when experimentData.name
or debouncedSave
change.
This is a good start: when users change the experiment name, this Hook will run and call debouncedSave
with the new data, sending the mutation request in order to persist the change in the database.
Now let’s have a look at how to implement the debounced save:
const debouncedSave = debounce(async (newExperimentData) => { await saveExperimentDataToDb(newExperimentData); }, DEBOUNCE_SAVE_DELAY_MS) );
The code is straightforward: we save the updated experiment data in the database.
However, debouncedSave
will not actually do what we are expecting it to do. As it is now, the debounced function will be recreated at each re-render. This means that inside the useEffect
, we would be calling a different debouncedSave
function every time, thus not debouncing properly.
To overcome this problem, we can use another Hook: useCallback
. This Hook has the same arguments as useEffect
, but it returns a memoised version of the function we pass to it as the first argument. This function will be recreated only when some of the items in the dependency list change. In our case, we don’t want it to change, so we pass an empty list.
const debouncedSave = useCallback( debounce(async (newExperimentData) => { await saveExperimentDataToDb(newExperimentData); }, DEBOUNCE_SAVE_DELAY_MS), [], );
Now there is only one bit missing. So far, we haven’t written any code to fetch the experiment data from the database. This can be done in the parent component and passed to the Autosave component as props:
function ParentComponent() { // This could live in Redux instead, for example, depending on your architecture. // This UI state mirrors what's in the database. const [experimentData, setExperimentData] = useState( // Initial value fetched from the backend. ); return ( <> <TextInput onChange={onChangeName} value={experimentData.name} /> <Autosave experimentData={experimentData} /> // More UI components <> ) }
Putting it all together:
function Autosave({ experimentData }) { const debouncedSave = useCallback( debounce(async (newExperimentData) => { await saveExperimentDataToDb(newExperimentData); }, DEBOUNCE_SAVE_DELAY_MS), [], ); // The magic useEffect hook. This runs only when `experimentData.name` changes. // We could add more properties, should we want to listen for their changes. useEffect(() => { if (experimentData) { debouncedSave(experimentData); } // debouncedSave is wrapped in a useCallback with an empty dependency list, // thus it will not change and in turn will not re-trigger this effect. }, [experimentData, debouncedSave]); // Do not display anything on the screen. return null; }
This Autosave component does what we wanted: anytime a user performs an action that changes the data we store in the database, we will trigger a debounced GraphQL mutation.
Another advantage of this approach is that it’s easy to implement/remove the autosave functionality as needed. For instance, we could have a read-only version of the ParentComponent
, in which case we wouldn’t want to update the database. This can be achieved in one line:
{!isReadonly && <Autosave experimentData={experimentData} />}
The code can also be easily extended to autosave more properties. For instance, if we had a boolean flag experimentData.isFavourite
, the only change needed would be to add a UI component that allowed users to set said flag. The rest would remain unchanged.
function ParentComponent() { // ... return ( <> <TextInput onChange={onChangeName} value={experimentData.name} /> {/* The value of this new checkbox will be autosaved */} <CheckBox onChange={onChangeIsFavourite} value={experimentData.isFavourite} /> <Autosave experimentData={experimentData} /> <> ) }
Error Handling
As the astute reader might have noticed, something is missing from the code snippets and the sandboxes: error handling. In a real-world scenario, the call to saveExperimentDataToDb
could fail for various reasons, e.g. because of connectivity problems. We should then inform the user that the saving has failed.
In our Synthace app, we show an error message and allow the user to continue editing the local state. Once the connection is restored, we continue autosaving.
Conclusion
In this article, we presented a simple autosave implementation that leverages React Hooks. With little code, we can listen to user changes and persist those in the database.
If this post got you hooked, you might want to check the React Hooks documentation. Here is a sandbox with a working TypeScript version of the code from this article. We use TypeScript for our frontend and backend code at Synthace.
If you already have experience with React Hooks, you might be thinking that another way of implementing autosave could be using a custom Hook. Here is a sandbox with the same functionality, this time implemented with a custom useAutosave Hook.
Have you implemented a similar or a better strategy? Let us know! You can reach out to the author of this article via Twitter or contact our team via Twitter, LinkedIn, or email.
To find out more about engineering at Synthace, please click here.
*We're hiring!* You can view our latest engineering openings over on our careers page.