ReactでlocalStorageの値と同期した状態を扱う

# はじめに
Reactのコンポーネントで`localStorage`の値を扱っても、値の変化によるレンダリングは引き起こされません。これは、`localStorage`の値がReactの文脈における状態としては扱われないためです。
例として`useState`によるカウンターと、`localStorage`の値をそのまま使うカウンターを用意しました。

https://codesandbox.io/embed/dreamy-field-ydsxh8?fontsize=14&hidenavigation=1&theme=dark

`localStorage`の方は値の変化が画面に反映されるような期待した動作は生じません。

この記事では、この点を改善するために以下の2点を満たしたカウンターを作成することをゴールとします。
- 初期値は`localStorage`がもつ値に設定
- ボタンのクリックによって表示される値と`localStorage`に保存される値の両方を更新

# localStorage
[`localStorage`](https://html.spec.whatwg.org/multipage/webstorage.html#dom-localstorage-dev)は保存期間に制限がないローカルの[`Storage`](https://html.spec.whatwg.org/multipage/webstorage.html#storage)オブジェクトです。key-value方式でデータを保存でき、オリジンごとに保存されます(つまりhttpやhttpsのように異なるプロコトルを利用した場合も異なる領域に保存されます)。

## getItem
指定したキー名に対応した値を取得できます。
```ts
const value = localStorage.getItem(key);
```
`getItem`の型は
```ts
(keyName: string) => string | null;
```
のようになっています。そのため、`localStorage`から取得する値は必ず`string`または、`null`になります。`null`が返ってきた時はそのキー名に対応する値が存在していないことを意味します。

## setItem
指定したキー名とそれに対応する値を保存するメソッドです。
```ts
localStorage.setItem(key, value);
```
保存したいキー名を第一引数に、保存するキーの値を第二引数に渡します。
型は以下のようになってます。
```ts
(keyName: string, keyValue: string) => void;
```
第一引数に渡したキー名と対応する値が、既に存在していた場合は上書きされます。

## removeItem
指定したキー名に対応する値を削除するメソッドです。
```ts
localStorage.removeItem(key)
```
型は以下のようになっています。
```ts
(keyName: string) => void
```
指定したキー名が`localStorage`に存在しなかった場合は何も行いません。

## その他
他にも、`localStorage`にあるすべてのデータを削除する`clear`メソッドだったり、キー名の一覧を取得する`key`メソッドが存在します。

# Reactの状態として扱う
最初に見た通り`localStorage`はそのままReactの状態として扱えません。値を状態として見做すためには、値の変化に応じてレンダリングが実行されることと、レンダリング間で値を保持し続ける性質を持つ必要があります。
`localStorage`はReactのライフサイクルの外部で持つ値なので、後者のレンダリング間で値を保持し続ける性質はクリアしています。そのため、前者の値が変化した時にレンダリングを実行させる仕組みができれば、期待するカウンターを作ることができます。

この記事では`localStorage`の値を`useState`によって作られたReactの状態に連携させて行います。

## 関数の土台を作る
まず最初は作成する関数の外型として、`useState`をラップしただけの関数を作ります。
```ts
export const useLocalStorage = (key: string, initValue: string) => {
  const [value, setValue] = useState(initValue);

  return [value, setValue] as const;
};
```
引数には保存するキー名と初期値を渡しています。キー名はこの関数では使ってませんが、後で`localStorage`を扱うときに利用します。
返り値は`useState`によって得られる`value`と`setValue`をそのまま返します。
これを用いてカウンターを作ると以下のようになります。
```tsx
export const Counter: FC = () => {
  const [count, setCount] = useLocalStorage("counter", "0");

  return <button onClick={setCount(() => `${Number(count) + 1}`)}>{count}</button>
};
```
この段階では`useLocalStorage`は`useState`のラッパーでしかないので、`useState`を使用した時と全く同じ挙動をします。

## 初期値を`localStorage`と同期させる
次に、`localStorage`に値があるときは初期値を`initValue`ではなく`localStorage`の値を参照するようにします。
```ts
const getLocalStorageValue = (key: string, initValue: string) => {
  const item = localStorage.getItem(key);

  return item ? item : initValue;
}

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

  return [value, setValue] as const;
};
```
`initValue`を`useState`の初期値として渡していたところから、`getLocalStorageValue`という関数を渡すようにしました。
```ts
  const [value, setValue] = useState(getLocalStorageValue(key, initValue));
```
このように書くと、レンダリングの度に`getLocalStorageValue`が計算されてしまうので、
```ts
  const [value, setValue] = useState(() =>
    getLocalStorageValue(key, initValue)
  );
```
のように関数を渡すようにしました。
`getLocalStorageValue`は`localStorage`からキー名が`key`の値を取り出して、`null`であれば(存在しなければ)`initValue`、そうでなければ`localStorage`に保存された値を返すような関数です。
これを用いて状態の初期値を`localStorage`に既に値がある場合はコンポーネントから渡される初期値でなく`localStorage`によって管理された値になります。

## 状態の変更と一緒に`localStorage`を更新する
最後に、状態の値が変更するたびに`localStorage`の値も変更するようにします。
```ts
const getLocalStorageValue = (key: string, initValue: string) => {
  const item = localStorage.getItem(key);

  return item ? item : initValue;
};

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

  const setLocalStorageValue = useCallback(
    (setStateAction: string | ((prevState: string) => string)) => {
      const newValue =
        setStateAction instanceof Function
          ? setStateAction(value)
          : setStateAction;

      localStorage.setItem(key, newValue);
      setValue(() => newValue);
    },
    [key, value]
  );

  return [value, setLocalStorageValue] as const;
};
```
`setLocalStorageValue`という関数を用意して、状態を更新する関数`setValue`を実行するときに、`localStorage`の値も更新するようにしました。`setLocalStorageValue`を状態を更新する関数として外部に提供することで、状態の変化があったときは同時に`localStorage`を変化させるようにすることが可能になります。

## `localStorage`を用いたカウンター
これで、`localStorage`と同期した状態を扱えるようになりました。この関数を使えば以下のようなカウンターができます。

https://codesandbox.io/embed/goofy-ramanujan-22863b?fontsize=14&hidenavigation=1&theme=dark

リロードすると`localStorage`の値(リロード前に表示されていた値)が初期表示され、ボタンをクリックするとレンダリングが発火して表示される値が更新されるようになりました。

## 異なるタブで`localStorage`が更新されたことを確認する
異なるタブで`localStorage`の値が更新された時、`storage`イベントが発火します。
そのため、それらを検知するには`window.addEventListener`を用いて以下のようにします。
```ts
window.addEventListener('storage', callback);
```
これをReactに組み込むには`useEffect`を使って書きます。
```ts
useEffect(() => {
  window.addEventListener('storage', callback);
  return () => {
    window.removeEventListener('storage', callback);
  };
}, []);
```
コンポーネントの初期マウント時に、`storage`イベントにコールバック関数を追加して、アンマウント時にそれを解除するようなコードです。
それを、`useLocalStorage`に組み込みます。

```ts
const getLocalStorageValue = (key: string, initValue: string) => {
  const item = localStorage.getItem(key);

  return item ? item : initValue;
};

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

  useEffect(() => {
    const callback = (event: StorageEvent) => {
      if (event.key === key) {
        setValue((value) => localStorage.getItem(key) ?? value);
      }
    };

    window.addEventListener('storage', callback);
    return () => {
      window.removeEventListener('storage', callback);
    };
  }, [key]);

  const setLocalStorageValue = useCallback(
    (setStateAction: string | ((prevState: string) => string)) => {
      const newValue =
        setStateAction instanceof Function
          ? setStateAction(value)
          : setStateAction;

      localStorage.setItem(key, newValue);
      setValue(() => newValue);
    },
    [key, value]
  );

  return [value, setLocalStorageValue] as const;
};
```
`callback`関数は以下のように書きました。発火した`localStorage`の`key`が状態にしたい`localStorage`の`key`に一致することを確認して、一致していれば`setValue`で状態を現在の`localStorage`の値に更新しています。
```ts
const callback = (event: StorageEvent) => {
  if (event.key === key) {
    setValue((value) => localStorage.getItem(key) ?? value);
  }
};
```
外部での変更を検知したい場合はこちらのコードに書き換えて利用してください(呼び出したウィンドウ内での変更は検知されません)。

## 注意
サーバー側でこの関数を動かす時は`window`が`undefined`になるのでエラーが発生します。対策として、`window`が`undefined`の場合は`localStorage`を用いない普通の`useState`の挙動をするように調整をすると良いです。

`localStorage`の更新によって状態が更新されないことに注意してください。状態の変化によって`localStorage`が変化しますが、その逆は成り立ちません。コード内では`localStorage`を直接触らないようにして、更新は常に状態から行うようにします。


## さいごに
Reactで`localStorage`の値と同期した状態を扱う関数を紹介しました。`localStorage`の更新によって状態の更新を行えなかったことが残念ですが、コンポーネントから直接扱う値を`localStorage`のような外部のソースでなく、状態を扱うようにすれば困らないので`localStorage`の値を参照する状態が必要な場合はこのように実装してみてはいかがでしょうか。