Logo
Overview

Jotaiなんもわからん

February 22, 2025
2 min read

背景

State管理ライブラリであるJotaiを使っているのですが、なんもわからんので将来わかることを見越してメモしておきます。

Async

atom

これはユーザーのデータなどを固定値のURLを呼び出してデータを取ってくるときに使えます。

const userAtom = atom((get) => {
const response = await fetch(new URL(...).href)
return await response.json()
})

これはfetch中にはSuspensefallbackのコンポーネントが呼び出されるのでユーザーに「現在通信中である」ということを簡単に通知できます。

よって、Suspenseを利用する必要があります。

const View: React.FC = () => {
const user: View = useAtomValue(userAtom) return (
<p>{user.name}</p>
)
}

で、気になるのはもしユーザー情報を更新したい場合にはどうするかということですが単純な非同期atomにはsetがないのでこれは実現できません。

要するに、一回値が入るとキャッシュが利用されてしまうので二度と更新できません。

loadable

非同期atomの使い方の一つでSuspenseError Boundaryを使いたくないときに使います。

基本的にはSuspenseError Boundaryを使うと思うのでこちらは解説しません。

atomWithObservable

rxjsと組み合わせる感じです。

const counterAtom = atomWithObservable(
() => interval(1000).pipe(map((value) => value * 2))
);

ちょっと調べてみた感じvalueは0から順番に数値が返ってくるみたいですね。

const View: React.FC = () => {
const [counter] = useAtom(counterAtom);
return (
<>
<ModalDialog>
<DialogTitle>Asnyc</DialogTitle>
<DialogContent>
<button type="button">{counter}</button>
</DialogContent>
</ModalDialog>
</>
);
};

なのでこう書くと0, 2, 4, 8という感じで一秒ごとにデータが更新されます。

unwrap

非同期atomの使い方の一つでSuspenseError Boundaryを使いたくないときに使います。

基本的にはSuspenseError Boundaryを使うと思うのでこちらは解説しません。

atomWithRefresh

const userAtom = atomWithRefresh((get) => {
const response = await fetch(new URL(...).href)
return await response.json()
})

これは非同期atomに対してリフレッシュ機能を提供します。

const View: React.FC = () => {
const [user, refresh] = useAtom(userAtom)
return (
<button onClick={() => refresh()}>{user.name}</button>
)
}

この場合だとボタンを押したときにuserAtomの再評価が行われ、便利なわけですね。

公式ドキュメントだとonClick={refresh}だけで更新がかかるような書き方がされていますが、Vite+Reactの環境では動きませんでした。

Lazy

遅延評価をするatomです。

似たような仕組みがSwiftにもあるので多分同じ感じだと思います。

atomWithLazy

ですがググっても全然出てこない上に公式ドキュメントも良くわからないのでスキップします。

Reset

atomWithReset

初期値を再代入するためのatomです。

RESETという特別な値を入れることで初期化できます。

これもドキュメントが良くわからないのでまだ使えていません。

atomWithDefault

再代入かつ初期化できるatomです。

const count1Atom = atom(1);
const count2Atom = atomWithDefault((get) => get(count1Atom) * 2);

例えばこう書いたとすると初期値はそれぞれ1と2になります。

ただ、count2Atomの初期値がcount1Atomの二倍にしたいのであれば、

const count1Atom = atom(1);
const count2Atom = atom((get) => get(count1Atom) * 2);

こう書いても同じ結果が得られます。ただし、これはReadOnlyなatomなので再代入ができません。

const View: React.FC = () => {
const [count1, setCount1] = useAtom(count1Atom);
const [count2, setCount2] = useAtom(count2Atom);
return (
<div>
count1: {count1}, count2: {count2}
</div>
<button type='button' onClick={() => setCount1((c) => c + 1)}>
increment count1
</button>
<button type='button' onClick={() => setCount2((c) => c + 1)}>
increment count2
</button>
<button type='button' onClick={() => setCount2(RESET)}>
Reset with RESET const
</button>
)
}

こう書くとcount2を変更しない間は初期値が利用されるのでcount1を増加させるとその二倍の値がcount2として設定されます。

そこからcount2だけを増加させるとcount1を増やしてもこれら二つは同期されなくなりますが、リセットを実行するとcount2count1の値の二倍(初期値)に戻ります。

const [count2, setCount2] = useAtom(count2Atom);
const resetCount2 = useResetAtom(count2Atom);

という風に定義すればresetCount2()setCount2(RESET)は同じ効果を持ちます。

Storage

値をLocalStorageなどに保存して永続化することができます。

