Jotaiに入門する〜天気予報アプリで学ぶReactの状態管理〜
そもそもJotaiとは
Reactの状態管理ライブラリです。useStateと使い方が似ていますが、グローバルに値を管理できるのが特徴です。コンポーネントが深くネストした際に発生する「バケツリレー(prop drilling)」問題を解消できます。
また、atomWithQueryやatomWithPromiseを使えば非同期にfetchすることも可能です。
useContextとの違い
useContextも同様に、値をグローバルに使用できるようになる仕組みです。しかし以下のような違いがあります。
再レンダリングの範囲
-
useContext:まとめて再レンダリングされる
- useContextで使っている値が変わった時、そのcontextを読んでいる全てのコンポーネントが再レンダリングされます。そのため、その値を使っていないコンポーネントも巻き込まれる可能性があります。
-
Jotai:「atomを使っているところだけ再レンダリング」
- Jotaiはatom単位で再レンダリングされるので、細かく分けるほど効率がいいです。
導入の仕方
# npm
npm install jotai
# yarn
yarn add jotai
# pnpm
pnpm add jotai
atomとは
一言で言うと、atomは最小の状態の単位です。
もしuseStateでcountUpの状態を管理しようとするとこうなります。
import { useState } from 'react';
const [count, setCount] = useState<number>(0);
Jotaiではこの状態をatomとして管理します。
import { atom } from 'jotai';
export const countAtom = atom<number>(0);
ポイント
- 「
= atom<number>(0)」の部分でatomを作成しています - atomを定義する場合、必ず
exportをつけること(他のコンポーネントから読み込むため)
atomを使う3つのフック
Reactではこう使います。
import { useAtom } from 'jotai';
import { countAtom } from './atoms';
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>クリック回数: {count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>+1</button>
</div>
);
}
atomへのアクセス方法は用途によって3種類使い分けます。
| フック | 用途 |
|---|---|
useAtom |
値の読み取りと更新の両方をしたい時 |
useAtomValue |
値の読み取り専用 |
useSetAtom |
値の更新専用(書き込み専用) |
// 読み取りも書き込みもしたい時
const [count, setCount] = useAtom(countAtom);
// 読み取り専用
const count = useAtomValue(countAtom);
// 書き込み専用
const setCount = useSetAtom(countAtom);
💡 不要な再レンダリングを避けるため、用途に合わせて使い分けるのが推奨です。
派生atom(Derived Atoms)について
派生atomは、他のatomの値から動的に計算される状態です。元のatomが変わると、派生atomの値も自動的に再計算されます。
派生atomは大きく3つのタイプに分けられます。
- Read-only atom(読み取り専用)
- Write-only atom(書き込み専用)
- Read-Write atom(読み書き両方)
野球で例えるとこんな感じ
// 基本atom
const hitsAtom = atom<number>(120); // ヒット数
const atBatsAtom = atom<number>(400); // 打数
// Read-only な派生atom(打率を計算)
const battingAverageAtom = atom((get) => {
const hits = get(hitsAtom);
const atBats = get(atBatsAtom);
return (hits / atBats).toFixed(3); // .300
});
ヒット数や打数のatomが更新されると、battingAverageAtomの打率も自動で再計算されます。自分で更新ロジックを書かなくていいのがメリットです。
Provider
Jotaiのatomはデフォルトでグローバルなストアになっており、どこからでも読み書きできるのがメリットです。しかし、<Provider>で囲むとその中だけのローカルストアを作ることができます。
動作イメージ
// グローバルストア(Providerなし)
<App>
<ComponentA /> {/* countAtom = 5 */}
<ComponentB /> {/* countAtom = 5(同じ値を共有) */}
</App>
// Providerで分離
<App>
<Provider>
<ComponentA /> {/* countAtom = 5 */}
</Provider>
<Provider>
<ComponentB /> {/* countAtom = 0(別のストア!) */}
</Provider>
</App>
どんな時に使う?
- 同じコンポーネントを複数配置するが、状態は独立させたい時
- 例:複数のモーダル、複数のフォームインスタンス、テスト時の状態分離
グローバル過ぎて使いづらい時に範囲を絞るための機能、と理解するといいかもしれません。
天気予報アプリを例に
ここからは実際のアプリを例に、Jotaiの使い方を見ていきます。
ディレクトリ構成
weather/
├── atom/
│ └── weather.ts
├── components/
│ └── (各種コンポーネント)
├── css/
│ └── (各種CSSファイル)
├── hooks/
│ └── useWeather.tsx
└── type/
└── (型定義)
atom/weather.ts
import { atom } from 'jotai';
import type { WeatherFetchType } from '../types/WeatherType';
import { atomWithQuery } from 'jotai-tanstack-query';
export const weatherAtom = atom<WeatherFetchType>();
export const getWeatherAtom = atomWithQuery<WeatherFetchType>(() => ({
queryKey: ['weathers'],
queryFn: async () => {
const response = await fetch(
'https://weather.tsukumijima.net/api/forecast/city/130010',
);
const json = await response.json();
return json;
},
}));
weatherAtomについて
export const weatherAtom = atom<WeatherFetchType>();
これは**空の状態(初期値なし)**のatomです。useAtom(weatherAtom)を使えば、他のコンポーネントでこの状態にアクセス&更新ができます。
atomWithQueryについて
export const getWeatherAtom = atomWithQuery<WeatherFetchType>(() => ({
queryKey: ['weathers'],
queryFn: async () => {
const response = await fetch(
'https://weather.tsukumijima.net/api/forecast/city/130010',
);
const json = await response.json();
return json;
},
}));
これはjotai-tanstack-queryパッケージの機能で、状態管理とAPIフェッチをセットで扱えます。
atomWithQueryのメリット
-
useAtom(getWeatherAtom)を使うだけで自動的にAPIからデータを取得できる -
useEffectやuseStateを自分で書かなくていい - ローディング・エラー・キャッシュ管理まで自動でやってくれる
queryKey
- キャッシュの識別子です。同じキーなら結果を再利用できます
- 似たAPIを使い分ける場合は
['weather', cityId]のようにキーを工夫します
queryFn
- 実際にデータを取得する関数です
-
returnした値がuseAtom(getWeatherAtom)を通してUIに届きます
hooks/useWeather.tsx
import { WeatherFetchType } from '../types/WeatherType';
import { useAtom } from 'jotai';
import { weatherAtom, getWeatherAtom } from '../atom/weather';
import { useEffect } from 'react';
export const useWeather = () => {
const [weatherData, setWeatherData] = useAtom<WeatherFetchType | undefined>(
weatherAtom,
);
const [{ data, isFetching, isError }] = useAtom(getWeatherAtom);
useEffect(() => {
if (data) {
setWeatherData(data);
}
}, [data, setWeatherData]);
return { weatherData, isLoading: isFetching, isError };
};
💡 設計意図について
今回はatomWithQueryの結果をいったんweatherAtomに流し込む構成にしています。これは「他のコンポーネントからweatherAtomを直接書き換えるユースケース」を想定した設計です。
シンプルに使うだけなら、weatherAtomを経由せずatomWithQueryの結果を直接返してもOKです。
atomWithQueryが返す値
const [{ data, isFetching, isError }] = useAtom(getWeatherAtom);
atomWithQueryは自動的に以下の3つを返します。
| プロパティ | 説明 |
|---|---|
data |
fetchで取得したデータ。型はatomWithQueryで指定した型 |
isFetching |
fetch中ならtrue、終わっていればfalse
|
isError |
エラーが起こったらtrue、デフォルトはfalse
|
useEffectで詰め替えている理由
useEffect(() => {
if (data) {
setWeatherData(data);
}
}, [data, setWeatherData]);
なぜこんな書き方をしているかというと、atomWithQueryを記述した時の型はWeatherFetchTypeを指定しました。しかし、取得したデータが渡ってくるdataという箱はWeatherFetchType | undefinedになります。これはfetch前はデータが無いのでどうしようもないです。
そのため、if文でundefinedの可能性を排除してからweatherAtomにセットしています。
return時のリネームについて
return { weatherData, isLoading: isFetching, isError };
isLoading: isFetchingは、isFetchingをisLoadingという名前で返すという意味です。
💡
isFetchingはエンジニア用語すぎてデザイナーなど他職種に伝わりにくいため、isLoadingにリネームするのが公式推奨のやり方です。
components/DailyWeather.tsx
import dailyWeather from '../css/dailyWeather.module.css';
import { WeatherBox } from './WeatherBox';
import { Temperture } from './Temperture';
import { ChanceOfRain } from './ChanceOfRain';
import { useAtomValue } from 'jotai';
import { weatherAtom } from '../atom/weather';
type DailyWeatherProps = {
id: number;
};
export const DailyWeather: React.FC<DailyWeatherProps> = ({ id }) => {
const weatherData = useAtomValue(weatherAtom);
return (
<div className={dailyWeather.dailyWeatherCard}>
<p>
{weatherData?.location.city}の{weatherData?.forecasts[id].dateLabel}
の天気
</p>
<WeatherBox id={id} />
<Temperture id={id} />
<ChanceOfRain id={id} />
</div>
);
};
💡 コンポーネント側では基本的に
setはしない方がいいので、コンポーネントファイルではuseAtomValueを使用するのを目指すべきです。状態の更新はカスタムフックや専用の処理に集約すると保守性が上がります。
atomWithQueryの発火ポイント
atomWithQueryはいつ実行されるのか?答えは2つあります。
-
useAtom()でそのatomを初めて呼んだ時 -
get()で依存しているatomの値が変わった時
export const getWeatherAtom = atomWithQuery<WeatherFetchType>(() => ({
queryKey: ['weathers'],
queryFn: async () => { /* ... */ },
}));
ここでいうgetWeatherAtomをuseAtom(getWeatherAtom)で使用すれば、atomWithQueryが実行されます。
atomWithQuery内でmap関数を回す場合
// atom/getFetchAtom.ts
export const getDetailDataAtom = atomWithQuery<populationData[]>((get) => ({
queryKey: ["populationData"],
queryFn: async () => {
const activeCode = get(activeCodeAtom);
const response = await Promise.all(
activeCode.map((num: number) => {
return fetch(
`http://localhost:3001/api/v1/population/composition/perYear?prefCode=${num}`, {
headers: {
"X-API-Key": "8FzX5qLmN3wRtKjH7vCyP9bGdEaU4sYpT6cMfZnJ"
}
}
).then((response) => response.json())
.then((json) => json.result)
})
)
return response;
},
}))
ポイントは今までとあまり変わらないですが、最初につまずいた疑問点を共有します。
「この関数はいつ実行されるのか?」
通常の関数であれば、配列の中身が変わった時にイベントで関数を呼び出せます。しかし、これはatomフォルダの中のatomであり、外部から関数名で呼び出すことはできません。
答えはこの行にあります。
const activeCode = get(activeCodeAtom);
これはactiveCodeAtomの中にある配列を取得してactiveCodeに格納しているコードですが、実はこのget()が結構いい仕事をしています。
get()の引数のatomが変わると、queryFnが再実行されます。つまりこの例ではactiveCodeAtomが変わればfetchが走ります。これでイベント監視しているわけです。
⚠️ ハマりポイント:queryKeyに依存値を含める
実はこのコードには改善の余地があります。queryKeyに依存値を含めないとキャッシュが効きすぎて古いデータが返ってくることがあります。
修正版はこんな感じです。
export const getDetailDataAtom = atomWithQuery<populationData[]>((get) => {
const activeCode = get(activeCodeAtom);
return {
queryKey: ["populationData", activeCode], // ← activeCodeをキーに含める
queryFn: async () => {
const response = await Promise.all(
activeCode.map((num: number) => {
return fetch(
`http://localhost:3001/api/v1/population/composition/perYear?prefCode=${num}`, {
headers: {
"X-API-Key": "8FzX5qLmN3wRtKjH7vCyP9bGdEaU4sYpT6cMfZnJ"
}
}
).then((response) => response.json())
.then((json) => json.result)
})
)
return response;
},
};
})
queryKeyにactiveCodeを含めることで、依存値が変わった時にキャッシュが正しく無効化されます。
まとめ
Jotaiは「最小の状態単位(atom)」を中心としたシンプルな状態管理ライブラリです。
- useState感覚で使えるグローバル状態管理
- atom単位の再レンダリングで効率がいい
atomWithQueryで状態管理 + API fetchがセットで完結- 派生atomで動的な値を自動計算
- Providerで状態のスコープを切り分けられる
useStateやuseContextに限界を感じたら、ぜひ試してみてください!