DEV Community

loading...
Cover image for Persisting your React state in 9 lines

Persisting your React state in 9 lines

selbekk profile image selbekk Originally published at selbekk.io Updated on ・3 min read

I was playing around with a project from Frontend Mentor this weekend, and I was implementing this theme switcher with React hooks. It struck me that persisting which theme I had chosen between reloads would be a nice feature. So let's build a hook that provides just that!

This article will take you through the process of creating a reusable custom hook that persists our state to local storage.

Getting started

We're going to create a custom hook named usePersistedState to store our state to local storage. Our function should accept a key to store the state under, as well as the default value (in case we haven't saved anything yet). It will return the same API as useState (a tuple of the state and an updater function). Here's our hook signature:

function usePersistedState(key, defaultValue) {
  // Some magic
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

Even though we store our state in local storage, we're keeping a local runtime copy in a regular setState call. This is so that we can trigger re-renders, as well as improve access time slightly (accessing local storage might not be that quick). Finally, if localStorage is not available for some reason, we still have a working hook (although it won't persist the setting).

function usePersistedState(key, defaultValue) {
  const [state, setState] = React.useState(defaultValue);
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

Saving data in local storage

Next up, let's start reading from local storage! The localStorage API is built into your browser, and lets you access values by calling the getItem function with a string key.

function usePersistedState(key, defaultValue) {
  const [state, setState] = React.useState(
    localStorage.getItem(key) || defaultValue
  );
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

Here, we set the default value of our useState call to be whatever we've had stored in localStorage, or the defaultValue we passed in as an argument. Next, let's implement updating our local storage as well. We're going to use a useEffect hook for that:

function usePersistedState(key, defaultValue) {
  const [state, setState] = React.useState(
    localStorage.getItem(key) || defaultValue
  );
  useEffect(() => {
    localStorage.setItem(key, state);
  }, [key, state]);
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

Clever, right? Every time we update our state, we should update what's stored in our local storage. If the key changes, we'd want to store our current state under the new key as well.

What about complex values?

Although the local storage API is great, it can only store string values. This is kind of a pain - but we can get around this limitation by serializing our JavaScript objects to JSON whenever we update our state (and back again). We do this with the JSON.parse and JSON.stringify functions.

function usePersistedState(key, defaultValue) {
  const [state, setState] = React.useState(
    JSON.parse(localStorage.getItem(key)) || defaultValue
  );
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(state));
  }, [key, state]);
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

Now we support complex data structures too!

A last performance optimization

Our current implementation has one performance pitfall - we're reading from local storage on every render! To make matters worse - we're doing it just to get the initial value for our useState call! Luckily, there's a way around this sort of issue. By passing in a function to useState, the default value will only be run once!

Let's implement this:

function usePersistedState(key, defaultValue) {
  const [state, setState] = React.useState(
    () => JSON.parse(localStorage.getItem(key)) || defaultValue
  );
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(state));
  }, [key, state]);
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

Summing up!

And that's it! We've implemented a pretty neat piece of reusable code in a few lines of code. This is perfect for local settings like themes, font-sizes or whatever else UI state you'd like to persist between visits.

Here's the project I mentioned initially, complete with this very hook to save the selected theme. Try it out!

What is your favorite reusable hook?

Discussion (17)

pic
Editor guide
Collapse
mxmzb profile image
Maxim • Edited

Really nice, thank you Kristofer! This is a very pragmatic approach. I kind of like Redux, and you inspired me to add a construct React.useReducer on top of this. It might be just the perfect substitute for a full-blown react-redux + redux-persist setup.

Collapse
fredericocotrim profile image
fredericocotrim

Could you share your approuch using usePersistedState on useReducer?

Collapse
jcaguirre89 profile image
Cristobal Aguirre • Edited

I adapted both this post and this blog post by Swizec Teller to get what I wanted using useReducer. I'm making an ecommerce site and wanted to persist the cart items. So I did:

const initialState = {
  //other state
  cartItems: JSON.parse(localStorage.getItem('cart_items')) || [],
}

function reducer(state, action) {
  switch (action.type) {
    case 'UPDATE_CART': {
      const { variantId, quantity } = action.payload;
      const updatedCartItems = state.cartItems.filter(
        i => i.variantId !== variantId
      );
      const cartItems = [...updatedCartItems, { variantId, quantity }];
      // write to localStorage <-- key part
      if (typeof window !== 'undefined') {
        localStorage.setItem('cart_items', JSON.stringify(cartItems));
      }
      return {
        ...state,
        cartItems,
      };
    }

and then plugged that into context. If you need more context, I'm using gatsby and followed this guide to set up the state mgt. logic.

Hope it helps!

Collapse
knokit profile image
Ivo Silva

In case you're storing falsy values, you might want to initialize the state like this:

const [value, setValue] = useState(() => {
    const storedValue = localStorage.getItem(key);
    return storedValue !== null ? JSON.parse(storedValue) : defaultValue;
});
Enter fullscreen mode Exit fullscreen mode
Collapse
yuhanx0728 profile image
Yuhan Xiao

Hi @selbekk , nice article! Can you explain a little about the last section(about optimization)? How would

() => JSON.parse(localStorage.getItem(key))

inside useState() work? Wouldn't it not work, since it is declared, not invoked inside useState()?

Collapse
selbekk profile image
selbekk Author

Thanks!

When you pass a function as an argument to useState instead of a "regular" value, it is only run on the initial render. It’s a way React offers to do initialization work only once, instead of on each render.

Collapse
yinonhever profile image
yinonhever

Hey, can I use this code in a class-based component? Or can it only be used in a functional component whose state is managed with Hooks?

Collapse
selbekk profile image
selbekk Author

Hi! Hooks unfortunately require function components to work, but you could recreate the same functionality with either a HOC or just directly in your class component. In your componentDidUpdate, add a line that serializes and saves your state to local storage.

componentDidUpdate() {
  window.localStorage.setItem(
    my state key,
    JSON.stringify(this.state)
  );
}

When you initialize the state, you need to do the reverse:

constructor(props) {
  this.state = JSON.parse(
    window.localStorage.getItem(my state key)
  ) || { fallback: value };
}

Hope that helps!

Collapse
ndmax profile image
Nathan Maxwell

Hi @selbekk , thanks so much for your post and this comment reply. When I tried to implement this class-based version, I encountered the following (unfamiliar) error:

Error: Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}). If you meant to render a collection of children, use an array instead.

I'm trying to track down this error, but do you have any suggestions to point me in the right direction?

Thread Thread
selbekk profile image
selbekk Author

That happens when you try to render a regular object as children. I’d have to see more of the related code to be of any assistance

Thread Thread
ndmax profile image
Nathan Maxwell

Thanks @selbekk , for your reply. That was indeed the case - I was implementing the class-based version in the wrong place within the component hierarchy. The quick fix was simply to locally store only the single piece of state that needed to persist, rather than the entire state object. Works like a charm, and I'm grateful for this post.

Thread Thread
jesii profile image
Jon Seidel

Probably need to JSON.stringify that object to store the data in local storage.

Collapse
raarts profile image
Ron Arts

What if an Async Storage solution is being used, like AsyncStorage on react native?

Collapse
a_m_h_gad profile image
Jad

This was very helpful ☺️ thanks for sharing 💜

Collapse
yinonhever profile image
yinonhever

Great custom hook, I already used it in several projects. Are you gonna make it an NPM package?

Collapse
selbekk profile image
selbekk Author

For 9 lines of code? I’d rather just copy it. But feel free to make a package out of it yourself if you want to 🥳

Collapse
Sloan, the sloth mascot
Comment deleted