skip to Main Content

Reference:
https://youtu.be/hQAHSlTtcmY?t=1342

I had an issue where I cannot initialize the todos from useEffect through localStorage. After adding two ToDo into the list and then click browser refresh button, all ToDo will not be able to load back. The trace looks as below:

storedTodos size:  2
App.js:41 todos size:  0
App.js:34 storedTodos size:  0
App.js:41 todos size:  0
App.js:41 todos size:  0

It seems to me the setTodos(storedTodos) doesn’t work as expected. The solution is to load the initialization through useState shown below.

Question> Can someone please advice me that why this initialization doesn’t work within useEffect?

Thank you

function App() {
  const [todos, setTodos] = useState([])
  // const [todos, setTodos] = useState(() => {
  //   if (localStorage.getItem(LOCAL_STORAGE_KEY)) {
  //     const storedTodos = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY))
  //     return storedTodos;
  //   }
  //   else
  //     return [];
  // })
  const todoNameRef = useRef()

  // This doesn't work as expected
  //
  useEffect(() => {
    const storedTodos = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY))
    
    if (storedTodos) {
      console.log("storedTodos size: ", storedTodos.length)
      setTodos(storedTodos)
    }
  }, [])

  useEffect(() => {
    console.log("todos size: ", todos.length)
    localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos))
  }, [todos])

  function handleAddTodo(e) {
    const name = todoNameRef.current.value
    if (name === '') return
    setTodos(prevTodos => {
      return [...prevTodos, { id: uuidv4(), name: name, complete: false }]
    })
    todoNameRef.current.value = null
  }

  return (
    <>
      <TodoList todos={todos} />
      <input ref={todoNameRef} type="text" />
      <button onClick={handleAddTodo}>Add Todo</button>
      <button>Clear Completed Todos</button>
      <div>0 left to do</div>
    </>
  )
}

export default App;

3

Answers


  1. I think the problem is that your useEffect hook with todos as dependency runs every time the todos state changes, which includes the initial render when todos is null. This causes the localStorage to be overwritten with an empty array, which then prevents the other useEffect hook from loading the stored todos.

    To fix this, you can move the localStorage logic inside the setTodos function and remove useEffect with todos as dependency.

    For example:

    setTodos((prevTodos) => {
      const todos = [...prevTodos, { id: uuidv4(), name: name, complete: false }];
      localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos));
      return todos;
    });
    
    Login or Signup to reply.
  2. Try to add a check to ensure the state is only set if the value from localStorage has resolved. Replace if(storedTodos) with if(storedTodos.length > 0).

      useEffect(() => {
        const storedTodos = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY))
        // added check
        if (storedTodos && storedTodos.length > 0) {
          console.log("storedTodos size: ", storedTodos.length)
          setTodos(storedTodos)
        }
      }, [])
    
    Login or Signup to reply.
  3. You just want to avoid caching the todos on the first render.

      const firstRender = useRef(true);
      useEffect(() => {
        if (firstRender.current) {
          firstRender.current = false;
          return;
        }
        console.log('todos size: ', todos.length);
        localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos));
      }, [todos]);
    

    Note that state variables are immutable during a render cycle. setState updates the value for the next render. That means that for the entire first render, todos is [], no matter what you do. So allowing this function to run on the first render will overwrite your localStorage to [].

    The solution you already have with the commented out code is better imo, since it immediately instantiates the state variable rather than waiting for a whole render cycle.


    To give you a tip from what I saw in the comments. If you want the TodoList component to be able to update the state (ie. to check an item), you can pass in the setState function as well.

    <TodoList todoState={[todos, setTodos]} />
    
    function TodoList({ todoState }) {
      const [todos, setTodos] = todoState;
      ...
    }
    

    An option that plays nicely with Strict Mode is to just create a wrapper for setTodos

      const [todos, _setTodos] = useState([]);
      function setTodos(todos) {
        if (typeof todos === 'function') {
          _setTodos((prevTodos) => {
            const newTodos = todos(prevTodos);
            localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newTodos));
            return newTodos;
          });
        } else {
          localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos));
          _setTodos(todos);
        }
      }
    

    Now just call the new setTodos and remove the useEffect with the todos dependency.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search