React状態管理ライブラリJotaiで非同期処理のTodoリストを作ってみる
はじめに
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
ライブラリ
% cd my-jotai-tutorial
% npm install jotai
% 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はまだありませんがいったん呼び出してます。
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とリストの内容のみのシンプルな形とします。
export interface Todo {
id: number;
content: string;
}
fakeApi.tsx作成
非同期で初期値を返す擬似APIを作成します。
TodoオブジェクトをPromiseで返すようにし、本記事ではアプリの非同期処理時の振る舞いを確認しやすくするため、非同期処理の遅延をすべて2秒としていきます。
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でラップしたものを出力するようにします。
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で渡す形で実装していきます。
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...を表示するようにします。
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つとなっています。
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を返すようにしています。
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を渡すことで非同期処理中は入力フォームと追加ボタンが非活性になるようにしています。
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を定義
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も渡します。
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修正
完了(削除)ボタンを追加します。
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を理解するのにおすすめの講義です。)
Discussion