🪝

webStorage.hook をつくってみた

2024/01/19に公開

ReactでもwebStorageを積極的に使いたいのですが、ちょっと面倒です。いちいち取得したりセットしたり、useEffectでrenderを起こしたり…

いろんなサイトを参考しながら、hookを作成してみました🙏

こちらが作ってみたコードです

webStorage.hook.ts
import { SetStateAction, useSyncExternalStore } from 'react';
import { LocalStorageKeyValues, WebStorageType } from './types';

const selectWebStorage = (storageType?: WebStorageType) => {
  switch (storageType) {
    case 'session':
      return window.sessionStorage;
    case 'local':
    default:
      return window.localStorage;
  }
};

const subscribe = (callback: () => void) => {
  window.addEventListener('storage', callback);
  return () => window.removeEventListener('storage', callback);
};

export const useSyncWebStorage = <K extends keyof LocalStorageKeyValues>(
  key: K,
  initialValue: LocalStorageKeyValues[K],
  storageType?: WebStorageType,
): [
  LocalStorageKeyValues[K],
  (value: SetStateAction<LocalStorageKeyValues[K]>) => void,
] => {
  const storage = selectWebStorage(storageType);
  const getSnapshot = () => {
    return storage.getItem(key);
  };
  const localStorageValue = useSyncExternalStore(subscribe, getSnapshot);
  const parsedValue = localStorageValue
    ? (JSON.parse(localStorageValue) as LocalStorageKeyValues[K])
    : initialValue;
  const setValue = (action: SetStateAction<LocalStorageKeyValues[K]>) => {
    try {
      const value = action instanceof Function ? action(parsedValue) : action;
      storage.setItem(key, JSON.stringify(value));
      window.dispatchEvent(
        new StorageEvent('storage', { key, newValue: JSON.stringify(value) }),
      );
    } catch (error) {
      console.error(error);
    }
  };
  return [parsedValue, setValue];
};
types.ts
import { Team } from 'services/teams/types';

export type WebStorageType = 'local' | 'session';

export type LocalStorageKeyValues = {
  foo: Record<bar, boolean>;
};

工夫したポイント

keyに対して対応したvalueの型を推論させた(参考URL)

ここの部分

export const useSyncWebStorage = <K extends keyof LocalStorageKeyValues>(
  key: K,
  initialValue: LocalStorageKeyValues[K],
  storageType?: WebStorageType,
): [

useStateと似たようなインターフェースにした(参考URL)

ここの部分

  const setValue = (action: SetStateAction<LocalStorageKeyValues[K]>) => {
    try {
      const value = action instanceof Function ? action(parsedValue) : action;

useStatesetHoge((prev) => [...prev, hoge]) みたいなことがしたかった

useSyncExternalStoreを使って、webStorageの変更とレンダリングを同期させた

react@18から登場したhookです
https://ja.react.dev/reference/react/useSyncExternalStore

sessionもlocalもインターフェースがほぼ同じなので選べるようにした

※ただし、将来的に仕様が変えればhookごと分けます

const selectWebStorage = (storageType?: WebStorageType) => {
  switch (storageType) {
    case 'session':
      return window.sessionStorage;
    case 'local':
    default:
      return window.localStorage;
  }
};

ハマったこと

変更を行なったのと同じページでは、storage イベントは発火しない

メモ: これは変更を行ったのと同じページでは動作しません。本来、これは同じ保存領域を使用している同じドメインの他のページが更新を同期するための仕組みです。他のドメインのページは、同じ保存領域オブジェクトにはアクセスできません。
https://developer.mozilla.org/ja/docs/Web/API/Window/storage_event

こちらの記事、コードが参考になりました🙏
https://oakhtar147.medium.com/sync-local-storage-state-across-tabs-in-react-using-usesyncexternalstore-613d2c22819e

useSyncExternalStoreが無限ループする

reactのドキュメント、トラブルシューティングに載ってます。

describe

hook内部にあると毎度呼び出されます。外部に定義するか useCallbabck の利用を検討しましょう。
https://ja.react.dev/reference/react/useSyncExternalStore#my-subscribe-function-gets-called-after-every-re-render

getSnapshot

変更がない時は常に同じ値を返さないと無限ループします。今回の場合、 getSnapshot 内で JSON.parse すると object になる可能性があります。こちらは、内部的にはObject.isで比較されるため、中身が同じでも参照が違えば false になります。そのため、parseをせずに getSnapshot を実行し、そのあと工程でparseを行います。
https://ja.react.dev/reference/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached

参考リンク集

Discussion