はじめに
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
の値を参照する状態が必要な場合はこのように実装してみてはいかがでしょうか。