How to implement a useLocalStorage hook in Next.js?
Asked Answered
D

5

5

I am trying to create a replacement of useState in next.js resilient to page refreshes.

One of the possible solutions that came across was to use window.localStorage to save and retrieve the state. That would make state persistent even after page refreshes.

I found the following implementation of a useLocalStorage hook for ReactJS https://usehooks.com/useLocalStorage/

function useLocalStorage(key, initialValue) {
  // State to store our value
  // Pass initial state function to useState so logic is only executed once
  const [storedValue, setStoredValue] = useState(() => {
    if (typeof window === "undefined") {
      return initialValue;
    }
    try {
      // Get from local storage by key
      const item = window.localStorage.getItem(key);
      // Parse stored json or if none return initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // If error also return initialValue
      console.log(error);
      return initialValue;
    }
  });
  // Return a wrapped version of useState's setter function that ...
  // ... persists the new value to localStorage.
  const setValue = (value) => {
    try {
      // Allow value to be a function so we have same API as useState
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      // Save state
      setStoredValue(valueToStore);
      // Save to local storage
      if (typeof window !== "undefined") {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      // A more advanced implementation would handle the error case
      console.log(error);
    }
  };
  return [storedValue, setValue];
}

However, it generates the following error when I use it in NextJS:

Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server

After a little search I found out that the window object doesn't exist in the (Next.js) server side and this is the possible cause of the error (Window is not defined in Next.js React app). A possible solution is to protect the usage of window with the useEffect hook, that only runs on the client side.

My current implementation of the useLocalStorage hook is

function useLocalStorage<T>(key: string, defaultValue: T): [T, Dispatch<SetStateAction<T>>] {
  const [value, setValue] = useState<T>(defaultValue);

  useEffect(() => {
    try {
      const item = window.localStorage.getItem(key);
      setValue(item ? JSON.parse(item) : defaultValue);
    }
    catch (error) {
      setValue(defaultValue);
    }
    
  }, [key, defaultValue]);

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

  return [value, setValue];
};

However, this time the hook doesn't always work as expected because the order of execution of the useEffect callbacks is not guaranteed. As a consequence, sometimes the state is lost.

I want to know what would be a correct implementation for this in NextJS and understand where the logic of my code is failing.

Despair answered 13/7, 2022 at 6:9 Comment(0)
D
7
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'

export default function useLocalStorage<T>(
  key: string,
  defaultValue: T
): [T, Dispatch<SetStateAction<T>>] {
  const isMounted = useRef(false)
  const [value, setValue] = useState<T>(defaultValue)

  useEffect(() => {
    try {
      const item = window.localStorage.getItem(key)
      if (item) {
        setValue(JSON.parse(item))
      }
    } catch (e) {
      console.log(e)
    }
    return () => {
      isMounted.current = false
    }
  }, [key])

  useEffect(() => {
    if (isMounted.current) {
      window.localStorage.setItem(key, JSON.stringify(value))
    } else {
      isMounted.current = true
    }
  }, [key, value])

  return [value, setValue]
}

Here, useRef is being used to prevent the defaultValue getting stored in localStorage in first render. Basically, it skips the callback of second useEffect to run on first render, so initialization can complete without race condition by first useEffect hook.

Dot answered 28/3, 2023 at 13:11 Comment(0)
P
2

Here is a useLocalStorage hook which works with next.js

import React, { useDebugValue, useEffect, useState } from "react";

export const useLocalStorage = <S>(
  key: string,
  initialState?: S | (() => S)
): [S, React.Dispatch<React.SetStateAction<S>>] => {
  const [state, setState] = useState<S>(initialState as S);
  useDebugValue(state);

  useEffect(() => {
    const item = localStorage.getItem(key);
    if (item) setState(parse(item));
  }, []);

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

  return [state, setState];
};

const parse = (value: string) => {
  try {
    return JSON.parse(value);
  } catch {
    return value;
  }
};
Plumley answered 14/7, 2022 at 7:17 Comment(4)
this doesn't seem to work any more.Awn
@Awn I use this hook very regularly and it works can you describe your problemPlumley
For me the state gets reset to the default value when I reload the pagePencil
@DavidMartin the hook doesn't work in dev with reactStrictMode enabled. Do you have it enabled by any chance?Orchestrion
P
2

Markos's answer kept resetting the state on refresh for me. I added one small tweak, and that fixed the issue:

import React, { useDebugValue, useEffect, useState } from "react"

const useLocalStorage = <S>(
  key: string,
  initialState?: S | (() => S)
): [S, React.Dispatch<React.SetStateAction<S>>] => {
  const [state, setState] = useState<S>(initialState as S)
  useDebugValue(state)

  useEffect(() => {
    const item = localStorage.getItem(key)
    if (item) setState(parse(item))
  }, [])

  useEffect(() => {
    if (state !== initialState) {
      localStorage.setItem(key, JSON.stringify(state))
    }
  }, [state])

  return [state, setState]
}

const parse = (value: string) => {
  try {
    return JSON.parse(value)
  } catch {
    return value
  }
}

export default useLocalStorage
Pencil answered 8/10, 2022 at 18:42 Comment(1)
The fix is actually buggy. It may be totally valid that the value is supposed to be the initial value. Image that scenario: initial value: A -> user changes value to B -> user changes value back to A -> wouldn't work because of if (state !== initialState)Orchestrion
T
1

I don't know if the author is still relevant. But maybe someone will come in handy.
This worked for me. I replaced one useEffect with a function so that useEffect wouldn't get called uncontrollably.
The default value will be set to useEffect, in all other cases the changeValue function will be called.

export const useLocalStorage = (key: string, defaultValue: any) => {
    const [value, setValue] = useState(defaultValue);

    const changeValue = (value) => {        
        setValue(value);
        localStorage.setItem(key, JSON.stringify(value));
    } 

    useEffect(() => {
        const stored = localStorage.getItem(key);

        if (!stored) {
            setValue(defaultValue);
            localStorage.setItem(key, JSON.stringify(defaultValue));
        } else {
            setValue(JSON.parse(stored));
        }
    }, [defaultValue, key]);

    return [value, changeValue]
}
Tillie answered 18/3, 2023 at 22:9 Comment(0)
O
0

This worked for me: Next.js use localstorage problem with SSR

... and it didn't reset the value at every refresh unlike @MarkosTh09's solution.

Octahedrite answered 3/3, 2023 at 21:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.