jotaiとても便利ですよね
そんな中でuseAtomによって用意したsetterで更新した値が、予想通りの動きをせず困ったことがあったので、その対処法をメモとして残しておこうと思います。
Jotaiとは
reactの状態管理ライブラリです。
useStateのように一つのコンポーネントで保持する変数とは異なり、useContextのようなReactアプリケーション全体で変数を扱えるようになります。
Jotaiは、atomという単位でstateを取り扱っていきます。
import { atom } from 'jotai'
export const sampleAtom = atom(0);
そこから使いたいコンポーネントでuseAtomを使うことでgetterとsetterが使えるようになります。
import { sampleAtom } from "@/store"
import { useAtom } from "jotai"
const [counter, setCounter] = useAtom(sampleAtom);
起きた現象
useAtomでgetterとsetterを用意して、下のようにsetterで値を更新しながら非同期でconsoleで表示しようとしたとします。
import { sampleAtom } from "@/store"
import { useAtom } from "jotai"
import { useEffect } from "react"
export const JotaiSample = () => {
const [counter, setCounter] = useAtom(sampleAtom);
// 非同期で処理する
const asyncLogStaleValue = () => {
setTimeout(() => {
// この時点では counter が更新されていても、コンポーネントマウント時の値(0)を参照している可能性がある
console.log("stale counter:", counter);
}, 1000);
};
useEffect(() => {
// マウント時にカウンターを更新
setCounter(prev => prev + 1);
// 非同期処理を呼ぶ
asyncLogStaleValue();
}, []);
return (
<div>
<h2>Counter: {counter}</h2>
<p>この例では、非同期で読み取っている counter の値が更新されない(stale)状況を再現しています。</p>
</div>
);
}
ブラウザ上ではcounterの値は1に変わっていたのですが、consoleでは0となっており、更新されていない状態で表示されていました。
原因
これはStaleClosureという現象が起きているからでした。
closureは、以下のように説明されます。
クロージャは、組み合わされた(囲まれた)関数と、その周囲の状態(レキシカル環境)への参照の組み合わせです。言い換えれば、クロージャは関数にその外側のスコープにアクセスする機能を提供します。JavaScript では、クロージャは関数が作成されるたびに、関数作成時点で作成されます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Closures
という説明なのですがわかりづらいですよね。
一般的には、クロージャーとは関数が作成された時点で関数のスコープ内にある変数の値を保持し続けてしまう状態のことを指します。
上のコードで言うとマウントされた状態では、counterは0になっており、作成されたasyncLogStaleValue
はcounterが0の状態を保持しています。
さらに、setCounterでatomの値を変更した後に、非同期でasyncLogStaleValue
を実行しているためasyncLogStaleValue
が再作成(レンダリングによる再作成)が行われず、counterは0のまま扱ってしまっています。
このため思ったような動作をしていない状態になっています。
解決方法
JotaiにはStoreと呼ばれるのが存在します。
jotaiのatomはデフォルトでStoreを確保してそこにstateを保管しています。
そのStoreはgetDefaultStoreで作成したインスタンスからアクセスすることができます。
const defaultStore = getDefaultStore()
そこからgetメソッドを使うことでstoreの値を直接参照しにいくことができます。
defaultStore.get(sampleAtom)
これを使って下のようにコードを変えてみたところ、思い通りの動作をしました。
動作した理由としては、変数ではなくstoreからatomのstateを取りにいくという関数を実行しているため、StaleClosureによる値の保持が行われず、実行するごとに値が取りにいっているからだと思います。
"use client"
import { sampleAtom } from "@/store"
import { useAtom, getDefaultStore } from "jotai"
import { useEffect } from "react"
export const JotaiSample = () => {
const [counter, setCounter] = useAtom(sampleAtom);
const store = getDefaultStore(); //デフォルトのstoreを取得
// 非同期処理の中で、初期の counter 値がクロージャに捕捉される例
const asyncLogStaleValue = () => {
setTimeout(() => {
// この時点では counter が更新されていても、
console.log("stale counter:", store.get(sampleAtom)); //storeで参照
}, 1000);
};
useEffect(() => {
// マウント時にカウンターを更新
setCounter(prev => prev + 1);
// 非同期処理を呼ぶ
asyncLogStaleValue();
}, []);
return (
<div>
<h2>Counter: {counter}</h2>
<p>この例では、非同期で読み取っている counter の値が更新されない(stale)状況を再現しています。</p>
</div>
);
これ以外のやり方だとそもそもJotaiを使わずに、useRefでのメモ化を利用することで解決できるそうですが、今回はどうしてもJotaiを使うことで解決する必要があったので採用していません。
ぜひ参考にしてください。