🦔

React状態管理ライブラリJotaiで非同期処理のTodoリストを作ってみる

2024/09/17に公開

はじめに

Reactの状態管理ライブラリJotaiに触る機会があったのでインプットのためにTodoリストを作りました。
Todoリストの初期表示(擬似APIから読み込み)のほか登録/削除機能を持ち、非同期処理時は処理中であることを表示したり、ボタンを非活性にするといった挙動をJotaiの機能を使用しながら実装していきます。

本記事では、
①Todoリスト初期表示
②Todoリスト追加機能
③Todoリスト削除機能
の3段階に分けて実装していきます。

ターゲット

  • Jotai初学者

バージョン

  • node.js : v20.10.0
  • React : 18.3.1
  • TypeScript : 5.6.2
  • Jotai : 2.9.3
  • Vite : 5.4.5
  • Matarial UI : 6.1.0(見た目は良きに計らってください)

各種インストール

Reactプロジェクト

React(Vite/TypeScript)

% npm create vite@latest my-jotai-tutorial -- --template react-ts

ライブラリ

Jotai

% cd my-jotai-tutorial
% npm install jotai

Material UI

% npm install @mui/material @emotion/react @emotion/styled 

ローカルサーバー起動

% npm run dev

起動されたポートにアクセスするとReactで画面が表示されます。

成果物について

src配下は最終的に以下のファイル構成になります。
コンポーネントは親コンポーネントとしてTodo.tsxが存在し、その下にForm.tsxとList.tsxの2つがぶら下がる形となっています。
main.tsx以外は消していただいて構いません。

src
├── atoms
│   └── todoListAtom.tsx
├── components
│   ├── Form.tsx
│   ├── List.tsx
│   └── Todo.tsx
├── models
│   └── todoListModel.tsx
├── fakeApi.tsx
└── main.tsx

①Todoリスト初期表示

Todoリストの初期データを表示できるようにします。
初期データ取得の非同期処理中はそのことが分かるようloading...の文字を表示するようにします。

main.tsx修正

先にReactのエントリーポイントを編集しておきます。
Todo.tsxはまだありませんがいったん呼び出してます。

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
- import App from './App.tsx'
- import './index.css'
+ import Todo from './components/Todo.tsx';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
-     <App />
+     <Todo />
  </StrictMode>,
)

todoListModel.tsx作成(modelsフォルダ作成)

今回扱うTodoリストの型を定義しておきます。
idとリストの内容のみのシンプルな形とします。

src/models/todoListModel.tsx
export interface Todo {
    id: number;
    content: string;
}

fakeApi.tsx作成

非同期で初期値を返す擬似APIを作成します。
TodoオブジェクトをPromiseで返すようにし、本記事ではアプリの非同期処理時の振る舞いを確認しやすくするため、非同期処理の遅延をすべて2秒としていきます。

src/fakeApi.tsx
import { Todo } from "./models/todoListModel";

const todoList: Todo[] = [
    { id: 1, content: "入金する" },
    { id: 2, content: "返事する" },
    { id: 3, content: "プレゼント買う" },
];

export const fakeAPI = {
    getTodos: (): Promise<Todo[]> => {
        return new Promise((resolve) => {
            setTimeout(() => {
                resolve(todoList);
            }, 2000);
        });
    },
};

todoListAtom.tsx作成(atomsフォルダ作成)

擬似APIからTodoリストの初期値を取得する読み込みAtomを定義します。
初期データ取得の非同期処理の処理状態の管理ではloadableというJotaiのユーティリティ関数を使用しており、loadableは読み込みatomの非同期処理において'loading' | 'hasData' | 'hasError'の3つの状態を持つことができます。
ここで擬似APIから取得したTodoリストをloadableでラップしたものを出力するようにします。

src/atoms/todoListAtom.tsx
import { atom } from "jotai";
import { loadable } from "jotai/utils";
import { Todo } from "../models/todoListModel";
import { fakeAPI } from "../fakeApi";

// リスト取得atom
const asyncGetTodoListAtom = atom<Promise<Todo[]>>(
    async () => {
        return await fakeAPI.getTodos();
    }
);

