Auto-Saving Forms Done Right 2/2

Implement a good auto-saving form using React Hooks

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:

Buggy Form

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!

Complete Profile Form

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!

Form with 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")

Network Throttling in Chrome DevTools

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!