React18で追加されたuseSyncExternalStoreというhookを使って、localStorageのデータ取得、更新を行ってみたいと思います。
目次
useSyncExternalStoreの概要
イメージ的には、外部ストアの値を取得しつつ、変更を加えればそれを検知してReactが再レンダリングしてくれるといった機能です。
例えば、useSyncExternalStoreを使い、ブラウザ(=React管理外のシステム)のオンラインステータスを検知するisOnlineというstateを作ったとき、ブラウザをオフライン状態に変更したら、それを検知してisOnlineもfalseになり、画面も再レンダリングされます。
簡単に言えば、レンダリング的な観点で、外部ストアをstateと同じように扱えるようなhookといった感じでしょうか。
詳しく知りたい方は、公式ドキュメントをご参照ください。
ゴールの確認
汎用性を高めるべく、useLocalStorageというカスタムフックを作ります。
作っていく前に、どういう使い方にするかを確認していきます。
以下のように、ほとんどuseStateと同等の使用感で扱えるようにしていきます。
const [todos, setTodos] = useLocalStorage("todos", []);
const handleAdd = () => {
setTodos(t => [{ id: crypto.randomUUID(), content: "new Todo!"}, ...t])
}
第一引数にはlocalStorageのkeyとなる文字列を、第二引数には初期値を渡し、返り値はuseState同様、値とsetterが入った配列にします。
useLocalStorageを作っていく
型と雛形作成
まずは型だけを指定した雛形を作ります。
type SetValue = (value: T | ((prevState: T) => T)) => void;
export const useLocalStorage = (
key: string,
initialValue: T
): [T, (value: SetValue) => void] => {
};
第一引数のkeyは文字列を受け取り、第二引数は初期値をジェネリクスで受け取るようにします。返り値はuseStateに寄せるべく、SetValue型をつくり、値とsetterを返しています。
第一引数(subscribe)
第一引数のsubscribe関数を作っていきます。※hook内に依存する要素がないのでhook外で作ります。
const subscribe = (callback: () => void) => {
window.addEventListener("local-storage", callback);
return () => {
window.removeEventListener("local-storage", callback);
};
};
ブラウザで「local-storage」というイベントが発生した際にcallbackが発火するようにします。このcallbackには再レンダリング処理が入ることになります。setter関数内でlocal-storageイベントを発生させるので、つまりsetterが発火したら再レンダリングが走るという仕組みになります。これによりsubscriptionの動きが実現できます。
第二、第三引数(スナップショットを返す関数)
次に第二と第三引数の関数を作ります。※これらはinitValueとkeyに依存するため、hooks内で作ります。
// localStorage用に初期値をJSON.stringifyした値
const jsonStrData = JSON.stringify(initialValue);
// 第二
const getSnapshot = useCallback(() => {
const data = window.localStorage.getItem(key);
if (!data) {
window.localStorage.setItem(key, jsonStrData);
return jsonStrData;
}
return data;
}, [jsonStrData, key]);
// 第三
const getServerSnapshot = useCallback(() => {
return jsonStrData;
}, [jsonStrData]);
第二は、まずlocalStorage内に指定のkeyの値が存在しているかチェックし、存在していればlocalStorage.getItem(key)で取得した値を、存在していなければ新しくlocalStorage.setItemで初期値をセットし、その初期値を返すようにしています。
※ここの返り値とストアの値は同じでなければなりません。でないと無限に変更を検知し、無限レンダリングに陥ります。
第三はサーバーサイドでのスナップショットゆえに、ブラウザAPIであるlocalStorageは使えないので初期値をそのまま返しています。
さて、準備が整ったのでuseSyncExternalStoreを使っていきます。
const data = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);
これでデータを使えるかと思いきや、このままではlocalStorageから取得した値のままなのでstring型です。なのでJSON.parseします。安全にparseすべく、エラーハンドリングを施したparse関数を作ります。
const parseJSON = (value: string | null): T | undefined => {
try {
return value === "undefined" ? undefined : JSON.parse(value ?? "");
} catch (error) {
console.error(`パースに失敗しました。${error}`);
return undefined;
}
};
この関数でデータをparseします。
const parsedData = useMemo(() => parseJSON(data) as T, [data]);
setterを作る
最後にsetterを作ります。
useStateのsetterと同じ型なので、以下の型を使います。
import {
Dispatch,
SetStateAction,
} from "react";
type SetValue = Dispatch<SetStateAction>;
const setLocalStorage: SetValue = useCallback(
(value) => {
const newValue = value instanceof Function ? value(parsedData) : value;
window.localStorage.setItem(key, JSON.stringify(newValue));
window.dispatchEvent(new Event("local-storage"));
},
[key, parsedData]
);
主にやってることはlocalStorage.setItemです。
あと重要なのは「local-storage」というイベントを発火させている点です。さっきsubscribe関数で「local-storage」イベントを登録したと思いますが、それをここで発火させています。登録したイベントが発火しないとsubscribeのコールバック関数が発火されず、再レンダリングされません。この処理をコメントアウトし、localStorage.setItemを行うと、localStorageには反映されるが再レンダリングはされないという挙動になります。
完成
これで完成です。
import { useCallback, useMemo, useSyncExternalStore } from "react";
type SetValue = (value: T | ((prevState: T) => T)) => void;
export const useLocalStorage = (
key: string,
initialValue: T
): [T, SetValue] => {
const jsonStrData = useMemo(
() => JSON.stringify(initialValue),
[initialValue]
);
const getSnapshot = useCallback(() => {
const data = window.localStorage.getItem(key);
if (!data) {
window.localStorage.setItem(key, jsonStrData);
return jsonStrData;
}
return data;
}, [jsonStrData, key]);
const getServerSnapshot = useCallback(() => {
return jsonStrData;
}, [jsonStrData]);
const data = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const parsedData = useMemo(() => parseJSON(data) as T, [data]);
const setLocalStorage: SetValue = useCallback(
(value) => {
const newValue = value instanceof Function ? value(parsedData) : value;
window.localStorage.setItem(key, JSON.stringify(newValue));
window.dispatchEvent(new Event("local-storage"));
},
[key, parsedData]
);
return [parsedData, setLocalStorage];
};
const subscribe = (callback: () => void) => {
window.addEventListener("local-storage", callback);
return () => {
window.removeEventListener("local-storage", callback);
};
};
const parseJSON = (value: string | null): T | undefined => {
try {
return value === "undefined" ? undefined : JSON.parse(value ?? "");
} catch (error) {
console.error(`パースに失敗しました。${error}`);
return undefined;
}
};