// リスト読み込みの処理状態管理atom
export const getTodoListAtom = loadable(asyncGetTodoListAtom);

Todo.tsx作成(componentsフォルダ作成)

今回のTodoリストの親コンポーネントになります。
基本的に親コンポーネントにあたるTodo.tsxで状態(atom)の呼び出しを行い、子コンポーネントにpropsで渡す形で実装していきます。

src/components/Todo.tsx
import { Container } from "@mui/material";
import { useAtom } from "jotai";
import { getTodoListAtom} from "../atoms/todoListAtom";
import List from "./List";

const Todo = () => {
    const [todos] = useAtom(getTodoListAtom);

    return (
        <Container maxWidth="sm">
        <h2>Todoリスト</h2>
        < List todos={todos} />
        </Container>
    )
};

export default Todo;

List.tsx作成

先ほどloadableでラップしたtodosの非同期処理の処理状態を確認し、hasData以外の場合はloading...を表示するようにします。

src/components/List.tsx
import { Loadable } from 'jotai/vanilla/utils/loadable';
import { Todo } from '../models/todoListModel';

interface Props {
    todos: Loadable<Promise<Todo[]>>;
}

const List: React.FC<Props> = (props) => {
    const renderList = (todos: Loadable<Promise<Todo[]>>) => {
        if (todos.state !== "hasData") {
            return <span>loading...</span>;
        }

        return todos.data.map(todo => {
            return (
                <div key={todo.id}>
                    <span>{todo.content}</span>
                </div>
            );
        })
    }

    return renderList(props.todos);
}

export default List;

ここまででTodoリストの初期表示ができるようになっていると思います。

②Todoリスト追加

Todoリストを追加する機能を実装します。
Todoリスト追加の非同期処理中は入力フォーム、追加ボタン共に非活性となるようにします。

todoListAtom.tsx修正

今回はTodoリストのデータ保持にローカルストレージを使っていきます。
先ほどのTodoリストの取得もローカルストレージに保存済みのデータがある場合はそちらから取得するようにします。

また初期状態でfalseが定義されたatomを追加して、Todoリスト追加atomの中でtrue/falseを切り替えることで非同期の処理状態を管理していきます。(loadableは書き込みatomでは使用不可)
atomの中で別のatomを扱うことができるというのもJotaiの特徴の1つとなっています。

src/atoms/todoListAtom.tsx
import { atom } from "jotai";
- import { loadable } from "jotai/utils";
+ import { atomWithStorage, loadable } from "jotai/utils";
import { Todo } from "../models/todoListModel";
import { fakeAPI } from "../fakeApi";

+ const todoListAtomStorage = atomWithStorage<Todo[]>('todoList', []);

