sessionStorageをリアルタイム同期できるReactカスタムフック

フロントエンドでの状態管理にはReduxやReact Contextなど多々ありますが、ブラウザネイティブの `sessionStorage`  / `localStorage` を採用したいケースもあると思います。例えば、

* ユーザーが選択したダーク／ライトテーマを保持したい
* フォーム入力を下書き保存しておきたい
* 拡張機能でページに独立して注入するウィジェット間で状態を共有したい（実行コンテキストが独立しているため、ReduxやReact Contextでは状態を共有できない）

といった、ページをリフレッシュしてもタブ単位やブラウザ全体で値を保持したいケースに `sessionStorage` / `localStorage` はぴったりです。

今回、これらのブラウザネイティブを簡単に扱えて、かつ複数箇所から値をリアルタイム同期できるReactカスタムフックを作ったので共有します。

## きっかけ

業務でChrome拡張機能を開発していました。

この拡張機能は特定のページに２つのウィジェットを埋め込むというものなのですが、この**２つのウィジェット間で状態を共有**しつつ、その**状態はページをリフレッシュしてもキープ**し、しかも**タブ毎に独立させたい**という実務要件がありました。

Chrome拡張機能で状態管理するならExtension Storage (`chrome.storage.*` ) が最初に候補として上がりますが、Extension Storageはタブを跨いでも状態が共有されてしまうため、今回の要件（同じタブ内に２つあるウィジェットだけが値を共有し、別タブとは完全に独立）には `sessionStorage` の方がフィットしました。

![what-i-want-to-do.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981597/d44f429c-8ca8-4422-a088-bfc0cf77f12e.jpeg)


## 実装コード

実際に作成したカスタムフックの汎用化したもの (`useSampleStorage`) を共有します。

実務ではもう少し複雑なオブジェクトを扱いましたが、基本的にはオブジェクトのtypeを変えていただくだけで自由に使い回すことができます。

```tsx:use-count-storage.ts
import { useCallback, useEffect, useState } from 'react';

type Sample = { count: number; text: string };

const INITIAL_VALUE: Sample = { count: 0, text: '' }; // 状態の初期値
const SESSION_STORAGE_KEY_NAME = 'sample'; // sessionStorageのキー名
const CHANGED_EVENT_NAME = 'sampleChanged'; // 状態変更のイベント名

export const useSampleStorage = () => {
  // sessionStorageから値を読み込む関数
  const readValue = useCallback((): Sample => {
    if (typeof window === 'undefined') return INITIAL_VALUE;
    try {
      const stored = sessionStorage.getItem(SESSION_STORAGE_KEY_NAME);
      if (stored === null) return INITIAL_VALUE;
      return JSON.parse(stored);
    } catch {
      return INITIAL_VALUE;
    }
  }, []);

  const [value, setValue] = useState<Sample>(readValue);

  // valueが変更されたとき、sessionStorageに反映させる
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const newValueStr = JSON.stringify(value);
    const oldValueStr = sessionStorage.getItem(SESSION_STORAGE_KEY_NAME);
    if (newValueStr !== oldValueStr) {
      sessionStorage.setItem(SESSION_STORAGE_KEY_NAME, newValueStr);
      window.dispatchEvent(new Event(CHANGED_EVENT_NAME));
    }
  }, [value]);

  // sessionStorageの値が外部で変更されたとき、valueに反映させる
  useEffect(() => {
    const handler = () => {
      const newData = readValue();
      setValue(old => (JSON.stringify(old) === JSON.stringify(newData) ? old : newData)); // 無限ループを防ぐため、現在の値と新しい値が異なる場合のみ更新
    };
    window.addEventListener('storage', handler);
    window.addEventListener(CHANGED_EVENT_NAME, handler);
    return () => {
      window.removeEventListener('storage', handler);
      window.removeEventListener(CHANGED_EVENT_NAME, handler);
    };
  }, [readValue]);

  return {
    value,
    setValue,
  };
};

```

このカスタムフックのポイントは、状態が変更されたときに **他のコンポーネントや同一タブ内の別の場所でも即時に反映されるようにすること** です。

`sessionStorage` や `localStorage` は、値の変更を自動で検知する仕組み（watcher）が組み込まれていないため、 そのままでは変更を別コンポーネントに伝播できません。そこで、値を更新するたびにEventを発火させて、同一タブ内でもリアルタイムに通知できるように工夫しています。

![how-change-event-flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981597/e0aa3ca5-7fcc-4b46-a4f1-02ca5cb97b73.png)

**※ localStorage 版にするには？**
`sessionStorage` → `localStorage` に置き換えるだけでOKです。

## 使い方サンプル

フックの使用方法は非常に簡単で、任意のコンポーネントからカスタムフックを呼び出して、値と更新関数を取得するだけです。

```tsx
export default function App() {
  const sampleStorage = useSampleStorage();

  return (
    <div>
      count
      <input
        type="number"
        value={sampleStorage.value.count}
        onChange={e => sampleStorage.setValue(value => ({ ...value, count: Number(e.target.value) }))}
      />
    </div>
  );
}
```

## 動作確認

実際に簡単なChrome拡張機能を作り、`example.com` で動作確認してみます。

この拡張機能はWebページに２つのウィジェットを注入し、それぞれが独立して`useSampleStorage` を使用しています。

![useSampleStorage-test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981597/c4ae5660-8670-434a-a09a-5b0cc6353e1d.gif)


このように、独立して注入された２つのウィジェットがリアルタイムで値を共有できていることがわかります。このように、独立して注入された２つのウィジェットがリアルタイムで値を共有できていることがわかります。それぞれ実行コンテキストが独立しているので、ReduxやReact Contextでは状態を共有できないはずですが、`sessionStorage` を利用することによりそれを可能にしています。

もちろん `sessionStorage` なので、ページをリフレッシュしても値は保持されます。

## 注意点

一応注意点として`storage` イベントはブラウザごとに挙動差があるので、実際に使用する際はブラウザを切り替えてのテストは必須ですね。