Schlez
2020

Preserving Form State in Refreshes and Navigation with React

Using the History API to store meaningful state that preserves across refreshes and page navigations.Published .

You probably witnessed it as well. You fill a form, you navigate somewhere else and then go back to realize all the form was cleared away. Maybe it was a search form or even a registration one. This shouldn't be the case: you can write a simple hook to preserve state in the browser history.

🀌tl;dr

You can skip all the words and try it live here. The code is live on CodeSandbox.

A demo of using the browser history to preserve stateA demo of using the browser history to preserve state


These aren't easy times. Ever since the COVID-19 pandemic stroke, I've been locked up to my apartment. Mainly working remotely and playing Overcooked 2 with my fiancΓ©e. Last week I had the chance to volunteer in a non-profit project, where I was writing some front-end code (forms!) in React. I haven't written actual front-end code for a while, which was a fresh breeze.

The form itself was split to 3 pages, or "stages", to make filling it easier:

The form was split to 3 different stagesThe form was split to 3 different stages

I started by coding every stage on its own. Adding navigation later should be easy and not a problem, thanks to React Router v5. This made my coding experience REALLY fast. When I got to navigation, using RRv5 was kinda easy. I decided to use the browser history state to pass the page results between pages. The browser history state is a way of passing data or storing data in the history stack itself, instead of adding a new global state to the app. So navigating looked like this:

import { useHistory } from "react-router-dom";

function MyComponent() {
  const history = useHistory();

  return (
    <button
      type="button"
      onClick={() => {
        history.push({
          pathname: "/next-page",
          state: {
            firstSectionResult: THE_VALUE_I_WANT_TO_PASS,
          },
        });
      }}
    >
      Next step
    </button>
  );
}

How does it work? Well, if you long-press the "back" button on your browser, you'd probably see your history for this specific tab. Using window.history.back() is a handy way to go back programmatically, but it's not the entire API that the browser gives us. It also gives us the power to manipulate the history stack with pushing a new item and replacing the current item. Each item on the history has a path name and state that you can manage. Most project I witnessed don't use the history state at all, despite it being a super powerful tool!

I also provided a hook to read the value off the history state:

import { useHistory } from "react-router-dom";

export function useFirstSectionResult(): FirstSectionResult | undefined {
  const history = useHistory();
  const result = history.location.state?.firstSectionResult;
  return result;
}

Now I could use the history state in the next page just by calling this hook. If I got undefined, I could redirect the users back to the first section so they will complete it. I saw that this idea works, and I was excited to try pressing the browser's "back" button to immediately press "next" to see that the state management actually works.

So I hit the "back button". The form that I just filled is empty. When I clicked "next" the second stage of the form worked as expected, but I was very annoyed by the fact that all the fields I filled is now completely empty.

Solving the State Preservation Problem

This is a problem we have in modern front-end apps which we hadn't when we were doing forms generated by some backend (like Rails or ASP) because the browsers do try to remember the values we filled in the forms! Because all the routing and rendering happens on the client side, way after the browser "rehydrates" its inputs, we lose all the state of the form.

This is clearly unacceptable because it's the worst user experience there is. I discussed with the people who volunteered with me regarding the issue and consulted Google. Seems like like most solutions are using localStorage, sessionStorage and indexedDB. I decided to go with sessionStorage because both localStorage and indexedDB sounded like a long-time cache and a session-long cache sounds appealing to me.

I decided to go and make a custom hook (useSessionState) that worked like useState, only read the initial value from sessionStorage, and wrote all changes to sessionStorage as well. Then, I made all form elements controlled by specifying onChange and value.

Thinking about the solution again, I didn't think it is good enough. The main problem I have with it, is that sessionStorage is consistent across different browser tabs/windows. It is basically a global cache. That means that you can't use the form in more than one tab(!). Not exactly what you expect from a web browser. Imagine you open multiple tabs and fill forms just to realize they override each other silently. Absurd!

This would also happen in localStorage and indexedDB because they too work like a global cache. So how can I still make the form work across different tabs while supporting refreshes and navigations?

Altering the History State

Remember the browser history state we have just used to provide state when navigating to a new page? We did it by calling the History push function. What if we could change the current page's navigation state? Apparently, this is possible using the replace function, which replaces the current item in the history stack instead of pushing a new one (which is the normal behavior of navigation). We can avoid altering the pathname (or URL) part, and only alter the state, like so:

history.replace({
  ...history.location,
  state: {
    ...history.location.state,
    SOME_ITEM: SOME_VALUE,
  },
});

This can be wrapped in a hook, to make it look and feel like the useState hook:

import { useHistory } from "react-router-dom";

function useHistoryState<T>(key: string, initialValue: T): [T, (t: T) => void] {
  const history = useHistory();
  const [rawState, rawSetState] = useState<T>(() => {
    const value = (history.location.state as any)?.[key];
    return value ?? initialValue;
  });
  function setState(value: T) {
    history.replace({
      ...history.location,
      state: {
        ...history.location.state,
        [key]: value,
      },
    });
    rawSetState(value);
  }
  return [rawState, setState];
}

Now, when we don't have any plain useState in our form, every form input (and even complex JSON objects) will be preserved across refreshes and page navigations without further thinking. This solution isn't just for people who use React Router. The attached CodeSandbox has a native implementation you can use anywhere. Make sure to open the preview in a new window because CodeSandbox's preview pane does not preserve history state.

You see, making forms didn't use to be hard. Making them work like they did while using modern front-end frameworks shouldn't be either. Implementing this simple setHistoryState makes us stop thinking about how to manage forms' state and lets us just write forms with a great experience. All thanks to the History API, which doesn't only allow us to do magnificent client-side routing, it also helps us preserve meaningful state for our pages. πŸŽ‰

A demo of using the browser history to preserve stateA demo of using the browser history to preserve state

Read more