jpceia
jpceia

Reputation: 51

How to implement a useLocalStorage hook in Next.js?

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.

Upvotes: 5

Views: 10603

Answers (5)

rmbits
rmbits

Reputation: 2327

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.

Upvotes: 7

Anastasiia
Anastasiia

Reputation: 11

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]
}

Upvotes: 1

themegabyte
themegabyte

Reputation: 43

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.

Upvotes: 0

David Martin
David Martin

Reputation: 133

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

Upvotes: 2

MarkosTh09
MarkosTh09

Reputation: 334

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;
  }
};

Upvotes: 2

Related Questions