主に設定項目などを保存しておくと良いと思います。

LocalStorageは暗号化されていないのでクレデンシャルなどの情報は保存してはいけません。

const darkModeAtom = atomWithStorage("theme", false);

キー名を指定して値を保存します。このとき、デフォルトだとJSON.stringify()が利用されるのでJSONに変換できない値は保存できません。保存したい場合には自分で保存用のメソッドを定義する必要があります。

また、何も指定しない場合にはLocalStorageから読み込む前に第一引数(この場合はfalse)で初期化されます。

const View: React.FC = () => {
const [darkMode, setDarkMode] = useAtom(darkModeAtom);
return (
<button type="button" onClick={() => setDarkMode(!darkMode)}>
{darkMode ? "Dark Mode" : "Light Mode"}
</button>
)
}

よってこのようにコンポーネントを定義した場合、どのような値で保存されていたとしても最初にLight Modeと表示されてしまいます。

これが意図した挙動と違うのであれば、

const darkModeAtom = atomWithStorage("theme", false, undefined, {
getOnInit: true,
});

このように第三引数のオプションにgetOnInittrueを指定すれば値が保存されていれば初期値の代わりにLocalStorageから読み込んだ値で初期化します。

こっちの方が挙動として自然だと思うので、こっちを使ったほうがいいと思います。

Family

atomFamily

atomFamilyは引数を受け取ってatomを返すメソッドを提供します。

type User = {
name: string
age: number
}
const usersAtomFamily = atomFamily(
({ name, age}: User) => atom({ name: name, age: age}),
(a,b) => a.name === b.name
)

これは無名のatomを生成し、Jotaiの内部状態として保存します。よって、外部からatomの管理をしなくていいというのが便利です。

const usersAtom = atom<User[]>([])

じゃあこれと変わらないんじゃないかと思うかもしれませんが、atomFamilyの場合はそれぞれ個別のatomを保存しているため要素の一つの値が変化したときにその値に関するコンポーネントだけ再レンダリングが走ります。

それに対して要素の配列自体をatomに入れた場合はどれか一つの値が変わると全ての要素に関するコンポーネントが再レンダリングされるため、例えば要素が100あるatomが変化したとき前者では変化したatomの分だけしかコンポーネントが変化しませんが、後者の場合は全てのコンポーネントが再描画されるため動作が重くなるなどの懸念が発生します。

よって、配列の一部が変化する可能性がある変数をatomとして管理する場合にはatomFamilyを利用したほうが効率が良いことがあります。

唯一困る点としてはatomFamilyは保存している内部状態全ての値を一度に取得するプロパティがありません。

よって、もし全件をループしてコンポーネントとして表示したい場合には保存しているatomのユニークなキーを別のatomとして保存しておく必要があります。

Callback

const countAtom = atom<number>(0)

まずは適当に数値をカウントするためのatomを作成します。

const Counter: React.FC = () => {
const [count, setCount] = useAtom(countAtom)
return (
<>
<div>ATOM: {count}</div>
<button type="button" onClick={() => setCount((c) => c + 1)}>
+1
</button>
</>
)
}
const View: React.FC = () => {
const [count, setCount] = useState(0)
const readCount = useAtomCallback(
useCallback((get) => {
const current: number = get(countAtom);
console.log("CURRENT:", current);
setCount(current);
return current;
}, []),
);
// readCountが初期化された段階で呼ばれる
useEffect(() => {
const timer = setInterval(async () => {
console.log(readCount());
}, 1000);
return () => {
clearInterval(timer);
};
}, [readCount]);
return (
<div>CALLBACK: {count}</div>
<Counter />
)
}

こう書くとボタンを押せばcountAtomの状態が変わってどんどん値が変わるのは当たり前なのですが、このときuseAtomCallbackはどういう挙動をするのかが気になります。

で、どうなっているかというと一秒に一回readCountが呼び出されてその時にcountAtomの値を取得してその値をcountに代入します。

よって、ボタンを連打していても一秒に一回、countAtomの値とcountの値が同期されるというわけです。

この機能自体は通常のatomを使って、

const Monitor: React.FC = () => {
const [count, setCount] = useState(0);
const _count = useAtomValue(countAtom);
useEffect(() => {
setCount(_count);
}, [_count]);
return <div>CALLBACK: {count}</div>;
};

こう書けば同期的に実行できるのにわざわざuseAtomCallbackを使うのはLocalStorageやAPI通信などの非同期通信を伴う場合、即座に変更させることができないからですね。

まとめ

まだまだ途中までしかかけていませんが、これからいろいろJotaiについて調べていこうと思います。