BlogReactJS

Autosave user input in ReactJS with setInterval (+ useAutosave custom hook)

Written by Codemzy on December 16th, 2022

Here's a custom hook for adding an autosave feature to ReactJS, without ending up with stale data. It uses setInterval to trigger the save, useEffect to keep things clean, and useRef to keep your save functions fresh.

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!