DEV Community

🌈 Josh
🌈 Josh

Posted on • Updated on • Originally published at joshwcomeau.com

Persisting React State in localStorage

This is a cross-post from my personal blog. View it there for at least 35% more whimsy!

Let's say we're building a calendar app, like Google Calendar. The app lets you toggle between three different displays: month, week, and day.

Quick screen-grab of Google Calendar, toggling between day and week views

Personally, I always want to see the "Week" view. It gives me everything I need to know about the current day, while also giving me a peek at what's coming up in the next couple of days.

Thankfully, calendar apps know that users have strong preferences around this kind of thing, and the toggle is sticky. If I switch from β€œweek” to β€œmonth” and refresh the page, the β€œmonth” view is the new default; it sticks.

Conversely, it's super annoying when form controls aren't sticky. For example: every month, I create 4-5 expenses through Expensify. Every single time, I have to swap the default currency from USD to CAD. Why can't it remember that I'm Canadian??

In this tutorial we'll see how we can create a custom React hook to abstract away the "stickiness", so we get it for free whenever we need it.

Show me the code

Here's what our custom hook looks like:

function useStickyState(defaultValue, key) {
  const [value, setValue] = React.useState(() => {
    const stickyValue =
      window.localStorage.getItem(key);

    return stickyValue !== null
      ? JSON.parse(stickyValue)
      : defaultValue;
  });

  React.useEffect(() => {
    window.localStorage.setItem(
      key, 
      JSON.stringify(value)
    );
  }, [key, value]);

  return [value, setValue];
}

What about SSR? If your app is server-rendered (with a framework like Next.js or Gatsby), you'll get an error if you try using this hook as-is.

This is actually a pretty tricky problem, because that first render on the server doesn't have access to your computer's localStorage; it can't possibly know what the initial value should be!

Dynamic content in a server-rendered app is a complex subject, but fortunately, my very next blog post will shed some light on this! Join my newsletter to make sure you don't miss it.

If this code isn't clear to you, fear not! The rest of this tutorial explains it in greater detail πŸ’«

In practice

This hook makes a single assumption, which is reasonably safe in React apps: the value powering a form input is held in React state.

Here's a non-sticky implementation of a form control to switch between values:

const CalendarView = () => {
  const [mode, setMode] = React.useState('day');

  return (
    <>
      <select onChange={ev => setMode(ev.target.value)}>
        <option value="day">Day</option>
        <option value="week">Week</option>
        <option value="month">Month</option>
      </select>

      {/* Calendar stuff here */}
    </>
  )
}

We can use our new "sticky" variant by swapping out the hook:

const CalendarView = () => {
  const [mode, setMode] = useStickyState('day', 'calendar-view');

  // Everything else unchanged
}

While the useState hook only takes 1 argumentβ€”the initial valueβ€”our useStickyState hook takes two arguments. The second argument is the key that will be used to get and set the value persisted in localStorage. The label you give it has to be unique, but it otherwise doesn't matter what it is.

How it works

Fundamentally, this hook is a wrapper around useState. It just does some other stuff too.

Lazy initialization

First, it takes advantage of lazy initialization. This lets us pass a function to useState instead of a value, and that function will only be executed the first time the component renders, when the state is created.

const [value, setValue] = React.useState(() => {
  const stickyValue =
    window.localStorage.getItem(key);

  return stickyValue !== null
    ? JSON.parse(stickyValue)
    : defaultValue;
});

In our case, we're using it to check for the value in localStorage. If the value exists, we'll use that as our initial value. Otherwise, we'll use the default value passed to the hook ("day", in our earlier example).

Keeping localStorage in sync

The final step to this is to make sure that we update localStorage whenever the state value changes. For that, our trusty friend useEffect comes in handy:

React.useEffect(() => {
  window.localStorage.setItem(name, JSON.stringify(value));
}, [name, value]);

If the state value changes rapidly (like, many times a second), you may wish to throttle or debounce the updates to localStorage. Because localStorage is a synchronous API, it can cause performance problems if it's done too rapidly.

Don't take this an excuse to prematurely optimize, though! The profiler will show you whether or not your updates need to be throttled.

Wrapping up

This hook is a small but powerful example of how custom hooks let us invent our own APIs for things. While packages exist that solve this problem for us, I think there's a lot of value in seeing how to solve these problems ourselves πŸ§™πŸ»β€β™‚οΈ

Special thanks to Satyajit Sahoo for a couple refactor suggestions 🌠

Oldest comments (4)

Collapse
 
fmgordillo profile image
Facundo Martin Gordillo • Edited

Awesome post!!

Additional things to consider, if someone wants to implement this:

1) If you are concern about variable type, you should make additional changes into useStickyState() by two ways: Detecting the variable inside the function or passing an additional arg (i.e: 'integer')
2) "The label you give it has to be unique", one option for that could be creating a Set outside the function, and control if that label is being created or not, and throw a warning in the code.

But that's too fancy and all, I really prefer your solution :)

And welcome to Dev.to ;)

Collapse
 
1000machines profile image
Andrzej Koper

Great post!
Seems like exactly what I need.
But unfortunately I can't get it to work with nextjs.
I probably miss something obvious. ;)
Have a look at this code sandbox ( codesandbox.io/s/nextjs-localstora... )
console shows correctly the value from localstorage but select tag is always set to first option.

Collapse
 
joshwcomeau profile image
🌈 Josh • Edited

Right! So I realized, my solution wasn't actually great for SSR 😬

It turns out this is a really hard problem for SSR. Here's a version that "works":

function useStickyState(defaultValue, key) {
  const [value, setValue] = React.useState(defaultValue);

  React.useEffect(() => {
    const stickyValue =
      window.localStorage.getItem(key);

    if (stickyValue !== null) {
      setValue(JSON.parse(stickyValue));
    }
  }, [key])

  React.useEffect(() => {
    window.localStorage.setItem(
      key,
      JSON.stringify(value)
    );
  }, [key, value]);

  return [value, setValue];
}

"Works" is in quotes because there's still a problem with this one, which is that the initial state still flashes. In your CodeSandbox, it briefly shows "Day" before being reset to the sticky value.

The ideal solution would be to hide this component until it's rendering on the client. It's impossible for the "initial" painted value to have the correct value, since the server has no idea what value is stored in your computer's localStorage.

Incidentally my next blog post will be on this subject, so I'll have more info then :D

Sorry for the confusion!

Collapse
 
1000machines profile image
Andrzej Koper

Oh. I see. this makes sense.
Looking forward to you blog post :)
cheers.