👻

状態管理ライブラリ Jotaiの使い方

2024/08/27に公開

今回は React アプリケーションの状態管理ライブラリの中でも人気の高い "Jotai" の使い方を記事にしたいと思います。豊富な機能の中から本記事では以下の機能について記載しています。

記事内に掲載しているソースコードは Github でも確認できます。

https://github.com/twosun-8-git/jotai

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 日」inputvalue「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" で状態を管理する最小単位です。それぞれが独立しているので更新や使用が簡単にできます。

app/basic/atom.ts
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 コンポーネント

このコンポーネントはフォームの入力、送信、リセットを担当します。

app/basic/_components/Form.tsx
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" コンポーネントでは"年齢"は表示せず更新するだけなので、useSetAtomcurrentAgeAtomの更新関数だけ取得しています。

const setCurrentAge = useSetAtom(currentAgeAtom);

setCurrentAgebirthday を元に計算を行うのでuseEffectbirthdayに変更があれば再計算するようにしています。

// 年齢を計算
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);
};

あとはフォームの各フィールドに valueonChange を設定していきます。
"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 日」 で表示
  • 年齢を表示
app/basic/_components/Result.tsx
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 の値を表示するだけです。ただの確認用なので特別なことはしていません。

app/basic/_components/Original.tsx
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 でこれらのコンポーネントを読み込みます。

app/basic/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 を定義した時のデフォルト値に戻るように修正します。

app/basic/_components/Form.tsx
// フォームリセット(Atomをリセットする)
const handleReset = () => {
  setFirstName(""); // setFirstName("Yamada"); 初期値と異なる値を設定する恐れがある
  setLastName("");
  setBirthday(null);
  setCurrentAge(null);
  onSubmit(false);
};

まずは atom.ts を修正します。"Jotai" のユーティリティから atomWithReset をインポートし atom と置き換えます。

app/basic/atom.ts
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" コンポーネントを修正します。

app/basic/_components/Form.tsx
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" の両方を実装している AtomRead 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

app/basic/atom.ts
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 コンポーネント

app/basic/_components/Form.tsx
- 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 だけ使用していた箇所が useAtomValueuseSetAtom に変更されより厳格になっています。


Result コンポーネント

app/basic/_components/Form.tsx
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 の使い方を見ていこうと思います。

  1. 似たような構造を持つ Atom を効率的に作る
  2. [ ( 値 & 更新用関数 ), 値 A, 値 B ] を持つ Atom を作る

似たような構造を持つ Atom を効率的に作る

ここでは、単純に "入力された値 + 1" するだけの Atom を作ってみます。

似たような構造XとYのAtom
似たような構造 X と Y の Atom

app/creator/atomCreator.ts
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を読み込み初期値を設定しておきます。

app/creator/atom.ts
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 を使用します。

app/creator/page.tsx
"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
app/creator/atomCreator.ts
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 つ" 定義されている点に注目です。

app/creator/atom.ts
import { createDateAtom } from "./atomCreator";

export const [dateAtom, formatJpAtom, formatEnAtom] = createDateAtom(null);

先ほどと同様に最後は UI 表示する Page コンポーネントでこれらを読み込みます。
"Read Only Atom" がありますので useAtomValue も使用します。

app/creator/page.tsx
"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 )によってリスト絞り込む サンプルを作ります。
※表示するデータはダミーです

先に下記のようなダミーデータを用意しておきます。
https://github.com/twosun-8-git/jotai/blob/main/src/app/params/mock.json

続いて Atom の定義とダミーデータのインポートを行います。

Atom

app/params/atom.ts
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" パラーメータの値は malefemale のいずれかのみなので文字列を定数にしておきます(タイポ対策)。

app/params/constants.ts
export const MALE = "male";
export const FEMALE = "female";

Page

定義した Atom を利用して "Page" コンポーネントを先に作っておきます。

app/params/page.tsx
"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>&quot;male&quot;, &quot;female&quot;</b> 切り替え
        </p>
        <button onClick={hanldeToggleParams}>Toggle</button>
      </div>
    </div>
  );
}

Result コンポーネント

このコンポーネントは useAtomValueparamsAtom の値を取得しデータを絞り込み、表示します。値が male, female 以外の場合はデータ全件を表示します。

ちなみに &show=1 などこちらで何も定義していないパラメーターが指定されたとしても何も起こりません。

app/params/_components/Result.tsx
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:&nbsp;
          {params.gender !== MALE && params.gender !== FEMALE ? (
            // gender パラメーターがない場合
            !params.gender ? (
              <span className="empty">No Params</span>
            ) : (
              // male, female 以外の場合
              <span className="error">
                Error: Prams is not &quot;male&quot; or &quot;female&quot;
              </span>
            )
          ) : (
            // gender パラメーターの値を表示
            <span className="success">{params.gender}</span>
          )}
        </p>
      </div>
    </div>
  );
}

"Result", "Params" コンポーネントを作ったらこれらを先ほどの page.tsx に組み込みます。

app/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>&quot;male&quot;, &quot;female&quot;</b> 切り替え
        </p>
        <button onClick={hanldeToggleParams}>Toggle</button>
      </div>
+     <Result />
+     <Params />
    </div>
  );
}

これで完了です。npm run dev を実行して実際の動作を確認してみましょう。
下記のようになっていると思います。

npm run dev

maleの場合
male の場合

femaleの場合
female の場合

指定なしの場合
指定なしの場合

male, female以外を指定した場合
male, female 以外を指定した場合

パラメーターにshow=1を追加した場合
パラメーターに show=1 を追加した場合

これで 「URL パラメーターの値に合わせて UI を変更する」 機能ができました。
ここまで簡単にできるのは助かりますね。特に未定義のパラメーターが指定されても何もエラーを起こさないのは助かります。

終わりに 🙇‍♂️

今回は "Jotai" のさまざまな機能の中から個人的に使っている、使うケースがありそうだと感じた機能だけを紹介しました。
ここで紹介した以外にも "Jotai" にはさまざまな機能がありますので Jotai の公式サイト を目を通しておくことをお勧めします。

これで "状態管理ライブラリ Jotai の使い方" の記事は以上になります。
最後までお読みいただき、ありがとうございました。この記事が皆様のお役に立てば幸いです。

Discussion