概要
セッションストレージの値をリアクティブに管理するためにやったことをまとめます。
※今回は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は、画面をリロードするまで値をリアクティブに管理できます。
利用方法
const [state, setState] = useState("initialState")
// まだ値をセットしていないので初期値の"initialState"が返却される
console.log(state) // "initialState"
// 値をセット
setState("nextState")
// いつでも最新の値(ここだと"nextState")を取得できる
console.log(state) // "nextState"
リアクティブなセッションストレージ
上記の前提より、セッションストレージとuseStateをうまく組み合わせれば
- リアクティブに状態を返す
- リロードしても値が飛ばない
ようなデータ管理ができるはず。
前置きが長くなりましたが、実際に作成したhooksを紹介します。
実際に作成したhooks
※今回はtypescript/Reactとセッションストレージを組み合わせたので、型変換も含みます。
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のデータセット処理
- コンポーネントやページでhooksのsetItemを呼ぶ
- setItem内部でセッションストレージに値をセットする
- 値をセットした後、手動でセッションストレージの更新イベントを発火する
- hook内部のuseEffect内部でセットした、セッションストレージのイベントリスナーが発火し、hook内部のuseStateのセット関数を呼ぶ
hookのデータ取得
- useStateのvalueが変更されると、valueに依存するuseMemoのparsedが更新される
- parsedをhookの返り値にすることで、常に最新の値が取れる
図にするとこんな感じ
まとめ
これでセッションストレージをリアクティブに扱えますね🙌