Autosave is something that's more or less expected by users these days. Imagine you are using something like Notion, or Google Docs, you might expect your work to get saved as you go.
When I put a nice fancy WYSIWYG editor in a ReactJS application, I knew autosave would be a nice feature to add. But it took me a few attempts to get it right!
I had a few options:
onBlur
setTimeout
setInterval
onBlur
made sense for multiple inputs, but not for one big WYSIWYG editor where someone might be editing content for >30 minutes, I need to save content more frequently for that!
setTimeout
could work, for example, running every time the content changes. But if I do it immediately, that's a lot of network requests. I want it to run more like every 30 seconds or every minute. And if I create a timeout to run every minute after a change, unless the user pauses typing for a minute at a time, it might not run at all!
That left setInterval
as my only hope!
Using setInterval
in React.useEffect
Since setInterval
is a side effect, and I don't want squillions of these things running forever, I'll need to remove the interval when it's no longer needed in my app - e.g. the user navigates away from the component using it.
Since it's a side effect that needs a cleanup, we'll use useEffect
.
Attempt #1 (the broken version)
My first attempt at autosave didn't go very well. I actually thought this worked - and it kinda did - just not in the way that I needed it to work.
// autosave
React.useEffect(() => {
const autosave = setInterval(function() {
if (changes !== saved) {
handleSave(); // autosave if any changes
}
}, 60 * 1000); // runs every minute
return () => {
clearInterval(autosave); // clear autosave on dismount
};
}, [changes, saved, handleSave]);
This did autosave every minute, but only if no further changes were made during that minute. Just like setTimeout
would have.
Whyyyyyyyyy?!?!?!
Because each time any of the dependencies [changes, saved, handleSave]
changed (I'm looking at you changes
), the interval would be cleared and restarted.
That's right - it only ran the autosave once the WYSIWYG editor had been dormant for 1 minute. Someone could be typing away happily for 20 minutes, and no save would take place!
And that's not what I wanted!
But I couldn't get around it. Because if I didn't pass changes
and saved
as dependencies, the useEffect
would only have access to the original values and not any updated state. I wouldn't know if there were any changes that needed saving - or be able to save them!
It was a catch-22.
Or so I thought.
Attempt #2 (the better version)
Ok, so I accepted that my original attempt wasn't going to work. Here's what I came up with for attempt number 2.
const [autosave, setAutosave] = React.useState(false); // to trigger checking if needs to autosave
// toggle autosave on every minute
React.useEffect(() => {
const autosave = setInterval(function() {
setAutosave(true);
}, 60 * 1000); // runs every minute
return () => {
setAutosave(false); // turn autosave off
clearInterval(autosave); // clear autosave on dismount
};
}, []);
// autosave if changes
React.useEffect(() => {
if (autosave && changes !== saved) {
handleSave();
setAutosave(false); // toggle autosave off
}
}, [autosave, changes, saved, handleSave]); // reset when lesson changes
I was pretty happy with this one!
By putting the useInterval
into its own useEffect
, it can just focus on what it needs to do - running a function every minute. And in this case, all that function does is set the new autosave
state to true
.
// toggle autosave on every minute
React.useEffect(() => {
const autosave = setInterval(function() {
setAutosave(true);
}, 60 * 1000); // runs every minute
return () => {
setAutosave(false); // turn autosave off
clearInterval(autosave); // clear autosave on dismount
};
}, []);
Then, there's a new useEffect
that will run whenever autosave
or changes
update, and if autosave
is true
and there are changes to be saved, it will run the handleSave
function.
// autosave if changes
React.useEffect(() => {
if (autosave && changes !== saved) {
handleSave();
setAutosave(false); // toggle autosave off
}
}, [autosave, changes, saved, handleSave]); // reset when lesson changes
Nice! Now we have an interval that runs every minute and can save the latest changes.
Creating a useAutosave
custom hook
Since I needed this autosave functionality in a couple of components, I figured I should create a custom hook to do the job. And I actually made some improvements along the way.
First, I found this blog post on using setInterval in React by Dan Abramov, which I recommend is worth a read if you use setInterval
with React. In fact, it heavily inspired all of the improvements I've made with my custom useAutosave
hook.
While I was happy with my original solution, and it worked, my new useAutosave
custom hook is far more reusable - for more than just autosaving (if you need setInterval
for other things too).
Let's get down to the nitty-gritty.
Here's the code inside useAutosave.hook.js
.
import React from 'react';
function useAutosave(callback, delay = 1000, deps = []) {
const savedCallback = React.useRef(); // to save the current "fresh" callback
// keep callback ref up to date
React.useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// create the interval
React.useEffect(() => {
// function to call the callback
function runCallback() {
savedCallback.current();
};
if (typeof delay === 'number') {
// run the interval
let interval = setInterval(runCallback, delay);
// clean up on unmount or dependency change
return () => clearInterval(interval);
}
}, [delay, ...deps]);
};
export default useAutosave;
Ok, some big changes here! Thanks to useRef
and Dan's blog post, I've been able to get rid of the double useEffect
. That wasn't going to work in a custom hook, because I want it to be reusable, I wouldn't necessarily know what state I need to keep fresh inside my useEffect
.
Now the useAutosave
hook doesn't need to know about any state in my components. Just pass it a callback
function, and a delay
and off it goes, running every 30 seconds (by default) or whatever interval you give it.
And you can pause/stop it by setting the delay
to false.
Here's how you can use the autosave hook.
import useAutosave from './useAutosave.hook';
const MyComponent = () => {
// runs autosave every minute
useAutosave(() => {
handleSave();
}, 60 * 1000);
//...
};
The useAutosave
hook also takes a dependency array deps
. This isn't for any data it needs, but so you can reset the interval for any reason.
Let's say you had several textarea
inputs. And if the user switched to a different input you wanted the autosave to restart. Otherwise, if the user switched to a new input after 25 seconds, the autosave would run on the new input in 5 seconds.
To reset the interval, I can pass an id
in a dependency array deps
which is an optional argument to the custom hook.
useAutosave(() => {
handleSave();
}, 60 * 1000, [id]);
You may or may not need this depending on your use case.
And finally, you can use the useAutosave
hook for anything you need an interval for, not just autosaving! So you can rename useAutosave
to useInterval
if you like!