状態管理ライブラリ Jotaiの使い方
今回は React アプリケーションの状態管理ライブラリの中でも人気の高い "Jotai" の使い方を記事にしたいと思います。豊富な機能の中から本記事では以下の機能について記載しています。
記事内に掲載しているソースコードは Github でも確認できます。
Jotai とは 🤔
Jotai は @dai_shi さんが作った React アプリケーションのための状態管理ライブラリです。軽量で使いやすく主に次のような特徴があります。
- Atom(アトム)と呼ばれる小さな単位で状態を管理できる
- 使い方が直感的で、学習コストが低い
- パフォーマンスに優れている
- 自由度が高い。Atom の加工をコンポーネント側または Atom 定義側で行なったりできる
- 非同期もサポートしている
- デバッグツールやユーティリティなどの追加できる機能が豊富
公式サイトはこちら → 👻 Jotai 公式サイト
開発環境 🛠️
では、開発環境を準備していきたいと思います。今回は Next を利用します。Node や Next などの詳しいバージョンは下記のようになっています。
各種バージョン情報
- Node.js(18.20.4)
- React(^18)
- Next(14.2.5)
- Jotai(^2.9.3)
- Jotai Location(^0.5.5)
- dayjs(^1.11.12)
では、まず create-next-app
で Next アプリの雛形を作ります。アプリ名は my-jotai としました。
npx create-next-app
/ /下記のように設定
What is your project named? my-jotai
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? No
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? No
続いて今回は jotai, jotai-location, dayjs
を使用しますのでこれらをインストールします。
npm install jotai jotai-location dayjs
これで準備ができました。まずは基本的な使い方から見ていきましょう。
基本的な使い方 🔍
今回はサンプルとして下記のような 「姓、名と誕生日」 を入力する簡単なフォームを作ります。
今回作るサンプル
画面を構成するコンポーネント、仕様や Atom については下記をご参照ください。
コンポーネント
コンポーネント名 | 説 明 |
---|---|
Form | フォーム用コンポーネント。リセット機能あり |
Result | Atom を加工、計算した結果を表示するコンポーネント |
Original | Atom をそのまま素の状態で表示する。確認用コンポーネント |
仕様
- 名前は First Name + " " + Last Name で連結して表示
- 名前は 全て大文字 で表示(日本語は対応しない)
- 生年月日の 表示は「YYYY 年 M 月 D 日」 、
input
のvalue
は 「YYYY-MM-DD」 とする - 年齢は 生年月日(birthday) から計算して表示
Atom
Atom | 型 | 説明 |
---|---|---|
firstNameAtom | String | First Name 用 |
lastNameAtom | String | Last Name 用 |
birthdayAtom | Date or null | Birthday 用 |
currentAgeAtom | Number or null | 現在の年齢用 |
なお、今回は CSS については解説しません。必要な方は下記からコピペして利用してください。
CSS
Atom とコンポーネントを作成する
"Form, Result, Original" の 3 つのコンポーネントを作る前に "Atom" を定義しておきます。
Atom について
Atom
とは "Jotai" で状態を管理する最小単位です。それぞれが独立しているので更新や使用が簡単にできます。
import { atom } from "jotai";
export const firstNameAtom = atom<string>("");
export const lastNameAtom = atom<string>("");
export const birthdayAtom = atom<Date | null>(null);
export const currentAgeAtom = atom<number | null>(null);
次に、コンポーネントを作っていきます。まずは、"Form" コンポーネントからです。
Form コンポーネント
このコンポーネントはフォームの入力、送信、リセットを担当します。
import { useAtom, useSetAtom } from "jotai";
import dayjs from "dayjs";
import "dayjs/locale/ja";
/** Atomのインポート */
import { firstNameAtom, lastNameAtom, birthdayAtom, currentAgeAtom } from "../atom";
type Props = {
onSubmit: React.Dispatch<React.SetStateAction<boolean>>;
};
export function Form({ onSubmit }: Props) {
// useAtom で値と更新用関数を取得
const [firstName, setFirstName] = useAtom(firstNameAtom);
const [lastName, setLastName] = useAtom(lastNameAtom);
const [birthday, setBirthday] = useAtom(birthdayAtom);
const setCurrentAge = useSetAtom(currentAgeAtom);
// 年齢を計算
useEffect(() => {
if (!birthday) return;
setCurrentAge(dayjs().diff(birthday, "year"));
}, [birthday]);
// フォーム送信
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(true);
};
// フォームリセット
const handleReset = () => {
setFirstName("");
setLastName("");
setBirthday(null);
setCurrentAge(null);
onSubmit(false);
};
return (
<form onSubmit={handleSubmit}>
<div className="formControll">
<div>
<label htmlFor="firstName">First Name</label>
<input type="text" id="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<p>※ 英字(小文字)のみ入力</p>
</div>
<div className="formControll">
<div>
<label htmlFor="lastName">Last Name</label>
<input type="text" id="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
<p>※ 英字(小文字)のみ入力</p>
</div>
<div className="formControll">
<div>
<label htmlFor="birthday">Biirthday</label>
<input
type="date"
id="birthday"
// "YYYY-MM-DD"にフォーマットして value にセット
value={birthday ? dayjs(birthday).format("YYYY-MM-DD") : ""}
// Date型に変換
onChange={(e) => setBirthday(dayjs(e.target.value).toDate())}
/>
</div>
<p>※日付を入力</p>
</div>
<div className="buttonGroup">
// 入力が空の場合は disabled
<button type="submit" disabled={!firstName || !lastName || !birthday}>
送信
</button>
// Atomをリセットする
<button type="button" onClick={handleReset}>
リセット
</button>
</div>
</form>
);
}
useAtom について
useAtom
は Atom の値と更新用関数の両方を取得するフックです。
useState
と同じような使い方ができるのが特徴で 「値と更新用関数」 の両方が必要な場合に使用します。
// useAtom で値と更新用関数を取得
const [firstName, setFirstName] = useAtom(firstNameAtom);
const [lastName, setLastName] = useAtom(lastNameAtom);
const [birthday, setBirthday] = useAtom(birthdayAtom);
useSetAtom について
useSetAtom
は Atom を更新用関数のみ取得するフックです。
「このコンポーネントでは更新のみ行うので値は不要」といった時などに使用します。
"Form" コンポーネントでは"年齢"は表示せず更新するだけなので、useSetAtom
で currentAgeAtomの更新関数
だけ取得しています。
const setCurrentAge = useSetAtom(currentAgeAtom);
setCurrentAge
は birthday
を元に計算を行うのでuseEffect
でbirthday
に変更があれば再計算するようにしています。
// 年齢を計算
useEffect(() => {
if (!birthday) return;
setCurrentAge(dayjs().diff(birthday, "year"));
}, [birthday]);
続いて、フォームの送信とリセット用の関数を定義します。
// フォーム送信
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(true);
};
// フォームリセット
const handleReset = () => {
setFirstName("");
setLastName("");
setBirthday(null);
setCurrentAge(null);
onSubmit(false);
};
あとはフォームの各フィールドに value
や onChange
を設定していきます。
"Birthday" は <input type="date">
なので dayjs
で変換しています。
<form onSubmit={handleSubmit}>
<div className="formControll">
<div>
<label htmlFor="firstName">First Name</label>
<input
type="text"
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<p>※ 英字(小文字)のみ入力</p>
</div>
<div className="formControll">
<div>
<label htmlFor="lastName">Last Name</label>
<input
type="text"
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
<p>※ 英字(小文字)のみ入力</p>
</div>
<div className="formControll">
<div>
<label htmlFor="birthday">Biirthday</label>
<input
type="date"
id="birthday"
// "YYYY-MM-DD"にフォーマットして value にセット
value={birthday ? dayjs(birthday).format("YYYY-MM-DD") : ""}
// Date型に変換
onChange={(e) => setBirthday(dayjs(e.target.value).toDate())}
/>
</div>
<p>※日付を入力</p>
</div>
<div className="buttonGroup">
// 入力が空の場合は disabled
<button type="submit" disabled={!firstName || !lastName || !birthday}>
送信
</button>
// Atomをリセットする
<button type="button" onClick={handleReset}>
リセット
</button>
</div>
</form>
Result コンポーネント
このコンポーネントは UI 表示を担当します。具体的には下記のことを行います。
- 名前は First Name + " " + Last Name で連結して表示
- 名前は 全て大文字 で表示
- 生年月日は 「YYYY 年 M 月 D 日」 で表示
- 年齢を表示
import { useAtomValue } from "jotai";
import dayjs from "dayjs";
import "dayjs/locale/ja";
/** Atomのインポート */
import { firstNameAtom, lastNameAtom, birthdayAtom, currentAgeAtom } from "../atom";
dayjs.locale("ja");
type Props = {
isShow: boolean;
};
export function Result({ isShow }: Props) {
// useAtomValue で値だけ取得
const firstName = useAtomValue(firstNameAtom);
const lastName = useAtomValue(lastNameAtom);
const birthday = useAtomValue(birthdayAtom);
const currentAge = useAtomValue(currentAgeAtom);
return (
<>
{isShow && (
<div className="content">
<span>Result</span>
<div className="contentInner result">
<p>
// 連結して .toUpperCase で大文字に変換して表示
<b>{`${firstName} ${lastName}`.toUpperCase()}</b>さんは
// "YYYY年M月D日"にフォーマットして表示
{<b>{dayjs(birthday).format("YYYY年M月D日")}</b>}生まれです。
</p>
<p>現在の年齢は<b>{currentAge}</b>歳です。</p>
</div>
</div>
)}
</>
);
}
useAtomValue について
useAtomValue
は atom の値のみを取得するフックです。 atom の更新は行えません
//useAtomValue で値だけ取得
const firstName = useAtomValue(firstNameAtom);
const lastName = useAtomValue(lastNameAtom);
const birthday = useAtomValue(birthdayAtom);
const currentAge = useAtomValue(currentAgeAtom);
値を取得したら今度は表示しましょう。ここでは先ほどの 「名前は First Name + " " + Last Name で表示、名前は 全て大文字 で表示する...」 などの仕様に従い表示を整えます。
<p>
// 連結して .toUpperCase で大文字に変換して表示
<b>{`${firstName} ${lastName}`.toUpperCase()}</b>さんは
// "YYYY年M月D日"にフォーマットして表示
{<b>{dayjs(birthday).format("YYYY年M月D日")}</b>}生まれです。
</p>
<p>現在の年齢は<b>{currentAge}</b>歳です。</p>
Original コンポーネント
このコンポーネントは Atom の値を表示するだけです。ただの確認用なので特別なことはしていません。
import { useAtomValue } from "jotai";
import { firstNameAtom, lastNameAtom, birthdayAtom } from "../atom";
export function Original() {
const firstName = useAtomValue(firstNameAtom);
const lastName = useAtomValue(lastNameAtom);
const birthday = useAtomValue(birthdayAtom);
return (
<div className="content original">
<span>Original</span>
<div className="contentInner">
<p>First Name: {firstName}</p>
<p>Last Name: {lastName}</p>
<p>Birthday: {birthday && birthday.toString()}</p>
</div>
</div>
);
}
Page
"Form, Result, Original" の 3 つのコンポーネントを作ったら後は、page.tsx
でこれらのコンポーネントを読み込みます。
"use client";
import { useState } from "react";
import { Form, Original, Result } from "./_components";
/** Component */
export default function Page() {
// Resultコンポーネント表示用 State
const [showResult, setShowResult] = useState(false);
return (
<div className="container">
<p className="message">
<b>コンポーネント側</b> で Atom を編集する場合
</p>
<Form onSubmit={setShowResult} />
<Result isShow={showResult} />
<Original />
</div>
);
}
ここまできたら、npm run dev
を実行して仕様通り動いているか動作確認してみましょう。
npm run dev
基本的な使い方 🔍 は以上となります。ここまで atom
, useAtom
, useAtomValue
, useSetAtom
を使用してきました。
これらは "Jotai" を使用する上で欠かせない機能です。これだけでも様々なユースケースに対応できると思います。
名称 | 説 明 |
---|---|
atom | Atom を定義する。 |
useAtom | 定義された Atom から値と更新関数の両方を提供するフック |
useAtomValue | 定義された Atom から値のみを提供するフック |
useSetAtom | 定義された Atom から更新用関数のみ提供するフック |
atomWithReset で デフォルト値に戻す 🤖
次の章に進む前に Atom
のリセットする処理を修正しておきましょう。
下記のようにリセットを行っていますが、これは実際には値をセットして更新しているだけなのでミスにつながる恐れがあります。
リセット時は Atom
を定義した時のデフォルト値に戻るように修正します。
// フォームリセット(Atomをリセットする)
const handleReset = () => {
setFirstName(""); // setFirstName("Yamada"); 初期値と異なる値を設定する恐れがある
setLastName("");
setBirthday(null);
setCurrentAge(null);
onSubmit(false);
};
まずは atom.ts
を修正します。"Jotai" のユーティリティから atomWithReset
をインポートし atom
と置き換えます。
import { atom } from "jotai";
+ import { atomWithReset } from "jotai/utils";
- export const firstNameAtom = atom<string>("");
- export const lastNameAtom = atom<string>("");
- export const birthdayAtom = atom<Date | null>(null);
- export const currentAgeAtom = atom<number | null>(null);
+ /** Atoms ( atom -> atomWithResetに変更 ) */
+ export const firstNameAtom = atomWithReset<string>("");
+ export const lastNameAtom = atomWithReset<string>("");
+ export const birthdayAtom = atomWithReset<Date | null>(null);
+ export const currentAgeAtom = atomWithReset<number | null>(null);
+ export const darkModeAtom = atomWithReset(false);
そして "Form" コンポーネントを修正します。
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
+ import { useResetAtom, RESET } from "jotai/utils"; // リセット用。RESETはシンボル
〜省略〜
- // フォームリセット(Atomをリセットする)
- const handleReset = () => {
- setFirstName("");
- setLastName("");
- setBirthday(null);
- setCurrentAge(null);
- onSubmit(false);
- };
// フォームリセット用関数を作成
+ const resetAll = useResetAtom(
+ atom(null, (_, set) => {
+ set(firstNameAtom, RESET);
+ set(lastNameAtom, RESET);
+ set(birthdayAtom, RESET);
+ set(currentAgeAtom, RESET);
+ })
+ );
+
+ // resetAllを実行
+ const handleReset = () => {
+ resetAll();
+ onSubmit(false);
+ };
これでリセットが実行されるとデフォルト値に戻るようになりました。
次の章では、UI の動作に変わりはありませんが、"Read Only Atom, Write Only Atom" を活用して見た目とロジックを分離 してみたいと思います。
Read Only Atom, Write Only Atom とは 📝
まずは "Read Only Atom, Write Only Atom" について簡単に説明します。
Read Only Atom
値の読み取りのみ許可し、更新は許可しない。
値の読み込みは useAtomValue
を使用します。
他の Atom
をベースにした計算を行ったりできますが、計算結果を別の Atom
にセットすることはできません。あくまで管理下は自分自身のみです。
例:(firstName + "" + lastName) を連結し大文字にして自分自身の値にセットする
先ほどのフォームを例にあげると下記の部分を "Read Only Atom" に変更できそうです。
名前は First Name + " " + Last Name で表示
名前は 全て大文字 で表示する
生年月日の 表示は「YYYY 年 M 月 D 日」 とする
Write Only Atom
値の更新のみ許可し、読み取りは許可しません。
更新には useSetAtom
を使用します。
他の Atom
をベースにした計算を行い結果を別の Atom
にセットするなどの使い方ができます。
例:atomX(値は 4) x 10 = 400、 結果の 400 を atomY の値にセットするなど。
先ほどのフォームを例にあげると下記の部分を "Write Only Atom" に変更できそうですね。
"birthdayAtom" の値を元に年齢を計算し、その結果を "currentAgeAtom" にセットする
Read Write Atom
そして、"Read と Write" の両方を実装している Atom
は Read Write Atom
となります。これは先ほどまでのソースコードで既に登場しています。
下記のように (get)
を使用せずに初期値を設定すれば自動的に Read Write Atom
になります。
// atom
export const firstNameAtom = atom<string>("");
// component
const [firstName, setFirstName] = useAtom(firstNameAtom);
// 値と更新用関数が設定できることが Read Write Atom の証明になる
まとめると Atom
を定義する時は大きく分けて下記の 3 つの書き方があります。
記述 | 説 明 | 例文 |
---|---|---|
aton() |
"Read Write Atom" 値の読み取りと更新用関数を返す |
atom("") |
atom((get) => {}) |
"Read Only Atom" 値のみ返す。更新不可 |
const textAtom = atom('hello'); const uppercaseAtom = atom((get) => get(textAtom).toUpperCase()); // textAtom: "hello" // uppercaseAtom: "HELLO" |
atom(null, (get, set, update) => {}) |
"Write Only Atom" 更新用関数のみ返す。値の読取不可 |
const textAtom = atom('hello'); const newTextAtom("") const setNewTextAtom( null, (get, set, update) => { const baseAtom = get(textAtom); set(newTextAtom, baseAtom + " " + update) }); // textAtom: "hello" // newTextAtom: "HELLO (update の文字列)" |
エディター( VS Code )でもエラーを検出できるのですぐわかりますね。
/** Write - Read Atom */
const firstNameAtom = atomWithReset<string>("");
const [firstName, setFirstName] = useAtom(firstNameAtom); // OK: 値と更新用関数が許可されている
/** Read Only Atom */
const fullNameAtom = atom((get) => get(firstNameAtom).toUpperCase());
const [fullName, setFullName] = useAtom(fullNameAtom); // NG: 値のみ許可、更新用関数は許可されてない
const fullName = useAtomValue(fullNameAtom); // OK: 値のみ読み込んでいる
/** Write Only Atom */
const birthdayAtom = atomWithReset<Date | null>(null);
const currentAgeAtom = atomWithReset<number | null>(null);
const setCurrentAgeAtom = atom(null, (get, set) => {
const birthday = get(birthdayAtom);
set(currentAgeAtom, birthday ? dayjs().diff(birthday, "year") : null);
});
const [currentAge, setCurrentAge] = useAtom(setCurrentAgeAtom); // NG: 値は許可されていない
const setCurrentAge = useSetAtom(setCurrentAgeAtom); // OK: 更新用関数のみ読み込んでいる
const [, setCurrentAge] = useAtom(setCurrentAgeAtom); // OK: 値を省略して更新用関数だけ読み込むことも可
では、実際に先ほどのソースコードを修正していきましょう。
Atom
import { atom } from "jotai";
import { atomWithReset } from "jotai/utils";
+ // atom側でフォーマットなども行うためdayjsをインポート
+ import dayjs from "dayjs";
+ import "dayjs/locale/ja";
+ dayjs.locale("ja");
/** Atoms ( atom -> atomWithResetに変更 ) */
〜省略〜
+ /** Read Only Atom: 第2引数を省略すると Read Only となる */
+ // firstNameAtom + " " + lastNameAtom を連結し大文字にした値を返す
+ export const fullNameAtom = atom((get) =>
+ `${get(firstNameAtom)} ${get(lastNameAtom)}`.toUpperCase()
+ );
+ // birthdayAtom を "YYYY年M月D日" でフォーマットした値を返す
+ export const birthdayJpAtom = atom((get) => {
+ const birthday = get(birthdayAtom);
+ return birthday ? dayjs(birthday).format("YYYY年M月D日") : "";
+ });
+ /** Write Only Atom: 第1引数を "null" にすると Write Only となる */
+ // birthdayAtom から現在の年齢を計算して "currentAgeAtom" に結果をセットする
+ export const setCurrentAgeAtom = atom(null, (get, set) => {
+ const birthday = get(birthdayAtom);
+ set(currentAgeAtom, birthday ? dayjs().diff(birthday, "year") : + null);
+ });
+ /** Write Read Atom: 第1引数, 第2引数の両方と設定することもできる */
+ export const birthdayFormatAtom = atom(
+ // birthdayAtom を "YYYY-MM-DD" でフォーマットした値を返す
+ (get) => {
+ const _birthday = get(birthdayAtom);
+ return _birthday ? dayjs(_birthday).format("YYYY-MM-DD") : "";
+ },
+ // 引数updateをDate型にして birthdayAtom にセットする
+ (_, set, update: string) => {
+ const _date = dayjs(update).toDate();
+ set(birthdayAtom, !isNaN(_date.getTime()) ? _date : null);
+ }
+ );
先ほどと大きく異なるのは コンポーネント側(表示側) に実装されていた "名前を全て大文字にする" や "日付を YYYY 年 M 月 D 日にフォーマットする" などの処理が "Atom 側" に記述されている点です。続いてコンポーネント側を修正していきます。
Form コンポーネント
- import { useAtom, useSetAtom } from "jotai";
- import dayjs from "dayjs";
- import "dayjs/locale/ja";
+ import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
+ import { useResetAtom, RESET } from "jotai/utils";
import {
firstNameAtom,
lastNameAtom,
birthdayAtom,
+ birthdayFormatAtom,
currentAgeAtom,
+ setCurrentAgeAtom,
} from "../atom";
type Props = {
onSubmit: React.Dispatch<React.SetStateAction<boolean>>;
};
export function Form({ onSubmit }: Props) {
const [firstName, setFirstName] = useAtom(firstNameAtom);
const [lastName, setLastName] = useAtom(lastNameAtom);
- const [birthday, setBirthday] = useAtom(birthdayAtom);
+ const birthday = useAtomValue(birthdayAtom);
+ const [valueBirthday, onChangeBirthday] = useAtom(birthdayFormatAtom);
- const setCurrentAge = useSetAtom(currentAgeAtom);
+ const setCurrentAge = useSetAtom(setCurrentAgeAtom);
/** フォーム送信 */
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(true);
};
/** フォームリセット */
const resetAll = useResetAtom(
atom(null, (_, set) => {
set(firstNameAtom, RESET);
set(lastNameAtom, RESET);
set(birthdayAtom, RESET);
set(currentAgeAtom, RESET);
})
);
const handleReset = () => {
resetAll();
onSubmit(false);
setCurrentAge();
};
return (
<form onSubmit={handleSubmit}>
〜変更がないので省略〜
<div className="formControll">
<div>
<label htmlFor="birthday">Biirthday</label>
<input
type="date"
id="birthday"
- value={birthday ? dayjs(birthday).format("YYYY-MM-DD") : ""}
+ value={valueBirthday}
- onChange={(e) => setBirthday(dayjs(e.target.value).toDate())}
+ onChange={(e) => onChangeBirthday(e.target.value)}
/>
</div>
<p>※日付を入力</p>
</div>
<div className="buttonGroup">
<button type="submit" disabled={!firstName || !lastName || !birthday}>
送信
</button>
<button type="button" onClick={handleReset}>
リセット
</button>
</div>
</form>
);
}
"FirstName" や "LastName" の箇所は変わりありませんが、"birthday" 関連の箇所が変更されています。dayjs で行っていたフォーマットや Date への変換などの処理が "コンポーネント側からは剥がされています
また、先ほどまでは useAtom
だけ使用していた箇所が useAtomValue
や useSetAtom
に変更されより厳格になっています。
Result コンポーネント
import { useAtomValue } from "jotai";
- import dayjs from "dayjs";
- import "dayjs/locale/ja";
-import {
- firstNameAtom,
- lastNameAtom,
- birthdayAtom,
- currentAgeAtom,
-} from "../atom";
- dayjs.locale("ja");
+ import { fullNameAtom, birthdayJpAtom, currentAgeAtom } from "../atom";
type Props = {
isShow: boolean;
};
export function Result({ isShow }: Props) {
- const firstName = useAtomValue(firstNameAtom);
- const lastName = useAtomValue(lastNameAtom);
+ const fullName = useAtomValue(fullNameAtom);
- const birthday = useAtomValue(birthdayAtom);
+ const birthdayJp = useAtomValue(birthdayJpAtom);
const currentAge = useAtomValue(currentAgeAtom);
return (
<>
{isShow && (
<div className="content">
<span>Result</span>
<div className="contentInner result">
<p>
- <b>{`${firstName} ${lastName}`.toUpperCase()}</b>さんは
+ <b>{fullName}</b>さんは
- {<b>{dayjs(birthday).format("YYYY年M月D日")}</b>}生まれです。
+ {<b>{birthdayJp}</b>}生まれです。
</p>
<p>
現在の年齢は<b>{currentAge}</b>歳です。
</p>
</div>
</div>
)}
</>
);
}
こちらも同じように "名前を連結して大文字" や "YYYY 年 M 月 D 日で表示" などの処理が剥がされています。
Orginal コンポーネント
"Orginal" コンポーネントは変更ありませんので割愛します。
これで "見た目とロジックを分離する作業" は完了です。
このように "ロジックも Atom 側で管理してコンポーネント側では呼び出すだけ" の形にすると、ロジックに変更があった場合の修正も容易になりますね。
また、複数人で開発している時にも処理が共通化されていることで、小さなミスが起きにくくなるかと思います。
Atom Creator で動的に Atom を作る 🛠️
Atom Creator(アトム・クリエイター)とは動的に Atom
作るための関数です。似たような構造を持つ Atom
を効率的に作ることができます。
また、通常の Atom ( Read Write Atom )
であれば返すのは [値, 更新用関数]
となりますが、これを [ ( 値 & 更新用関数 ), 値A, 値B ]
を返すように "Atom を独自に定義" できます。
今回は下記の 2 つを例として Atom Creator の使い方を見ていこうと思います。
- 似たような構造を持つ Atom を効率的に作る
- [ ( 値 & 更新用関数 ), 値 A, 値 B ] を持つ Atom を作る
似たような構造を持つ Atom を効率的に作る
ここでは、単純に "入力された値 + 1" するだけの Atom
を作ってみます。
似たような構造 X と Y の Atom
import { atom } from "jotai";
export const createCounterAtom = (initialValue: number) => {
// 関数内でベースとなる Atom
const baseAtom = atom(initialValue);
// Read Write Atom
const incAtom = atom(
(get) => get(baseAtom),
(_, set, newValue: number) => set(baseAtom, newValue + 1)
);
return incAtom;
};
次にcreateCounterAtom
を読み込み初期値を設定しておきます。
import { createCounterAtom } from "./atomCreator";
// createCounterAtom から2つの Atom を作る
export const incAtomX = createCounterAtom(0);
export const incAtomY = createCounterAtom(0);
最後は UI 表示する Page
コンポーネントででこれらを読み込み利用します。
createCounterAtom
内の incAtom
は "Read Write Atom" なので useAtom
を使用します。
"use client";
import { useAtom, useAtomValue } from "jotai";
import {
incAtomX,
incAtomY,
} from "./atom";
/** Component */
export default function Page() {
const [valueIncX, setIncX] = useAtom(incAtomX);
const [valueIncY, setIncY] = useAtom(incAtomY);
return (
<div className="container">
<div className="formControll">
<div>
<label htmlFor="x">X</label>
<input type="number" id="x" onChange={(e) => setIncX(Number(e.target.value))} />
</div>
<p>※半角数字で入力</p>
</div>
<div className="formControll">
<div>
<label htmlFor="y">Y</label>
<input type="number" id="y" onChange={(e) => setIncY(Number(e.target.value))} />
</div>
<p>※半角数字で入力</p>
</div>
<div className="content">
<span>Result</span>
<div className="contentInner result">
<p>X: {valueIncX}</p>
<p>Y: {valueIncY}</p>
</div>
</div>
</div>
);
}
これで "X" も "Y" もどちらも処理は同じですが、別々の Atom
なので独立して動作します。
通常であれば Atom
はそれぞれ独立しているので処理が同じ場合でも、下記のように書く必要がありました。
const xAtom = atom(0);
const incXAtom = atom(null, (get, set) => {
set(xAtom, c => c + 1);
};
const yAtom = atom(0);
const incYAtom = atom(null, (get, set) => {
set(yAtom, c => c + 1);
};
このように構造や処理が似ている複数の Atom
が必要な場合に Atom Creator を使うことでスマートに対応できます。
[ ( 値 & 更新用関数 ), 値 A, 値 B ] を持つ Atom を作る
ここでは下記の独自構造を持った Atom
を定義します。
atom 名 | Write, Read |
---|---|
dateAtom | Write Read |
formatJpAtom | Read Only |
formatEnAtom | Read Only |
import { atom } from "jotai";
import dayjs from "dayjs";
import "dayjs/locale/ja";
import "dayjs/locale/en";
/** Atom Creator */
export const createDateAtom = (initialValue: Date | null) => {
// 関数内でベースになる atom
const baseAtom = atom(initialValue);
// Write Read Atom
const dateAtom = atom(
(get) => {
return get(baseAtom) ? dayjs(get(baseAtom)).format("YYYY-MM-DD") : "";
},
(_, set, update: Date | string) => {
const _date = dayjs(update).toDate();
set(baseAtom, !isNaN(_date.getTime()) ? _date : null);
}
);
// Read Only Atom
const formatJpAtom = atom((get) => {
return get(baseAtom)
? dayjs(get(baseAtom)).locale("ja").format("YYYY年M月D日")
: "";
});
// Read Only Atom
const formatEnAtom = atom((get) => {
return get(baseAtom)
? dayjs(get(baseAtom)).locale("en").format("MMMM D, YYYY")
: "";
});
return [dateAtom, formatJpAtom, formatEnAtom] as const;
};
関数内に "Write Read Atom が 1 つ, Read Only Atom が 2 つ" 定義されている点に注目です。
import { createDateAtom } from "./atomCreator";
export const [dateAtom, formatJpAtom, formatEnAtom] = createDateAtom(null);
先ほどと同様に最後は UI 表示する Page
コンポーネントでこれらを読み込みます。
"Read Only Atom" がありますので useAtomValue
も使用します。
"use client";
import { useAtom, useAtomValue } from "jotai";
import { dateAtom, formatJpAtom, formatEnAtom } from "./atom";
/** Component */
export default function Page() {
const [date, setDate] = useAtom(dateAtom); // Read Write
const dateJp = useAtomValue(formatJpAtom); // Read Only
const dateEn = useAtomValue(formatEnAtom); // Read Only
return (
<div className="container">
<form>
<div className="formControll">
<div>
<label htmlFor="date">Date</label>
<input type="date" id="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<p>※日付を入力</p>
</div>
</form>
<div className="content">
<span>Result</span>
<div className="contentInner result">
<p>Date: {date}</p>
<p>Date JP: {dateJp}</p>
<p>Date EN: {dateEn}</p>
</div>
</div>
</div>
);
}
これで "[ ( 値 & 更新用関数 ), 値 A, 値 B ] " とゆう独自の構造を持つ Atom
を使用することができました。
上記の 2 つの例のように Atom Craetor を上手く使えば、不要な Atom
を作らずに済むので無駄のない設計を行えます。できるだけ積極的に使っていきたいですね。
URL からパラメーターを取得 🌹
URL のパラメーターを取得してその値によって UI を変える。などはよくあることだと思います。そのようなことも "jotai-location" を使えば簡単に行うことができます。
今回はアクセスされる URL を example.com/params?gender={male or female} と仮定して "gender" パラメーターの値( male or female )によってリスト絞り込む サンプルを作ります。
※表示するデータはダミーです
先に下記のようなダミーデータを用意しておきます。
続いて Atom
の定義とダミーデータのインポートを行います。
Atom
import { atom } from "jotai";
// atomWithLocationをインポート
import { atomWithLocation } from "jotai-location";
// ダミーデータをインポート
import mockData from "./mock.json";
export type Person = {
name: string;
age: number;
gender: string;
};
// Atomを定義
export const locationAtom = atomWithLocation();
export const paramsAtom = atom<{ [key: string]: string | null }>({
gender: null,
});
export const peopleAtom = atom<Person[]>(mockData);
"gender" パラーメータの値は male
か female
のいずれかのみなので文字列を定数にしておきます(タイポ対策)。
export const MALE = "male";
export const FEMALE = "female";
Page
定義した Atom
を利用して "Page" コンポーネントを先に作っておきます。
"use client";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { locationAtom, paramsAtom } from "./atom";
import { MALE, FEMALE } from "./contants";
/** Component */
export default function Page() {
const [location, setLocation] = useAtom(locationAtom);
const [params, setParams] = useAtom(paramsAtom);
/** paramsを取得 */
useEffect(() => {
if (!location.searchParams) return;
// パラメーター gender の値を取得し paramsAtom を更新
setParams({
gender: location.searchParams.get("gender") ?? null,
});
}, [location.searchParams]);
// male or female を切り替えるハンドラ(動作確認用)
const hanldeToggleParams = () => {
setLocation((prev) => ({
...prev,
pathname: "/params",
searchParams: new URLSearchParams([
["gender", params.gender === MALE ? FEMALE : MALE],
]),
}));
};
return (
<div className="container">
<div className="container__head">
<p className="message">
URLから <b>params ( gender )</b> を取得。Toggleボタンで
<b>"male", "female"</b> 切り替え
</p>
<button onClick={hanldeToggleParams}>Toggle</button>
</div>
</div>
);
}
Result コンポーネント
このコンポーネントは useAtomValue
で paramsAtom
の値を取得しデータを絞り込み、表示します。値が male
, female
以外の場合はデータ全件を表示します。
ちなみに &show=1
などこちらで何も定義していないパラメーターが指定されたとしても何も起こりません。
import { useState, useEffect } from "react";
import { useAtomValue } from "jotai";
import { paramsAtom, peopleAtom, Person } from "../atom";
import { MALE, FEMALE } from "../constants";
export function Result() {
const [showResult, setShowResult] = useState(false);
const params = useAtomValue(paramsAtom);
const people = useAtomValue(peopleAtom); // ダミーデータ
// 絞り込んだダミーデータを管理する State
const [filteredPeople, setFilteredPeople] = useState<Person[]>([]);
// paramsAtom に変更があるたびに絞り込みを行う。
useEffect(() => {
const _gender = params.gender;
const _filteredByGender =
_gender && (_gender === MALE || _gender === FEMALE)
? people.filter((person) => person.gender === _gender)
: people;
setFilteredPeople(_filteredByGender);
setShowResult(true);
}, [params]);
return (
<>
{!showResult ? (
<div className="loader"></div>
) : (
<div className="content">
<span>Result</span>
<div className="contentInner result">
<ul>
{filteredPeople.map((person, index) => (
<li key={index}>
<b>{person.name}</b>
<span>
Age: <i>{person.age}</i>, Gender: <i>{person.gender}</i>
</span>
</li>
))}
</ul>
</div>
</div>
)}
</>
);
}
Params コンポーネント
このコンポーネントは "gender" パラメーターの値を表示します(確認用)。
import { useAtomValue } from "jotai";
import { paramsAtom } from "../atom";
import { MALE, FEMALE } from "../constants";
export function Params() {
const params = useAtomValue(paramsAtom);
return (
<div className="content original">
<span>Params</span>
<div className="contentInner">
<p>
Gender:
{params.gender !== MALE && params.gender !== FEMALE ? (
// gender パラメーターがない場合
!params.gender ? (
<span className="empty">No Params</span>
) : (
// male, female 以外の場合
<span className="error">
Error: Prams is not "male" or "female"
</span>
)
) : (
// gender パラメーターの値を表示
<span className="success">{params.gender}</span>
)}
</p>
</div>
</div>
);
}
"Result", "Params" コンポーネントを作ったらこれらを先ほどの page.tsx
に組み込みます。
"use client";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { locationAtom, paramsAtom } from "./atom";
import { MALE, FEMALE } from "./contants";
+ import { Result, Params } from "./_components";
/** Component */
export default function Page() {
const [location, setLocation] = useAtom(locationAtom);
const [params, setParams] = useAtom(paramsAtom);
/** paramsを取得 */
useEffect(() => {
if (!location.searchParams) return;
// パラメーター gender の値を取得し paramsAtom を更新
setParams({
gender: location.searchParams.get("gender") ?? null,
});
}, [location.searchParams]);
// male or female を切り替えるハンドラ(動作確認用)
const hanldeToggleParams = () => {
setLocation((prev) => ({
...prev,
pathname: "/params",
searchParams: new URLSearchParams([
["gender", params.gender === MALE ? FEMALE : MALE],
]),
}));
};
return (
<div className="container">
<div className="container__head">
<p className="message">
URLから <b>params ( gender )</b> を取得。Toggleボタンで
<b>"male", "female"</b> 切り替え
</p>
<button onClick={hanldeToggleParams}>Toggle</button>
</div>
+ <Result />
+ <Params />
</div>
);
}
これで完了です。npm run dev
を実行して実際の動作を確認してみましょう。
下記のようになっていると思います。
npm run dev
male の場合
female の場合
指定なしの場合
male, female 以外を指定した場合
パラメーターに show=1 を追加した場合
これで 「URL パラメーターの値に合わせて UI を変更する」 機能ができました。
ここまで簡単にできるのは助かりますね。特に未定義のパラメーターが指定されても何もエラーを起こさないのは助かります。
終わりに 🙇♂️
今回は "Jotai" のさまざまな機能の中から個人的に使っている、使うケースがありそうだと感じた機能だけを紹介しました。
ここで紹介した以外にも "Jotai" にはさまざまな機能がありますので Jotai の公式サイト を目を通しておくことをお勧めします。
これで "状態管理ライブラリ Jotai の使い方" の記事は以上になります。
最後までお読みいただき、ありがとうございました。この記事が皆様のお役に立てば幸いです。
Discussion