1
/
5

【React】useSyncExternalStoreでlocalStorageの値をuseStateライクに扱う

React18で追加されたuseSyncExternalStoreというhookを使って、localStorageのデータ取得、更新を行ってみたいと思います。

目次

  • useSyncExternalStoreの概要

  • ゴールの確認

  • useLocalStorageを作っていく

  • 型と雛形作成

  • 第一引数(subscribe)

  • 第二、第三引数(スナップショットを返す関数)

  • setterを作る

  • 完成

  • おわりに

useSyncExternalStoreの概要


イメージ的には、外部ストアの値を取得しつつ、変更を加えればそれを検知してReactが再レンダリングしてくれるといった機能です。

例えば、useSyncExternalStoreを使い、ブラウザ(=React管理外のシステム)のオンラインステータスを検知するisOnlineというstateを作ったとき、ブラウザをオフライン状態に変更したら、それを検知してisOnlineもfalseになり、画面も再レンダリングされます。

簡単に言えば、レンダリング的な観点で、外部ストアをstateと同じように扱えるようなhookといった感じでしょうか。

詳しく知りたい方は、公式ドキュメントをご参照ください。
useSyncExternalStore – React

ゴールの確認

汎用性を高めるべく、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;
}
};

おわりに

ロジカルスタジオでは、ReactやVue.jsの技術、経験を活かしたいフロントエンドエンジニアを募集しています。
もしご興味がございましたら、下記よりご応募お待ちしております。

フロントエンドエンジニア
チームと共にプロジェクトを成功へ導くテクニカルディレクターを募集!!
ロジカルスタジオは、テクノロジーとデザインの視点からクライアントの課題解決をめざす、大阪のクリエイティブプロダクションです。 クライアントの期待を超えて「もっと良くなる、を見つける」のが私たちのスタイル。企画・提案からデザイン制作・システム構築、運用、改善提案をワンストップで行えるのが一番の強みです。近年ではその実績と品質が評価され、案件も急増しています。 代表の古川が掲げる企業理念は「絆を大切に、周りの人を豊かにし、社会に貢献する」。 私たちはこのミッションを推進するため、「新しい技術へのチャレンジ精神」を軸に、「切磋琢磨する仲間」と「働きやすい職場環境」を整え、「成長と可能性を大切にする風土」を育ててきました。 2019年9月に増床し、外部向けのセミナーにも活用できるカンファレンスルームを拡充。スタッフによる公式ブログや、マスコットキャラクター「ロージー&カール」によるTwitterなど、情報発信も強化しています。 可能性の芽をすくい上げられるこの場所を、より大きくしていきたい。 私たちと一緒に、このビジョンを実現しませんか。
株式会社ロジカルスタジオ


Invitation from 株式会社ロジカルスタジオ
If this story triggered your interest, have a chat with the team?
株式会社ロジカルスタジオ's job postings
6 Likes
6 Likes

Weekly ranking

Show other rankings
Like 岩村 充基's Story
Let 岩村 充基's company know you're interested in their content