const asyncGetTodoListAtom = atom<Promise<Todo[]>>(
-     async () => {
+     async (get) => {
+         const persistedTodos = get(todoListAtomStorage);
+         if (persistedTodos.length > 0) {
+             return persistedTodos;
+         }
        return await fakeAPI.getTodos();
    }
);

export const getTodoListAtom = loadable(asyncGetTodoListAtom);

+ export const submittingAtom = atom(false);

+ export const setTodoListAtom = atom(
+     null,
+     async (get, set, newContent: string) => {
+         set(submittingAtom, true);
+
+         const currentTodos = await get(asyncGetTodoListAtom);
+
+         const newTodo: Todo = {
+             id: Math.floor(Math.random() * 1e5),
+             content: newContent
+         }
+
+         await new Promise<void>(resolve => {
+             setTimeout(() => {
+                 set(todoListAtomStorage, [...currentTodos, newTodo]);
+                 set(submittingAtom, false);
+                 resolve();
+             }, 2000);
+         });
+     }
+ );

Todo.tsx修正

Todoリスト取得時はloadableにより、Todoリスト追加時は非同期処理状態管理atom(submitting)で処理状態を管理できるようになっているのでこれらを用いてdisabled変数を定義し、非同期処理中はtrueを返すようにしています。

src/components/Todo.ts
import { Container } from "@mui/material";
import { useAtom } from "jotai";
- import { getTodoListAtom} from "../atoms/todoListAtom";
+ import { useState } from "react";
+ import { getTodoListAtom, setTodoListAtom, submittingAtom} from "../atoms/todoListAtom";
+ import Form from "./Form";
import List from "./List";

const Todo = () => {
  const [todos] = useAtom(getTodoListAtom);
+   const [, setTodos] = useAtom(setTodoListAtom);
+   const [submitting] = useAtom(submittingAtom);
+   const [newTodoText, setNewTodoText] = useState("");

+   const handleClickAdd = async(newTodoText: string) => {
+     await setTodos(newTodoText);
+ 
+     setNewTodoText("");
+   };

+   const disabled = submitting || todos.state !== "hasData";

  return (
    <Container maxWidth="sm">
      <h2>Todoリスト</h2>
+       < Form newTodoText={newTodoText} onChangeText={(e) => setNewTodoText(e.target.value)} onSubmitAdd={handleClickAdd} disabled={disabled} />
      < List todos={todos} />
    </Container>
  )
};

export default Todo;

Form.tsx作成

フォーム入力後、Enterクリックor追加ボタン押下でTodoリストを追加できるようにしています。
またTextFieldとButtonにenabledを渡すことで非同期処理中は入力フォームと追加ボタンが非活性になるようにしています。

src/components/Form.tsx
import { Button, TextField } from '@mui/material';

export interface Props {
    newTodoText?: string;
    disabled?: boolean;
    onChangeText?: (e: React.ChangeEvent<HTMLInputElement>) => void;
    onSubmitAdd?: (newTodoText: string) => void;
}

const Form: React.FC<Props> = (props) => {
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        if (props.onSubmitAdd && props.newTodoText) {
            props.onSubmitAdd(props.newTodoText);
        }
    }

    const handleClickButton = (e: React.MouseEvent<HTMLButtonElement>) => {
        e.preventDefault();
        if (props.onSubmitAdd && props.newTodoText) {
            props.onSubmitAdd(props.newTodoText);
        }
    }

    return (
        <>
            <form onSubmit={handleSubmit}>
                <TextField
                    id="outlined-basic"
                    label="入力してください"
                    variant="outlined"
                    size="small"
                    value={props.newTodoText}
                    onChange={props.onChangeText}
                    disabled={props.disabled}
                />

                <Button
                    variant="outlined"
                    sx={{ margin: 0.5 }}
                    onClick={handleClickButton}
                    disabled={props.disabled}
                >追加</Button>
            </form>
        </>
    )
}

export default Form;

これでフォームを使用したTodoリストの追加ができるようになったと思います。

③Todoリスト削除

Todoリスト追加と同様の流れになります。
削除用atomを定義して、親コンポーネントでatomを呼び出し、propsとして子コンポーネントに渡して、子コンポーネントで実行できるようにします。

todoListAtom.tsx修正

削除用atomを定義

src/atoms/todoListAtom.tsx
import { atom } from "jotai";
import { atomWithStorage, loadable } from "jotai/utils";
import { Todo } from "../models/todoListModel";
import { fakeAPI } from "../fakeApi";

const todoListAtomStorage = atomWithStorage<Todo[]>('todoList', []);

const asyncGetTodoListAtom = atom<Promise<Todo[]>>(
    async (get) => {
        const persistedTodos = get(todoListAtomStorage);
        if (persistedTodos.length > 0) {
            return persistedTodos;
        }

        return await fakeAPI.getTodos();
    }
);

export const getTodoListAtom = loadable(asyncGetTodoListAtom);

export const submittingAtom = atom(false);

export const setTodoListAtom = atom(
    null,
    async (get, set, newContent: string) => {
        set(submittingAtom, true);

        const currentTodos = await get(asyncGetTodoListAtom);

        const newTodo: Todo = {
            id: Math.floor(Math.random() * 1e5),
            content: newContent
        }

        await new Promise<void>(resolve => {
            setTimeout(() => {
                set(todoListAtomStorage, [...currentTodos, newTodo]);
                set(submittingAtom, false);
                resolve();
            }, 2000);
        });
    }
);

