stin's Blog

localStorage に同期する React State を作るカスタムフック


React の useState と似た使い方で localStorage に保存されるカスタムフック useLocalStorage の実装を紹介します。このブログサイトのソースコードで使用しています。

react-use に同じ目的同じ名前のカスタムフックが入っていることは知っていますが、インターフェイスも内部実装も気に入らなかったので自作しました。

コード

インターフェイス

type UseLocalStorageParams<T> = {
  storageKey: string;
  initialState: T;
  isValidValue: (value: unknown) => value is T;
};

function useLocalStorage<T>(
  params: UseLocalStorageParams<T>,
): [T, React.Dispatch<React.SetStateAction<T>>];

引数では

  • localStorage のキーを指定する storageKey
  • ステートの初期値 initialState
  • localStorage に格納されている値を検証する isValidValue

の 3 つを受け取ります。localStorage の中身はプログラマーの意図しない値を容易に格納できるので、検証関数を必須としています。

戻り値は標準の useState とまったく同じタプル型にしてあります。

使い方

type ColorMode = "light" | "dark";

function isColorMode(v: unknown): v is ColorMode {
  return ["light", "dark"].includes(v as string);
}

const App: React.FC = () => {
  const [colorMode, setColorMode] = useLocalStorage<ColorMode>({
    storageKey: "site-color-mode",
    initialState: "light",
    isValidValue: isColorMode,
  });

  // ...
};

フックの引数だけ localStorage 向けに特殊ですが戻り値は useState と全く同じなので、同じ感覚でステート操作を行えます。

実装

保存時の形式

T 型の値をそのまま JSON.stringify して localStorage に保存すると、Tundefined を含む時に正しくシリアライズ・デシリアライズされません。なぜなら JSON.stringify(undefined)undefined を返し、localStorage.setItem(key, undefined) は localStorage に "undefined" という文字列を保存し、JSON.parse("undefined") はエラーを吐くからです。

useStateundefined を扱えるのに useLocalStorage が扱えないの悔しかったので、localStorage に格納するときは { __value: T } というオブジェクトにしてからシリアライズして保存します。こうすることで localStorage には最低でも "{}" という文字列が保存されるようになり、JSON.parse でエラーが出ることはありません。{} オブジェクトに対して __value でアクセスすれば undefined になるので、undefined も表現できます。

別タブと同期する

別タブで同じ Web サイトを開いていて片方で localStorage を更新した時、もう一方のタブで storage イベントが発火します。それをリッスンしておけば、別タブでの操作を検知してメモリ上のステートを更新できます。

useEffect(() => {
  window.addEventListener("storage", setValueFromStorage);
  return () => window.removeEventListener("storage", setValueFromStorage);
}, [setValueFromStorage]);

setState の内部実装

useLocalStorage の内部では標準の useState を使っていますが、その setState をそのまま return するのではなく、ラップして localStorage 更新を含む処理を追加した独自の setState を return しています。

const setState: React.Dispatch<React.SetStateAction<T>> = useCallback(
  (value) => {
    _setState((prevState) => {
      // @ts-expect-error
      const nextState: T = typeof value === "function" ? value(prevState) : value;

      window.localStorage.setItem(storageKey, JSON.stringify({ __value: nextState }));

      return nextState;
    });
  },
  [storageKey],
);

setState のインターフェイスだけ維持したまま localStorage に保存する機能を追加しようとしているので、なんと updater function の途中に副作用を発生されています…。もっといい方法がありそうな気がしたんですが、localStorage.setItem 冪等なので問題になることはないだろうと思ってこの方法を採用しました。いい方法知ってる人がいればぜひ教えてください…。

書いてて気づいたダメなこと

JSON.stringifyJSON.parse するので、ステートの値はシリアライズ可能な値に限定されますね。Date の値とかそのままでは使えないということです。useState と完全に同じ使い勝手とはいかないですね。引数でさらにシリアライズ関数・デシリアライズ関数を受け取るようにすれば対応できるかもと思ったり、変換レイヤーくらい使う側で用意しろよと思ったりしました(?)

また、別タブのステート同期はできるんですが同一タブで同じ storageKey のステートの同期はできないです。localStorage を更新しても、そのタブでは storage イベントは発火しないためです。厳密にやろうとすると、React Context を用意して Provider を設置してもらって…ということをやる必要が出てきます。面倒でした。

まとめ

useLocalStorage の紹介をしました。useState のインターフェイスを守りつつブラウザ API の localStorage にも反映するとなると、ハック的な書き方になってしまいますね。不整合が起きないようにして関数の内部にハッキーさを隠蔽してあれば問題ない派なのでこれでいいや。

それではよい React ライフを!