Reactと組み合わせるセッションストレージ

# 概要
セッションストレージの値をリアクティブに管理するためにやったことをまとめます。
※今回はReact hooksのuseStateと組み合わせました

# セッションストレージとuseState

### セッションストレージのデータ管理
セッションストレージはリアクティブな値の管理ができないため、Reactとの組み合わせが悪く、Reactのhooks内部でsetItem/getItemを呼んでいると、実際に利用したいタイミングでは少し古い値しか取れないことが多いです。

対応としてパッと思い浮かぶのは以下の2つ

- useMemoやuseCallbackといったhooks内部でwindow.sessionStorage.setItem/getItemを呼ばない
- setTimeoutでちょっと待つ

ただ上記の対応はやりたくないですよね。。。
（hooks内部で呼ばないならReactの旨味がないし、setTimeoutの時間はマジックナンバーだし。。。）

セッションストレージもリアクティブに値を管理できたら便利なのに🤔

### useStateのデータ管理
一方React hooksの[useState](https://ja.react.dev/reference/react/useState)は、画面をリロードするまで値をリアクティブに管理できます。
``` typescript: 利用方法
const [state, setState] = useState("initialState")
// まだ値をセットしていないので初期値の"initialState"が返却される
console.log(state) // "initialState"

// 値をセット
setState("nextState")
// いつでも最新の値（ここだと"nextState"）を取得できる
console.log(state) // "nextState"
```

### リアクティブなセッションストレージ
上記の前提より、セッションストレージとuseStateをうまく組み合わせれば

- リアクティブに状態を返す
- リロードしても値が飛ばない

ようなデータ管理ができるはず。

前置きが長くなりましたが、実際に作成したhooksを紹介します。

# 実際に作成したhooks
※今回はtypescript/Reactとセッションストレージを組み合わせたので、型変換も含みます。
``` typescript: hooks.ts
import { useCallback, useEffect, useMemo, useState } from "react";

// セッションストレージに関数をセットさせないために作成した型
type NonFunction<T> = T extends (...args: any[]) => any ? never : T;

// 変なキー情報を入れさせないために、キー情報をEnum化
export const KEYS = {
  TEST_PARAMS: "testParams"
} as const;
export type KEYS = typeof KEYS[keyof typeof KEYS];

// セッションストレージにセットした文字列から、自身が定義した型にパースする関数
const parseItem = <T, >(item: string | null): T | null => {
  if (item === null) {
    return null;
  }
  
  const parsed = JSON.parse(item);
  // うまくJSONにパースできなかったり、このhookを使ってsetされたデータでなければ（JSONにvalueキーを含んでいなければ）nullを返す
  if (typeof parsed !== "object" || !("value" in parsed)) {
    return null;
  }

  // パースできた時点でT型として扱う
  return parsed["value"] as T;
};

// セッションストレージから値を取得する関数
const getStringItem = (key: KEYS): string | null =>
   window.sessionStorage.getItem(key);

export const getItem = <T, U extends NonFunction<T> = NonFunction<T>>(
  key: KEYS,
): U | null => parseItem<U>(getStringItem(key));

// セッションストレージに値をセットする関数
const setItem = <T, U extends NonFunction<T> = NonFunction<T>>(
  key: KEYS,
  value: U,
): void => {
  // valueキーを持つJSONに変換
  const newValue: string = JSON.stringify({ value });

  window.sessionStorage.setItem(key, newValue);

  // 画面リロードや履歴を変更せずにセッションストレージの更新イベント発火
  window.dispatchEvent(
    new StorageEvent("storage", {
      key,
      // StorageEventの第二引数には古い情報をいれる必要あり
      oldValue: getStringItem(key),
      newValue,
      storageArea: window.sessionStorage,
    }),
  );
};

// セッションストレージの値を削除する関数
const removeItem = (key: KEYS): void => {
  window.sessionStorage.removeItem(key);

  // 画面リロードや履歴を変更せずにセッションストレージの更新イベント発火
  window.dispatchEvent(
    new StorageEvent("storage", {
      key,
      // StorageEventの第二引数には古い情報をいれる必要あり
      oldValue: getStringItem(key),
      // 削除関数なので新しい値はnull
      newValue: null,
      storageArea: window.sessionStorage,
    }),
  );
};

// hooksの引数、キー情報とデフォルト値をもらう
type Params<T, U extends NonFunction<T> = NonFunction<T>> = {
  key: KEYS,
  defaultValue: U
};

// hooksの返り値、セッションストレージで管理している値と
// セッションストレージへのセット関数/削除関数を返す
type Result<T> = [
  T,
  {
    setItem: (dispatch: T | ((prev: T) => T)) => void;
    removeItem: VoidFunction;
  },
];

export const useSessionStorage = <
  T,
  U extends NonFunction<T> = NonFunction<T>,
>({
  key,
  defaultValue,
}: Params<U>): Result<U> => {
  // リアクティブな値管理のためのuseState
  const [value, setValue] = useState<string | null>((): string | null =>
    getStringItem(key),
  );

  // stringからU（NonFunctionのT）に型変換
  const parsed = useMemo(
    (): U => parseItem<U>(value) ?? defaultValue,
    [defaultValue, value]
  );

  useEffect((): VoidFunction => {
    const handler = (event: StorageEvent) => {
      if (event.key === key && event.storageArea === window.sessionStorage) {
        setValue(event.newValue);
      }
    };

    // storageにイベントリスナーを追加し、キー情報が一致するセッションストレージが更新された場合は
    // useStateにセッションストレージの新しい値をセットする
    window.addEventListener("storage", handler);

    return (): void => {
      window.removeEventListener("storage", handler);
    };
  }, [key, defaultValue]);

  // hooksの返り値のセット関数が呼ばれたら、セッションストレージのセット関数を呼ぶ
  // → セッションストレージの値が変われば、storageのイベントリスナーが発火してuseStateの値が更新される
  const handleSetItem = useCallback(
    (dispatch: U | ((prev: U) => U)): void => {
      if (typeof dispatch === "function") {
        setItem<T>(key, (dispatch as (prev: U) => U)(parsed));
        return;
      }
      setItem<T>(key, dispatch);
    },
    [key, parsed],
  );

  // hooksの返り値の削除関数が呼ばれたら、セッションストレージのセット関数を呼ぶ
  // → セッションストレージの値が変われば、storageのイベントリスナーが発火してuseStateの値が更新される
  const handleRemoveItem = useCallback((): void => {
    removeItem(key);
  }, [key]);

  return [
    parsed,
    {
      setItem: handleSetItem,
      removeItem: handleRemoveItem, 
    },
  ];
};
```

### 解説
大まかなデータ更新の流れをまとめます。

##### hookのデータセット処理
1. コンポーネントやページでhooksのsetItemを呼ぶ
1. setItem内部でセッションストレージに値をセットする
1. 値をセットした後、手動でセッションストレージの更新イベントを発火する
1. hook内部のuseEffect内部でセットした、セッションストレージのイベントリスナーが発火し、hook内部のuseStateのセット関数を呼ぶ


##### hookのデータ取得
1. useStateのvalueが変更されると、valueに依存するuseMemoのparsedが更新される
1. parsedをhookの返り値にすることで、常に最新の値が取れる

#### 図にするとこんな感じ
```mermaid
sequenceDiagram
    autonumber
    actor ユーザー
    participant setItem as setItem関数
    participant session as session storage
    participant event as event listener
    
    ユーザー->>setItem: setItem関数の呼び出し
    setItem->>session: セッションストレージに<br/>値をセットしつつ、<br/>イベント発火
    session->>event: イベントが発火したので、<br/>リスナーのハンドラーを実行
    event->>useState: ハンドラー内部で<br/>useStateにセット
    useState->>useMemo: useStateのvalueが変わったので、<br/>parsedの値を更新
    useMemo->>ユーザー: parsedを返却
```

# まとめ
これでセッションストレージをリアクティブに扱えますね🙌