Auto-Saving is a way to ensure that data provided in a form gets persisted without the need to click on "Send" or "Submit". You might have come across multiple websites that implement auto-saving before.
In our last blog post, we discussed some guidelines on how to design an auto-saving form correctly. However, discussing these guidelines is far easier than actually implementing them.
In this article, we will be implementing an auto-saving form that conforms to the guidelines we specified in our last blog post. Our form will be a profile page for a given user.
Let’s go!
Getting Started
Requirements:
- NodeJS (I am using Node 18 but other versions should work as well)
We will start from scratch, but if you are in a hurry, you may want to clone our repository (rhian-cs/cm42-blogpost-react-autosave-reference), git checkout
to the before
branch, and skip to the "Adding a Naive Auto-Saving Implementation" section of the article.
For this tutorial, we’ll be using React as the front-end library and Vite as the build tool. We’ll be using bulma to add styling and react-icons for the icons.
Also, just so we have a back-end to work with, we’ll be using json-server.
To get started, create the Vite app with:
npm create vite@latest react-autosave -- --template react
cd
into the directory and add the dependencies:
cd react-autosave
npm i axios bulma react-icons date-fns
npm i --save-dev json-server
Setting Up the API
Before we start implementing the UI, let’s first set up our back-end. We will use a tmp/
directory to store our database as well as an empty version of it.
Create a file named server.js
under src/
:
// src/server.js
import jsonServer from "json-server";
const server = jsonServer.create();
const router = jsonServer.router("tmp/db.json");
const middlewares = jsonServer.defaults();
const PORT = 4000;
server.use(middlewares);
server.use(jsonServer.bodyParser);
server.use((req, res, next) => {
if (["PUT", "PATCH"].includes(req.method)) {
req.body.updatedAt = Date.now();
}
next();
});
server.use(router);
server.listen(PORT, () => {
console.log(`JSON Server is running on port ${PORT}.`);
});
Create a tmp/
directory and inside of it add a file called db.example.json
:
{
"profile": {
"name": "",
"gender": "other",
"description": ""
}
}
Now configure your package.json
to run the API. Add an api
key inside the scripts section. It should look like this:
{
// Other keys
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"api": "cp tmp/db.example.json tmp/db.json && node src/server.js" // <-- Add this key
}
// Other keys
}
You can now run the API with the command:
npm run api
Test the API by making a PUT
request to it, as shown in the cURL command:
curl -X PUT http://localhost:4000/profile -H 'Content-Type: application/json' -d '{"name": "My Name", "gender": "other", "description": "I love JSON."}'
After that request, the contents of the db.json
file should have been updated to something like this:
{
"profile": {
"name": "My Name",
"gender": "other",
"description": "I love JSON.",
"updatedAt": 1693918868108
}
}
If you’re using Git, you’ll most likely want to add the tmp/
directory to .gitignore
. Don’t forget to forcefully add tmp/db.example.json
before committing, though.
git add -f tmp/db.example.json
You may want to keep the server running in a separate terminal tab since we are going to use it soon.
Setting Up Styling with Bulma
We’re now just going to clean up some sample code generated by Vite so that we can use our own styling.
First, remove the following files:
- src/App.css
- src/assets/react.svg
- src/index.css
Replace the contents of src/main.jsx
with:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "bulma/css/bulma.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
And the contents of src/App.jsx
with:
function App() {
return (
<div className="container mt-5">
<h1>Hello, world!</h1>
</div>
);
}
export default App;
Now we can run the app via the command:
npm run dev
After that, the app can be accessed at http://localhost:5173/.
Creating a Simple Form
It’s time to create a form for our profile. Let’s start by creating a static form without any state.
Create a file at src/components/ProfileForm.jsx
:
export const ProfileForm = () => {
return (
<div className="card">
<div className="card-content">
<h1 className="title has-text-centered">Profile Form</h1>
<form>
<div className="columns">
<div className="column">
<div className="field">
<label className="label" htmlFor="profileName">
Name
</label>
<div className="control">
<input
className="input"
type="text"
name="profileName"
id="profileName"
/>
</div>
</div>
</div>
<div className="column">
<div className="field">
<label className="label" htmlFor="profileGender">
Gender
</label>
<div className="control">
<div className="select">
<select name="profileGender" id="profileGender">
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div className="field">
<div className="control">
<label className="label" htmlFor="profileDescription">
About Me
</label>
<textarea
className="textarea"
name="profileDescription"
id="profileDescription"
/>
</div>
</div>
<input
type="submit"
value="Save Profile"
className="button is-link"
/>
</form>
</div>
</div>
);
};
Then we can import it in src/App.jsx
:
import { ProfileForm } from "./components/ProfileForm";
function App() {
return (
<div className="container mt-5">
<ProfileForm />
</div>
);
}
export default App;
Here’s how our app is coming together:
However, we can see here that the <select /> input is looking a bit wonky. We can fix that by overriding Bulma’s CSS. Create a file such as src/bulma-overrides.css
with the contents:
.select,
.select select {
width: 100%;
}
And then in your App.jsx
, import the file we created:
import "./bulma-overrides.css";
Now, that’s much better!
Adding Manual Form Functionality
Now that the inputs are set up, we can start dealing with state and persisting data in the back-end.
Let’s add some state to our form. Inside the ProfileForm.jsx
file, add:
import { useEffect, useState } from "react";
const EMPTY_PROFILE = {
name: "",
gender: "other",
description: "",
};
export const ProfileForm = () => {
const [profile, setProfile] = useState({ ...EMPTY_PROFILE });
const handleAttributeChange = (attribute, value) => {
const newProfile = { ...profile, [attribute]: value };
setProfile(newProfile);
};
// ...
};
Replace the Profile Name input with the following:
<input
className="input"
type="text"
name="profileName"
id="profileName"
value={profile.name}
onChange={(e) => {
handleAttributeChange("name", e.target.value);
}}
/>
Then, replace the Profile Gender input with the following:
<select
name="profileGender"
id="profileGender"
value={profile.gender}
onChange={(e) => {
handleAttributeChange("gender", e.target.value);
}}
>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
Finally, replace the Profile Description input with the following:
<textarea
className="textarea"
name="profileDescription"
id="profileDescription"
value={profile.description}
onChange={(e) => {
handleAttributeChange("description", e.target.value);
}}
/>
By now your form should be working exactly as before, but it’s now using state.
Let’s add a layer to fetch and push data to the back-end. Create a file called src/apiClient.js
, with the contents:
import axios from "axios";
const API_URL = "http://localhost:4000";
export const getProfile = async () => {
const response = await axios.get(`${API_URL}/profile`);
return response.data;
};
export const updateProfile = (profile) => {
return axios.put(`${API_URL}/profile`, profile);
};
We can then import these functions and use them to interact with the back-end:
import { getProfile, updateProfile } from "../apiClient";
// ...
export const ProfileForm = () => {
// ...
const handleSubmit = async (e) => {
e.preventDefault();
await updateProfile(profile);
};
useEffect(() => {
getProfile().then((fetchedProfile) => {
setProfile(fetchedProfile);
});
}, []);
// ...
};
Finally, attach the submit handler to your form, like so:
<form onSubmit={handleSubmit}>...</form>
At this point, your ProfileForm
component should look like this:
import { useEffect, useState } from "react";
import { getProfile, updateProfile } from "../apiClient";
const EMPTY_PROFILE = {
name: "",
gender: "other",
description: "",
};
export const ProfileForm = () => {
const [profile, setProfile] = useState({ ...EMPTY_PROFILE });
const handleAttributeChange = (attribute, value) => {
const newProfile = { ...profile, [attribute]: value };
setProfile(newProfile);
};
const handleSubmit = async (e) => {
e.preventDefault();
await updateProfile(profile);
};
useEffect(() => {
getProfile().then((fetchedProfile) => {
setProfile(fetchedProfile);
});
}, []);
return (
<div className="card">
<div className="card-content">
<h1 className="title has-text-centered">Profile Form</h1>
<form onSubmit={handleSubmit}>
<div className="columns">
<div className="column">
<div className="field">
<label className="label" htmlFor="profileName">
Name
</label>
<div className="control">
<input
className="input"
type="text"
name="profileName"
id="profileName"
value={profile.name}
onChange={(e) => {
handleAttributeChange("name", e.target.value);
}}
/>
</div>
</div>
</div>
<div className="column">
<div className="field">
<label className="label" htmlFor="profileGender">
Gender
</label>
<div className="control">
<div className="select">
<select
name="profileGender"
id="profileGender"
value={profile.gender}
onChange={(e) => {
handleAttributeChange("gender", e.target.value);
}}
>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div className="field">
<div className="control">
<label className="label" htmlFor="profileDescription">
About Me
</label>
<textarea
className="textarea"
name="profileDescription"
id="profileDescription"
value={profile.description}
onChange={(e) => {
handleAttributeChange("description", e.target.value);
}}
/>
</div>
</div>
<input
type="submit"
value="Save Profile"
className="button is-link"
/>
</form>
</div>
</div>
);
};
Adding a Naive Auto-Save Implementation
So, with our form all set, let’s implement a really basic auto-saving feature.
We can use a React hook to isolate the auto-save responsibility. That way, we can also reuse this behavior for other forms.
Create a file at src/hooks/useAutoSave.js
, with the following contents:
import { useState } from "react";
const AUTOSAVE_DEBOUNCE_TIME = 2000;
export const useAutoSave = ({ onSave }) => {
const [autoSaveTimer, setAutoSaveTimer] = useState(null);
const dispatchAutoSave = (formData) => {
clearTimeout(autoSaveTimer);
const timer = setTimeout(() => onSave(formData), AUTOSAVE_DEBOUNCE_TIME);
setAutoSaveTimer(timer);
};
const triggerManualSave = (formData) => {
clearTimeout(autoSaveTimer);
onSave(formData);
};
return { dispatchAutoSave, triggerManualSave };
};
There’s quite a lot to unpack here, so let’s inspect this mechanism from the outside-in.
This hook exposes two functions: dispatchAutoSave
and triggerManualSave
.
The dispatchAutoSave
function will "schedule" the form to be submitted 2 seconds from now, using a timeout. The time is defined in the AUTOSAVE_DEBOUNCE_TIME
constant.
The hook also keeps track of the timer in a state hook so that it clears it before dispatching a new timeout. This is important, because the dispatchAutoSave
function might be called on every keystroke. If we don’t clear this timeout, the form might be submitted dozens, if not hundreds of times in a single minute.
The triggerManualSave
function causes the form to be submitted right away. It also clears the timer, so that the auto-save mechanism doesn’t cause the form to be saved twice.
Let’s call this new hook from inside our component:
import { useAutoSave } from "../hooks/useAutoSave";
export const ProfileForm = () => {
const [profile, setProfile] = useState({ ...EMPTY_PROFILE });
// Add this hook call
const { dispatchAutoSave, triggerManualSave } = useAutoSave({
onSave: updateProfile,
});
// ...
};
Dispatch an auto-save right after an attribute is changed. We can edit the handleAttributeChange
function for that:
const handleAttributeChange = (attribute, value) => {
const newProfile = { ...profile, [attribute]: value };
dispatchAutoSave(newProfile); // Add this line
setProfile(newProfile);
};
Lastly, let’s change the handleSubmit
function to use the triggerManualSave
function instead of calling the updateProfile
function directly:
const handleSubmit = (e) => {
e.preventDefault();
triggerManualSave(profile);
};
And there you have it! The form is now auto-saving 2 seconds after we type something in it or change the Gender selection.
Adding a Save Status Component to our Form
By looking at our form, we don’t know if our data has been saved or not. The only way to know currently is by looking at the browser DevTools, which the end user is unlikely to do.
Luckily, our back-end assigns an updatedAt
attribute to our Profile, which we can use to display information about the form to the user. This attribute can be present when we make a GET
request to fetch the profile, but it will also be present when we make a PUT
request to update profile.
In our ProfileForm
component, let’s define a new state variable to store the last time the record was saved:
export const ProfileForm = () => {
const [profile, setProfile] = useState({ ...EMPTY_PROFILE });
const [lastSavedAt, setLastSavedAt] = useState(null); // Add this state hook
};
Then, let’s ensure that fetching from the back-end allows us to set this value correctly. Update the useEffect
call to be like this:
useEffect(() => {
getProfile().then((fetchedProfile) => {
setProfile(fetchedProfile);
setLastSavedAt(fetchedProfile.updatedAt);
});
}, []);
Now, we have to ensure that we get the right updatedAt
value when we submit the form. Instead of passing the updateProfile
function directly to the useAutoSave
hook, let’s create a wrapper for it instead and then pass it to the hook.
// Create this function
const onSave = async (profile) => {
const { data } = await updateProfile(profile);
setLastSavedAt(data.updatedAt);
};
// Inject it into the hook as we did with the `updateProfile` function
const { dispatchAutoSave, triggerManualSave } = useAutoSave({
onSave: onSave,
});
This will ensure that the lastSavedAt
property is updated every time we submit the form.
Now, to show this "last saved" value, we should create a separate component. We will be naming it as SaveStatus
, and we will be rendering it like this: <SaveStatus savedAt={lastSavedAt} />.
We want to display this beside our submit button. We can use Bulma’s columns for that positioning. Locate the <input type="submit" … /> component in the form, and replace it with the following:
<div className="columns is-vcentered">
<div className="column is-narrow">
<input type="submit" value="Save Profile" className="button is-link" />
</div>
<div className="column is-narrow">
<SaveStatus savedAt={lastSavedAt} />
</div>
</div>
This won’t work for now since we don’t have the SaveStatus
component. So let’s create it right away.
Create a file at src/components/SaveStatus.jsx
. We’ll define multiple components in this file:
import { FaExclamationCircle, FaCheckCircle } from "react-icons/fa";
const SaveStatus = ({ savedAt }) => {
if (!savedAt) {
return <StatusIcon icon={FaExclamationCircle}>Not saved yet.</StatusIcon>;
}
return (
<StatusIcon icon={FaCheckCircle}>
Last saved <LastSavedAt savedAt={savedAt} />.
</StatusIcon>
);
};
const StatusIcon = ({ icon, children }) => {
return (
<div
style={{
display: "flex",
gap: "0.35em",
alignItems: "center",
}}
>
{icon({ fontSize: 20 })}
<span>{children}</span>
</div>
);
};
export { SaveStatus };
We will define the <LastSavedAt /> component in a moment. We want the "Not saved yet" message to be shown if a savedAt
value hasn’t been passed or is null/undefined. Otherwise, show that value in a formatted way. We can use date-fns
for that.
We’re also using this <StatusIcon /> component purely for decoration purposes, it’s simply a wrapper to show our icons from react-icons
.
In the same file, create a LastSavedAt
component:
import { formatDistance } from "date-fns";
import { useEffect, useState } from "react";
const LAST_UPDATED_AT_REFRESH_TIME = 5000;
const LastSavedAt = ({ savedAt }) => {
const [text, setText] = useState("");
useEffect(() => {
const updatedLastUpdatedText = () => {
if (!savedAt) return;
setText(formatLastSavedAt(savedAt));
};
updatedLastUpdatedText();
const interval = setInterval(
updatedLastUpdatedText,
LAST_UPDATED_AT_REFRESH_TIME
);
return () => clearInterval(interval);
}, [savedAt]);
return <span>{text}</span>;
};
const formatLastSavedAt = (savedAt) => {
const currentTime = new Date();
return formatDistance(savedAt, currentTime, {
addSuffix: true,
});
};
Now this has a bit of complexity. The idea is to show the "savedAt" text, always in comparison to the current time, like so: Last saved 5 minutes ago
.
We could just use the formatDistance
function, return the result in a span
and call it a day. But the problem is that it would become outdated, and the user would think it isn’t updating, since it will always show something like "Last saved a few seconds ago".
To fix this, we’re storing the text we want to return in a state hook (useState
). Then we’re using useEffect
to create an interval that will update that text every 5 seconds. We also have to run the function once to update the text manually so it doesn’t take 5 seconds to set the text on the first run.
Finally, let’s add the Save Status component to our form. In the src/components/ProfileForm.jsx
file, import the component:
import { SaveStatus } from "./SaveStatus";
We did it! Our component shows the form save status!
Adding More States to our SaveStatus Component
The User Experience has improved significantly after we created the SaveStatus component. However, there are a few things we can improve. For example, our component does not indicate the following:
- Whether or not the input the user has just typed is saved or not
- Whether or not the form is in the process of saving
Let’s address the first issue.
In the useAutoSaveHook
, create a state property called "isPendingSave":
export const useAutoSave = ({ onSave }) => {
// ...
const [isPendingSave, setIsPendingSave] = useState(false);
};
To make this change, we will have to change the way we’re saving the form. Instead of calling the onSave
function directly, we will create a function named triggerSave
in a moment. You’ll see why that’s necessary.
In the dispatchAutoSave
function, set the isPending
state to true. While doing that, replace the onSave
function call with triggerSave
:
const dispatchAutoSave = (formData) => {
clearTimeout(autoSaveTimer);
setIsPendingSave(true);
const timer = setTimeout(() => triggerSave(formData), AUTOSAVE_DEBOUNCE_TIME);
setAutoSaveTimer(timer);
};
In the triggerManualSave
function you can also set the isPendingSave
property to true:
const triggerManualSave = async (formData) => {
clearTimeout(autoSaveTimer);
setIsPendingSave(true);
await triggerSave(formData);
};
Finally, create the triggerSave
function we mentioned earlier:
const triggerSave = async (formData) => {
await onSave(formData);
setIsPendingSave(false);
};
The triggerSave
contains the logic that should execute the moment the form is saved. That goes for both the auto-saving process as well as a manual form submission.
We will also return the isPendingSave
property from our hook call:
return { dispatchAutoSave, triggerManualSave, isPendingSave };
Back in our ProfileForm
component, we can now use the property we created. In the hook call, add the isPending
property:
const { dispatchAutoSave, triggerManualSave, isPendingSave } = useAutoSave({
onSave,
});
And pass that property to the SaveStatus
component:
<SaveStatus savedAt={lastSavedAt} isPendingSave={isPendingSave} />
Go to the SaveStatus
component and ensure it accepts the isPendingSave
prop. After that, check if it’s true and return the "Not saved yet." span if it is:
const SaveStatus = ({ savedAt, isPendingSave }) => {
// Add the `isPendingSave` to this existing conditional
if (!savedAt || isPendingSave) {
return <StatusIcon icon={FaExclamationCircle}>Not saved yet.</StatusIcon>;
}
// ...
};
Nice! We did one more thing that improved the User Experience of our form.
Now let’s address the second issue: The form needs to show whether or not it is in the process of saving. That is simple to add due to the changes we just made.
Go to the useAutoSave
hook and create the following state property:
const [isSaving, setIsSaving] = useState(false);
Now let’s set this state in the triggerSave
function:
const triggerSave = async (formData) => {
setIsSaving(true);
await onSave(formData);
setIsSaving(false);
setIsPendingSave(false);
};
As usual, expose this property to the hook caller by returning it:
return { dispatchAutoSave, triggerManualSave, isPendingSave, isSaving };
In the ProfileForm
component, fetch the isSaving
property from the hook and pass it to the SaveStatus
component:
const { dispatchAutoSave, triggerManualSave, isPendingSave, isSaving } =
useAutoSave({ onSave });
<SaveStatus
savedAt={lastSavedAt}
isPendingSave={isPendingSave}
isSaving={isSaving}
/>
Finally, in the SaveStatus
component add the isSaving
property and another guard clause to handle it:
const SaveStatus = ({ savedAt, isPendingSave, isSaving }) => {
if (isSaving) {
return <StatusIcon icon={FaExclamationCircle}>Saving...</StatusIcon>;
}
// ...
};
Nice! Interact with the form to see it in action. If you can’t see the "Saving…" text it might be because your computer or network is too fast. Use network throttling as specified in the previous blog post to simulate network slowness.
(Open DevTools > Select the Network tab > Click on "No throttling" > Select "Slow 3G")
Adding Error Handling to Our Form
Currently, our form does not handle errors. If we try to submit it and it fails for whatever reason, it will just get stuck in "Saving…", which contributes to a bad User Experience.
Let’s add yet another state property to our useAutoSave
hook:
const [isError, setIsError] = useState(false);
Then, let’s change the save function logic to handle errors:
const triggerSave = async (formData) => {
setIsError(false);
setIsSaving(true);
try {
await onSave(formData);
setIsPendingSave(false);
} catch (e) {
setIsError(true);
} finally {
setIsSaving(false);
}
};
At the beginning, we’re ensuring the isError
property is set to false so we try (or retry) a request.
We will try executing the onSave
function. If it throws an error, we set the isError
property to true and the isSaving
property to false, so we don’t see the spinner anymore. If it goes smoothly, it will set the isPendingSave
and isSaving
properties to false, just like before.
Once again, return that property from the hook:
return {
dispatchAutoSave,
triggerManualSave,
isPendingSave,
isSaving,
isError,
};
In the ProfileForm
component, fetch this property from the hook and pass it to the SaveStatus
component.
const {
dispatchAutoSave,
triggerManualSave,
isPendingSave,
isSaving,
isError,
} = useAutoSave({ onSave });
<SaveStatus
savedAt={lastSavedAt}
isPendingSave={isPendingSave}
isSaving={isSaving}
isError={isError}
/>
In the SaveStatus
component, add another guard clause:
import {
FaExclamationCircle,
FaCheckCircle,
FaTimesCircle, // Import the new icon
} from "react-icons/fa";
// ...
const SaveStatus = ({ savedAt, isPendingSave, isSaving, isError }) => {
if (isError) {
return <StatusIcon icon={FaTimesCircle}>Save Error!</StatusIcon>;
}
// ...
};
And there you have it, the form can now handle generic errors.
Adding a Loading Spinner to Our Form
Lastly, to improve the User Experience of our form, let’s add a loading spinner to our form before it loads.
If you turn on Network Throttling and reload the form, you’ll see that it initially loads with blank fields. You can start typing, and after a while, the form will load the previously persisted data. There’s also a chance that the text you typed before has persisted in the back-end, even if it’s not shown in the interface anymore. This inconsistency is a source of confusion and should be eliminated.
Create a stylesheet for our spinner at src/components/Spinner.css
. I borrowed some code from w3docs to build the spinner:
/* Borrowed from: https://www.w3docs.com/snippets/css/how-to-create-loading-spinner-with-css.html */
@keyframes spinner {
0% {
transform: translate3d(-50%, -50%, 0) rotate(0deg);
}
100% {
transform: translate3d(-50%, -50%, 0) rotate(360deg);
}
}
.spinner::before {
animation: 1.5s linear infinite spinner;
animation-play-state: inherit;
border: solid 5px #cfd0d1;
border-bottom-color: #1c87c9;
border-radius: 50%;
content: "";
height: 40px;
position: absolute;
top: 10%;
left: 10%;
transform: translate3d(-50%, -50%, 0);
width: 40px;
will-change: transform;
}
Create a simple component for our spinner. Create a file at src/components/Spinner.jsx
with the contents:
import "./Spinner.css";
export const Spinner = () => {
return (
<div style={{ position: "relative" }}>
<div className="spinner"></div>
</div>
);
};
Now let’s go to our ProfileForm component. Import the Spinner component, as we’re going to use it in a moment:
import { Spinner } from "./Spinner";
Create an isLoaded
state, like so:
export const ProfileForm = () => {
// ...
const [isLoaded, setIsLoaded] = useState(false);
// ...
};
In the useEffect
that we’re loading the profile, set the isLoaded
state to true:
useEffect(() => {
getProfile().then((fetchedProfile) => {
setProfile(fetchedProfile);
setLastSavedAt(fetchedProfile.updatedAt);
setIsLoaded(true);
});
}, []);
Now let’s use the early return pattern to return a card with a loading spinner inside it until the profile is ready.
// Put this after all hook calls
if (!isLoaded) {
return (
<div className="card">
<div className="card-content">
<div className="columns is-centered is-vcentered">
<div className="column is-narrow my-5">
<Spinner />
</div>
</div>
</div>
</div>
);
}
// Keep the other return statement as it was
return ...
Finally, the form will display a loading state and there’s no chance of the user getting inconsistency errors due to the form not loading.
Wrapping Up
And there you have it! You can see that implementing auto-saving adds quite a bit of complexity and things to worry about. But if auto-saving is a requirement, this React implementation can provide you with some insights for your own implementation.
I hope this was useful to you. See you next time!
We want to work with you. Check out our "What We Do" section!