EthanG

How to Use Local Storage with React State

Author:
Last Updated:
Storage Container

Syncing local storage to useState seems to cause a lot of confusion for people new to React. With some of the questions I've seen around it, it makes me think it's a fantastic interview question to see if someone really understands the basics of React hooks and lifecycles. To explain what I mean let's look at a very common mistake.

This code will cause the value in local storage to reset to empty every time you refresh the page:

const [todos, setTodos] = useState<string[]>(() => {
  console.info('Set Initial State');
  return [];
});
const [newTodo, setNewTodo] = useState<string>('');

useEffect(() => {
  console.info('Get Initial Todos from Local Storage');

  const localTodos = localStorage.getItem('todos');

  if (localTodos !== null) {
    const parsed = JSON.parse(localTodos) as string[];
    setTodos(parsed);
  }
}, []);

useEffect(() => {
  console.info('Synchronize todos with local storage.');
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

const addTodo = (event: FormEvent): void => {
  event.preventDefault();

  setTodos(todos => {
    return [...todos, newTodo];
  });
};

If you look at console log you will see messages in this order:

  1. Set Initial State
  2. Get Initial todos from local storage
  3. Synchronize todos with local storage
  4. Get initial todos from local storage
  5. Synchronize todos with local storage

Yikes, bit of a mess right? No wonder we're not getting the results we want, what's going on here?

The todos are initially set to an empty array. The first useEffect runs and sets todos in local storage to that initial empty value. The third useEffect is run and sets the todos to that same initial value again from useState (which is an empty array). This all triggers the third useEffect one more time which causes a rerender and we override those todos once more.

...All we want to do is synchronize state to local storage, how do we clean this up?

The first thing to understand is that useState initial value is set on the initial render. This sounds obvious, but it's an underused tool. Often I see people always putting in a default empty value just to satisfy a type error, or just leave it empty.

useState's initial value is going to reset on every render whether you use an effect or not. So using an effect to achieve that goal is just creating a redundancy and running code twice. So we can get rid of the first useEffect and move the logic to useState. I'm using NextJS in this example so I need to check if window is defined.

const [todos, setTodos] = useState<string[]>(() => {
  if (typeof window === 'undefined') {
    return [];
  }

  console.info('Set Initial State');
  const localTodos = localStorage.getItem('todos');

  if (localTodos === null) {
    return [];
  }

  return JSON.parse(localTodos) as string[];
});
const [newTodo, setNewTodo] = useState<string>('');

useEffect(() => {
  console.info('Synchronize todos with local storage.');
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

const addTodo = (event: FormEvent): void => {
  event.preventDefault();

  setTodos(todos => {
    return [...todos, newTodo];
  });
};

Now our todos are properly synchronizing with state, if we refresh the page, the values are still there. But we still have a problem. This is what our console logs look like now:

  1. Set initial state
  2. Synchronize todos with local storage

Our last useEffect is running on initial render when really we don't want it to. The flow is something like this:

  1. Get todos from local storage
  2. Synchronize todos to local storage

Why do we need to synchronize immediately after we get todos the from local storage? Instead we can get rid of the useEffect altogether and only update after a todo is added.

const [todos, setTodos] = useState<string[]>(() => {
  if (typeof window === 'undefined') {
    return [];
  }

  console.info('Set Initial State');
  const localTodos = localStorage.getItem('todos');

  if (localTodos === null) {
    return [];
  }

  return JSON.parse(localTodos) as string[];
});
const [newTodo, setNewTodo] = useState<string>('');

const addTodo = (event: FormEvent): void => {
  event.preventDefault();

  console.info('Synchronize todos with local storage.');
  localStorage.setItem('todos', JSON.stringify(todos));

  setTodos(todos => {
    return [...todos, newTodo];
  });
};

Now we are properly synchronizing state of local storage and we should only have one console log on initial render:

1. Set initial state

And as a bonus we've gotten rid of all useEffects. Which is always a win. By the React documentation, useEffect is an escape hatch, and you shouldn't use it if you don't absolutely need to. React is already reactive without the help of effects. React is driven by changes in props and events, not by side effects.

To get a better understanding of this I highly recommend this page from the docs. But if you're working with React, you really should read every page on this site.