はじめに
ReactのコンポーネントでlocalStorageの値を扱っても、値の変化によるレンダリングは引き起こされません。これは、localStorageの値がReactの文脈における状態としては扱われないためです。
例としてuseStateによるカウンターと、localStorageの値をそのまま使うカウンターを用意しました。
localStorageの方は値の変化が画面に反映されるような期待した動作は生じません。
この記事では、この点を改善するために以下の2点を満たしたカウンターを作成することをゴールとします。
- 初期値は
localStorageがもつ値に設定 - ボタンのクリックによって表示される値と
localStorageに保存される値の両方を更新
localStorage
localStorageは保存期間に制限がないローカルのStorageオブジェクトです。key-value方式でデータを保存でき、オリジンごとに保存されます(つまりhttpやhttpsのように異なるプロコトルを利用した場合も異なる領域に保存されます)。
getItem
指定したキー名に対応した値を取得できます。
const value = localStorage.getItem(key);
getItemの型は
(keyName: string) => string | null;
のようになっています。そのため、localStorageから取得する値は必ずstringまたは、nullになります。nullが返ってきた時はそのキー名に対応する値が存在していないことを意味します。
setItem
指定したキー名とそれに対応する値を保存するメソッドです。
localStorage.setItem(key, value);
保存したいキー名を第一引数に、保存するキーの値を第二引数に渡します。
型は以下のようになってます。
(keyName: string, keyValue: string) => void;
第一引数に渡したキー名と対応する値が、既に存在していた場合は上書きされます。
removeItem
指定したキー名に対応する値を削除するメソッドです。
localStorage.removeItem(key)
型は以下のようになっています。
(keyName: string) => void
指定したキー名がlocalStorageに存在しなかった場合は何も行いません。
その他
他にも、localStorageにあるすべてのデータを削除するclearメソッドだったり、キー名の一覧を取得するkeyメソッドが存在します。
Reactの状態として扱う
最初に見た通りlocalStorageはそのままReactの状態として扱えません。値を状態として見做すためには、値の変化に応じてレンダリングが実行されることと、レンダリング間で値を保持し続ける性質を持つ必要があります。
localStorageはReactのライフサイクルの外部で持つ値なので、後者のレンダリング間で値を保持し続ける性質はクリアしています。そのため、前者の値が変化した時にレンダリングを実行させる仕組みができれば、期待するカウンターを作ることができます。
この記事ではlocalStorageの値をuseStateによって作られたReactの状態に連携させて行います。
関数の土台を作る
まず最初は作成する関数の外型として、useStateをラップしただけの関数を作ります。
export const useLocalStorage = (key: string, initValue: string) => {
const [value, setValue] = useState(initValue);
return [value, setValue] as const;
};
引数には保存するキー名と初期値を渡しています。キー名はこの関数では使ってませんが、後でlocalStorageを扱うときに利用します。
返り値はuseStateによって得られるvalueとsetValueをそのまま返します。
これを用いてカウンターを作ると以下のようになります。
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の値を参照するようにします。
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という関数を渡すようにしました。
const [value, setValue] = useState(getLocalStorageValue(key, initValue));
このように書くと、レンダリングの度にgetLocalStorageValueが計算されてしまうので、
const [value, setValue] = useState(() =>
getLocalStorageValue(key, initValue)
);
のように関数を渡すようにしました。
getLocalStorageValueはlocalStorageからキー名がkeyの値を取り出して、nullであれば(存在しなければ)initValue、そうでなければlocalStorageに保存された値を返すような関数です。
これを用いて状態の初期値をlocalStorageに既に値がある場合はコンポーネントから渡される初期値でなくlocalStorageによって管理された値になります。
状態の変更と一緒にlocalStorageを更新する
最後に、状態の値が変更するたびにlocalStorageの値も変更するようにします。
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と同期した状態を扱えるようになりました。この関数を使えば以下のようなカウンターができます。
リロードするとlocalStorageの値(リロード前に表示されていた値)が初期表示され、ボタンをクリックするとレンダリングが発火して表示される値が更新されるようになりました。
異なるタブでlocalStorageが更新されたことを確認する
異なるタブでlocalStorageの値が更新された時、storageイベントが発火します。
そのため、それらを検知するにはwindow.addEventListenerを用いて以下のようにします。
window.addEventListener('storage', callback);
これをReactに組み込むにはuseEffectを使って書きます。
useEffect(() => {
window.addEventListener('storage', callback);
return () => {
window.removeEventListener('storage', callback);
};
}, []);
コンポーネントの初期マウント時に、storageイベントにコールバック関数を追加して、アンマウント時にそれを解除するようなコードです。
それを、useLocalStorageに組み込みます。
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の値に更新しています。
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の値を参照する状態が必要な場合はこのように実装してみてはいかがでしょうか。