+ export const deleteTodoListAtom = atom(
+     null,
+     async (get, set, id: number) => {
+         set(submittingAtom, true);
+ 
+         const currentTodos = await get(asyncGetTodoListAtom);
+         const newTodos: Todo[] = currentTodos.filter(todo => todo.id !== id)
+ 
+         await new Promise<void>(resolve => {
+             setTimeout(() => {
+                 set(todoListAtomStorage, newTodos);
+                 set(submittingAtom, false);
+                 resolve();
+             }, 2000);
+         });
+     }
+ );

Todo.tsx修正

削除用atomを呼び出して子コンポーネントに渡します。
また非同期処理中は完了(削除)ボタンを非活性とするためにdisabledも渡します。

src/components/Todo.tsx
import { Container } from "@mui/material";
import { useAtom } from "jotai";
import { useState } from "react";
- import { getTodoListAtom, setTodoListAtom, submittingAtom} from "../atoms/todoListAtom";
+ import { deleteTodoListAtom, getTodoListAtom, setTodoListAtom, submittingAtom} from "../atoms/todoListAtom";
import List from "./List";
import Form from "./Form";

const Todo = () => {
  const [todos] = useAtom(getTodoListAtom);
  const [, setTodos] = useAtom(setTodoListAtom);
+   const [, deleteTodo] = useAtom(deleteTodoListAtom);
  const [submitting] = useAtom(submittingAtom);
  const [newTodoText, setNewTodoText] = useState("");

  const handleClickAdd = async(newTodoText: string) => {
    await setTodos(newTodoText);

    setNewTodoText("");
  };

+   const handleDeleteTodo = async(id: number) => {
+     await deleteTodo(id);
+   }

  const disabled = submitting || todos.state !== "hasData";

  return (
    <Container maxWidth="sm">
      <h2>Todoリスト</h2>
      < Form newTodoText={newTodoText} onChangeText={(e) => setNewTodoText(e.target.value)} onSubmitAdd={handleClickAdd} disabled={disabled} />
+       < List todos={todos} onSubmitDelete={handleDeleteTodo} disabled={disabled} />
      </Container>
  )
};

export default Todo;

List.tsx修正

完了(削除)ボタンを追加します。

src/components/List.tsx
import { Loadable } from 'jotai/vanilla/utils/loadable';
+ import { Button } from '@mui/material';
import { Todo } from '../models/todoListModel';

interface Props {
    todos: Loadable<Promise<Todo[]>>;
+     disabled: boolean;
+     onSubmitDelete: (id: number) => void;
}

const List: React.FC<Props> = (props) => {
+     const handleClickDelete = (e: React.MouseEvent<HTMLButtonElement>) => {
+         e.preventDefault();
+         const target = e.target as HTMLButtonElement;
+         const todo_id = target.getAttribute("data-id");
+         props.onSubmitDelete(Number(todo_id));
+     }

    const renderList = (todos: Loadable<Promise<Todo[]>>) => {
        if (todos.state !== "hasData") {
            return <span>loading...</span>;
        }

        return todos.data.map(todo => {
            return (
                <div key={todo.id}>
+                     <Button
+                         variant="contained"
+                         sx={{ margin: 0.5 }}
+                         onClick={handleClickDelete}
+                         data-id={todo.id}
+                         disabled={props.disabled}
+                     >完了</Button>
                    <span>{todo.content}</span>
                </div>
            );
        })
    }

    return renderList(props.todos);
}

export default List;

まとめ

初期画面表示のデータ取得時はloading...を表示し、Todoリスト追加/削除を含む非同期処理時は入力欄やボタンを非活性とするTodoリストを作成しました。
非同期処理の処理状態の管理については読み込みatomではloadable、書き込みatomでは処理状態管理用に定義したatomを書き込みatom内で更新しながら行いました。
いずれの方法もJotai特有の機能/概念を用いた実装となっています。

参考

かなり手を加えていますがTodoリストの雛形はこちらの講義内の一部をベースにしています。
(Reactを理解するのにおすすめの講義です。)
https://www.udemy.com/course/react-complete-guide/?couponCode=ST11MT91624A

Discussion