Web API作成入門
2024-2025 Shunsuke Watanabe
このチュートリアルについて
このチュートリアルはnodejsでREST APIを作成する過程を通じて、EventStormingでまとめたドメイン知識をコードに起こす基礎的な内容に触れてもらうことを目的としています。twitchまたはYoutubeで配信します、配信時間は1時間前後です。
Twitch → https://www.twitch.tv/shun__suke__
Youtube → https://www.youtube.com/@Captain_Emo
🧭 配信予定はconnpassをご確認ください → https://hands-on.connpass.com/
👨💻サンプルコード → https://github.com/craftgear/api_handson
discord → https://discord.gg/3havwjWGsw
bluesky → https://bsky.app/profile/craftgear.bsky.social
📝 blog → https://craftgear.github.io/posts/
1. 開発資料
EventStorming
-
イベントストーミングの利点3つ
Big Picture
Design Level
Onion Architecture
その他の3層アーキテクチャ
- Clean Architecture
- Hexagonal Architecture
2. 開発環境の準備
対象環境
- macOS
- Node.js 20〜
- LinuxやWindowsの方は自分でnodeとnpmをインストールできれば後は同じです
Node.js
-
nodeとnpmのバージョン確認
Terminal window ❯ node -vv22.2.0❯ npm -v10.8.3- インストール手順 https://nodejs.org/ja/download
SQLite3
-
sqlite3が使えることを確認
Terminal window ❯ sqlite3 --version3.22.0 2018-01-22 18:45:57 0c55d179733b46d8d0ba4d88e01a25e10677046ee3da1d5b1581e86726f2alt2
コードエディタ
- VisualStudio Code, neovim, emacsなどプログラミング支援機能が使えるもの
- テキストエディット、メモ帳などのテキストエディタは避けてください
新しいプロジェクトの作成
-
初期化
- テンプレートは下にスクロールして node.js を選択してください。
npm create hono@latest api -
コードフォーマッタとLinterのインストール
npm i -D eslint @eslint/js @types/eslint__js typescript typescript-eslint prettier -
eslint.config.mjs
ファイルを作成// @ts-checkimport eslint from '@eslint/js';import tseslint from 'typescript-eslint';export default tseslint.config(eslint.configs.recommended,...tseslint.configs.recommended,); -
tsconfig.json
の設定を変更“module”: “NodeNext”
を“module”: “ESNext”
に変更"verbatimModuleSyntax": true,
を消す"moduleResolution": "bundler",
を追加する
3. ドメインモデルの定義
zod https://zod.dev/
-
zodをインストール
npm i zod -
/src/todo/domain.ts
を作成、zodを使ってバリデーションスキーマと型を定義import { z } from 'zod';export const TodoSchema = z.object({id: z.number().brand('TodoId'),title: z.string(),done: z.coerce.boolean().default(false),doneAt: z.coerce.date().nullish(),});export type Todo = z.infer<typeof TodoSchema>;export type TodoId = Todo['id']; -
JSONのパース関数を追加
export const parseTodo = (data: unknown): Todo => TodoSchema.parse(data);export const parseTodoId = (id: number): TodoId => TodoSchema.shape.id.parse(id); -
新しく追加するTodoには
id
がないので、NewTodo
という型を追加する。export type NewTodo = Omit<Todo, 'id'>;export const parseNewTodo = (data: unknown): NewTodo => TodoSchema.omit({ id: true }).parse(data); -
vitestをインストール
npm i -D vitest -
テスト作成
/src/todo/domain.spec.ts
import { describe, it, expect, expectTypeOf } from 'vitest';import {parseTodo,parseTodoId,parseNewTodo,Todo,TodoId,NewTodo,} from './domain';describe('parseTodo', () => {it('parse a valid todo', () => {const result = parseTodo({ id: 1, title: "Buy milk" });expect(result).toEqual({id: 1,title: "Buy milk",done: false,});expectTypeOf(result).toEqualTypeOf<Todo>();const now = new Date();expect(parseTodo({ id: 1, title: 'Buy milk done', done: true, doneAt: now })).toEqual({id: 1,title: 'Buy milk done',done: true,doneAt: now,});});});describe('parseTodoId', () => {it('parse a valid todo id', () => {const todoId = parseTodoId(1);expectTypeOf(todoId).toEqualTypeOf<TodoId>();});});describe('parseNewTodo', () => {it('parse a valid new todo', () => {const result = parseNewTodo({ title: "Buy milk" });expect(result).toEqual({title: "Buy milk",done: false,});expectTypeOf(result).toEqualTypeOf<NewTodo>();});}); -
package.json
にtest
コマンドを追加 -
テスト実行
npm test
4. ビジネスロジックの実装
-
タイトルのチェックをzodのスキーマに追加
export const TodoSchema = z.object({id: z.number().brand('TodoId'),title: z.string({// パース時にタイトルの有無をチェックrequired_error: 'Title is required',}).trim()// パース時にタイトルが一文字以上あるかどうかチェック.min(1, { message: 'Title must be at least 1 character long' }),done: z.coerce.boolean().default(false),doneAt: z.date().optional(),}); -
完了チェックの関数を追加
export const isComplete = (todo: Todo) => todo.done; -
テスト追加
import { describe, it, expect, expectTypeOf } from 'vitest';import {parseTodo,parseTodoId,isComplete,parseNewTodo,Todo,TodoId,NewTodo,} from './domain';describe.concurrent('parseTodo', () => {it('parse a valid todo', () => {const result = parseTodo({ id: 1, title: "Buy milk" });expect(result).toEqual({id: 1,title: "Buy milk",done: false,});expectTypeOf(result).toEqualTypeOf<Todo>();const now = new Date();expect(parseTodo({ id: 1, title: 'Buy milk done', done: true, doneAt: now })).toEqual({id: 1,title: 'Buy milk done',done: true,doneAt: now,});});it('throws an error when title is missing', () => {expect(() => parseTodo({ id: 1 })).toThrowError('Title is required');});it('throws an error when title is empty', () => {expect(() => parseTodo({ id: 1, title: '' })).toThrowError('Title must be at least 1 character long');});it('throws an error when title is only whitespaces', () => {expect(() => parseTodo({ id: 1, title: ' ' })).toThrowError('Title must be at least 1 character long');});})describe('parseTodoId', () => {it('parse a valid todo id', () => {const todoId = parseTodoId(1);expectTypeOf(todoId).toEqualTypeOf<TodoId>();});});describe.concurrent('parseNewTodo', () => {it('parse a valid new todo', () => {const result = parseNewTodo({ title: "Buy milk" });expect(result).toEqual({title: "Buy milk",done: false,});expectTypeOf(result).toEqualTypeOf<NewTodo>();});it('throws an error when title is missing', () => {expect(() => parseNewTodo({})).toThrowError('Title is required');});it('throws an error when title is empty', () => {expect(() => parseNewTodo({ title: '' })).toThrowError('Title must be at least 1 character long');});it("throws an error when title is only whitespaces", () => {expect(() => parseTodo({ title: " " })).toThrowError("Title must be at least 1 character long",);});});describe.concurrent('isComplete', () => {it('returns true when todo is completed', () => {const todo = parseTodo({ id: 1, title: 'Buy milk', done: true });expect(isComplete(todo)).toBe(true);});it('returns false when todo is not completed', () => {const todo = parseTodo({ id: 1, title: 'Buy milk', done: false });expect(isComplete(todo)).toBe(false);});});
ドメイン層のコードはすべての関数が純粋関数になります。
5. ユースケース作成
Applicationという単語が一般的すぎるので、ここでは代わりにUsecaseと呼ぶことにします。
-
/src/todo/usecase.ts
を作成import { TodoId, TodoRepository } from './domain';export const readTodos = (repository: TodoRepository, page = 1, limit = 10) => {// TODO: implement};export const createTodo = (repository: TodoRepository, title: string) => {// TODO: Implement};export const completeTodo = (repository: TodoRepository, id: TodoId) => {// TODO: Implement}; -
/src/todo/domain.ts
にリポジトリの型定義を追加export type TodoRepository = {selectAll: (offset: number, limit: number) => Promise<Todo[]>;selectById: (id: TodoId) => Promise<Todo | null>;insert: (todo: NewTodo) => Promise<Todo | null>;setCompleted: (id: TodoId) => Promise<Todo | null>;}; -
1で定義したユースケースの実装
import {parseNewTodo,isComplete,TodoRepository,TodoId,} from './domain';export const readTodos = async ({ selectAll }: TodoRepository, page = 1, limit = 10) => {if (page < 1) {throw Error('page should be a positive number');}return await selectAll(page - 1, limit);};export const createTodo = async ({ insert }: TodoRepository, title: string) => {const todo = parseNewTodo({ title });return await insert(todo);};export const completeTodo = async ({ selectById, setCompleted }: TodoRepository,id: TodoId) => {const todo = await selectById(id);if (!todo) {throw new Error('Todo not found');}if (isComplete(todo)) {return todo;}return await setCompleted(id);}; -
ユースケースのテストを追加
/src/todo/usecase.spec.ts
import { describe, it, expect, vi, afterEach } from 'vitest';import { readTodos, createTodo, completeTodo } from './usecase';import type { TodoRepository, TodoId } from './domain';describe.concurrent('todo usecases', () => {const repository: TodoRepository = {insert: vi.fn(),selectAll: vi.fn(),selectById: vi.fn(),setCompleted: vi.fn(),};afterEach(() => {vi.resetAllMocks();});it('reads todos', async () => {await readTodos(repository);expect(repository.selectAll).toHaveBeenCalledWith(0, 10);});it("page number should be larger then 0", async () => {await expect(() => readTodos(repository, 0)).rejects.toThrowError("page should be a positive number",);});it('creates a new todo', async () => {const title = 'Buy milk';await createTodo(repository, title);expect(repository.insert).toHaveBeenCalledWith({ title, done: false });});it('completes a todo', async () => {const id = 1 as TodoId;const repo = {...repository,selectById: vi.fn().mockResolvedValueOnce({id: 1 as TodoId,title: "Buy milk",done: false,}),};await completeTodo(repo, id);expect(repo.selectById).toHaveBeenCalledWith(id);expect(repo.setCompleted).toHaveBeenCalledWith(id);});it('does NOT completes a todo which is already done', async () => {const id = 2 as TodoId;const repo = {...repository,selectById: vi.fn().mockResolvedValueOnce({id: 2 as TodoId,title: "Buy eggs",done: true,}),};await completeTodo(repo, id);expect(repo.selectById).toHaveBeenCalledWith(id);expect(repo.setCompleted).not.toHaveBeenCalledWith(id);});it('throws an error when todo is not found', async () => {const id = 999 as TodoId;const repo = {...repository,selectById: vi.fn().mockResolvedValueOnce(null),};await expect(() => completeTodo(repo, id)).rejects.toThrowError("Todo not found");});}); -
テストカバレッジを測定する
6. Httpサーバー作成
-
/src/todo/route.ts
を作成し、エンドポイントを定義import { Hono } from 'hono';import { HTTPException } from 'hono/http-exception';import { parseTodoId } from './domain';import { readTodos, createTodo, completeTodo } from './usecase';import { todoRepository } from './infra/repo';export const todoRoute = new Hono().basePath('/todos');todoRoute.get('/', async (c) => {const { page, limit } = c.req.query();const pageNumber = page ? Number(page) : undefined;const limitNumber = limit ? Number(limit) : undefined;const todos = await readTodos(todoRepository, pageNumber, limitNumber);return c.json(todos);});todoRoute.post('/', async (c) => {const data = await c.req.json();try {const newTodo = await createTodo(todoRepository, data.title);return c.json(newTodo);} catch (e: unknown) {throw new HTTPException(400, { message: (e as Error).message });}});todoRoute.patch('/:id/complete', async (c) => {const id = c.req.param('id');try {const todoId = parseTodoId(Number(id));const todo = await completeTodo(todoRepository, todoId);return c.json(todo);} catch (e: unknown) {throw new HTTPException(400, { message: (e as Error).message });}}); -
/src/todo/infra/repo.ts
を作成し、todoRepositoryのスケルトンを作成import type { TodoRepository, Todo } from '../domain';export const todoRepository: TodoRepository = {selectAll: async (offset, limit) => {//TODO: Implementreturn [];},selectById: async (id) => {//TODO: Implementreturn {} as Todo;},insert: async (todo) => {//TODO: Implementreturn {} as Todo;},setCompleted: async (id) => {//TODO: Implementreturn {} as Todo;},}; -
/src/index.ts
でルートを登録import { serve } from "@hono/node-server";import { Hono } from "hono";import { HTTPException } from "hono/http-exception";import { logger } from 'hono/logger';import { cors } from "hono/cors";import { todoRoute } from "./todo/route";const app = new Hono();app.use(logger());app.use("/v1/*", cors());app.route("/v1", todoRoute);app.onError((e, c) => {if (e instanceof HTTPException) {return e.getResponse();}return c.json({ error: e.message }, 500);});const port = 3000;console.log(`Server is running on port ${port}`);serve({fetch: app.fetch,port,}); -
npm run dev
でサーバーを起動
7. リポジトリ作成
-
DBスキーマを作成
/src/todo/infra/schema.sql
CREATE TABLE todo (id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT NOT NULL,done INTEGER DEFAULT 0,done_at INTEGER); -
DB作成
Terminal window cat src/todo/infra/schema.sql | sqlite3 database.sqlite -
クエリビルダーとSQLドライバーをインストール
Terminal window npm i kysely better-sqlite3 snake-camelnpm i -D kysely-codegen @types/better-sqlite3 -
データベースの型情報を生成
Terminal window echo 'DATABASE_URL=./database.sqlite' > .env && npx kysely-codegen\--out-file=./src/todo/infra/database.d.ts -
/src/todo/infra/db.ts
を作成import type { DB } from './database';import SQLite from 'better-sqlite3';import { Kysely, SqliteDialect } from 'kysely';const dialect = new SqliteDialect({database: new SQLite('./database.sqlite'),});export const db = new Kysely<DB>({ dialect }); -
/src/todo/infra/repo.ts
のTODOを実装import { db } from './db';import { toCamel } from 'snake-camel';import type { TodoRepository } from '../domain';import { parseTodo } from '../domain';export const todoRepository: TodoRepository = {selectAll: async (offset, limit) => {const data = await db.selectFrom('todo').selectAll().offset(offset * limit).limit(limit).orderBy('id desc').execute();return data.map((x) => parseTodo(toCamel(x)));},selectById: async (id) => {const data = await db.selectFrom('todo').selectAll().where('todo.id', '=', id).executeTakeFirst();return data ? parseTodo(toCamel(data)) : null;},insert: async (input) => {const data = await db.insertInto('todo').values({...input,done: 0,}).returningAll().executeTakeFirst();return data ? parseTodo(toCamel(data)) : null;},setCompleted: async (id) => {const data = await db.updateTable('todo').set({ done: 1, done_at: Date.now() }).where('todo.id', '=', id).returningAll().executeTakeFirst();return data ? parseTodo(toCamel(data)) : null;},}; -
postman等を用いてAPI呼び出しができることを確認する
8. OpenAPI定義作成
-
ライブラリを追加
npm i @hono/zod-openapi @hono/swagger-ui -
/src/todo/route.ts
のルート定義を OpenAPI対応のものに変更import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';import { HTTPException } from 'hono/http-exception';import { parseTodoId, TodoSchema } from "./domain";import { readTodos, createTodo, completeTodo } from "./usecase";import { todoRepository } from "./infra/repo";export const todoRoute = new OpenAPIHono();const getTodosRoute = createRoute({operationId: 'getTodos',tags: ['todos'],path: '/todos',method: 'get',description: 'Read all todos',request: {query: z.object({page: z.coerce.number().optional(),limit: z.coerce.number().optional(),})},responses: {200: {description: 'Get a list of todos',content: {'application/json': {schema: z.array(TodoSchema),},},},},});todoRoute.openapi(getTodosRoute, async (c) => {const { page, limit } = c.req.valid('query');const pageNumber = page ? Number(page) : undefined;const limitNumber = limit ? Number(limit) : undefined;const todos = await readTodos(todoRepository, pageNumber, limitNumber);return c.json(todos);});const postTodoRoute = createRoute({operationId: 'createTodo',tags: ['todos'],path: '/todos',method: 'post',description: 'Create a new todo item',request: {body: {required: true,content: {'application/json': {schema: z.object({title: z.string().openapi({example: 'Buy milk',description: 'The title of the todo',}),}),},},},},responses: {200: {description: 'a newly created todo item',content: {'application/json': {schema: TodoSchema,},},},400: {description: 'Bad request',},},});todoRoute.openapi(postTodoRoute, async (c) => {const data = await c.req.json();try {const newTodo = await createTodo(todoRepository, data.title);return c.json(newTodo);} catch (e: unknown) {throw new HTTPException(400, { message: (e as Error).message });}});const patchTodoRoute = createRoute({operationId: 'completeTodo',tags: ['todos'],path: '/todos/{id}/complete',method: 'patch',description: 'Mark a todo item as complete',request: {params: z.object({id: z.string(),}),},responses: {200: {description: 'a completed todo todo item',content: {'application/json': {schema: TodoSchema,},},},400: {description: 'Bad request',},},});todoRoute.openapi(patchTodoRoute, async (c) => {const id = c.req.param('id');try {const todoId = parseTodoId(Number(id));const todo = await completeTodo(todoRepository, todoId);return c.json(todo);} catch (e: unknown) {throw new HTTPException(400, { message: (e as Error).message });}}); -
/src/index.ts
に OpenAPI の定義とUIを表示するルートを追加import { serve } from "@hono/node-server";import { OpenAPIHono } from '@hono/zod-openapi';import { swaggerUI } from '@hono/swagger-ui';import { HTTPException } from 'hono/http-exception';import { logger } from 'hono/logger';import { cors } from "hono/cors";import { todoRoute } from "./todo/route";const app = new OpenAPIHono();app.use(logger());app.use("/v1/*", cors());app.route('/v1', todoRoute);app.onError((e, c) => {if (e instanceof HTTPException) {return e.getResponse();}return c.json({ error: e.message }, 500);});app.doc31('/open-api', {openapi: '3.1.0',info: { title: 'API Hands On', version: '1' },servers: [{ url: 'http://localhost:3000' }],tags: [{name: 'todos',description: 'Operations about todos',},],});app.get('/doc', swaggerUI({ url: '/open-api' }));const port = 3000;console.log(`Server is running on port ${port}`);serve({fetch: app.fetch,port,}); -
/src/todo/domain.ts
に OpenAPI用のデータを追加import { z } from "@hono/zod-openapi";export const TodoSchema = z.object({id: z.number().brand("TodoId").openapi("TodoId"),title: z.string({// パース時にタイトルの有無をチェックrequired_error: "Title is required",}).trim()// パース時にタイトルが一文字以上あるかどうかチェック.min(1, { message: "Title must be at least 1 character long" }),done: z.coerce.boolean().default(false),doneAt: z.coerce.date().optional(),}).openapi("Todo"); -
http://localhost:3000/open-api で API定義表示、http://localhost:3000/doc で ドキュメント表示
9. APIテスト
StepCI https://docs.stepci.com
-
Step CIをインストール
npm i -D stepci -
テストワークフローを生成
mkdir api-testsnpx stepci generate http://localhost:3000/open-api ./api-tests/todo.yml -
生成されたworkflowの名前と数値を修正する
-
テスト実行
npx stepci run ./api-tests/todo.yml
10. クライアント用コード生成
React入門で作ったTodoページのAPI呼び出しを置き換えてみます
Hey API https://heyapi.vercel.app/openapi-ts/get-started.html
-
React入門のリポジトリをcloneし、ディレクトリに入る
git clone https://github.com/craftgear/react-hands-oncd react-hands-on && npm i -
hey-apiをインストール
npm i -D @hey-api/openapi-ts @hey-api/client-fetch -
プロジェクトルートに
openapi-ts.config.ts
ファイルを作成import { defineConfig } from '@hey-api/openapi-ts';export default defineConfig({input: 'http://localhost:3000/open-api',output: {path: "src/api/generated",format: "prettier",},plugins: ['@hey-api/client-fetch', '@tanstack/react-query']}); -
TanStack Queryのフックに渡すオプションを生成
npx @hey-api/openapi-ts -
/src/api/todos.ts
の クエリを生成したコードで置き換えimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';import { client } from "./generated/client.gen";import {getTodosOptions,getTodosQueryKey,completeTodoMutation,createTodoMutation,} from './generated/@tanstack/react-query.gen';client.setConfig({baseUrl: "http://localhost:3000",});const queryKey = getTodosQueryKey();export const useGetTodos = () => useQuery(getTodosOptions());export const usePatchTodo = () => {const queryClient = useQueryClient();return useMutation({...completeTodoMutation(),onSuccess: () => {queryClient.invalidateQueries({ queryKey });},});};export const usePostTodo = () => {const queryClient = useQueryClient();return useMutation({...createTodoMutation(),onSuccess: () =>queryClient.invalidateQueries({ queryKey }),});}; -
usePatchTodo
とusePostTodo
にわたす引数を修正/src/pages/Todo.tsx ...const { mutate } = usePatchTodo();const handleChange = (e: ChangeEvent<HTMLInputElement>) => {const id = e.target.value;mutate({ path: { id });};.../src/components/TodoForm.tsx ...const { mutate } = usePostTodo();const submitForm: SubmitHandler<FormData> = (data) => {if (!data.title) return;mutate({ body: { title: data.title } }, { onSuccess: () => reset() });};...
TanStack Startで溶ける境界
Tanstack Start
https://tanstack.com/start/latest
-
cd tanstack-start
-
npm i; npm run dev
-
app/routes/todos.tsx
ファイルを新規作成すると、自動で以下の内容が生成される。import { createFileRoute } from '@tanstack/react-router'export const Route = createFileRoute('/todos')({component: RouteComponent,})function RouteComponent() {return <div>Hello "/todos"!</div>} -
Todo
とTodoForm
コンポーネントをapp/routes/todos.tsx
にコピーする。コンポーネント
import { ChangeEvent } from "react";import { useForm, type SubmitHandler } from 'react-hook-form';function Todos() {const router = useRouter();const data = Route.useLoaderData();const handleChange = (e: ChangeEvent<HTMLInputElement>) => {const id = e.target.value;updateTodo({ data: Number(id) }).then(() => router.invalidate());};return (<div><h1>Todos</h1><TodoForm />{data?.map(({ id, title, done }) => (<div key={id}><inputtype="checkbox"id={`${id}`}value={id}checked={done ? true : false}onChange={handleChange}/><label htmlFor={`${id}`}>{title}</label></div>))}</div>);}type FormData = {title: string;};function TodoForm() {const {register,handleSubmit,reset,formState: { errors },} = useForm<FormData>();const router = useRouter();const submitForm: SubmitHandler<FormData> = (data) => {if (!data.title) return;addTodo({ data: data.title }).then(() => {router.invalidate();reset();});};return (<form onSubmit={handleSubmit(submitForm)}>{errors.title && <p>タイトルを入力してください</p>}<input type="text" {...register("title", { required: true })} /><button className="btn btn-primary btn-sm">追加</button></form>);} -
app/domain
ディレクトリを作成し、api_handson のリポジトリから、todo/
ディレクトリをapp/domain/todo
にコピーする -
api_handson のリポジトリから、database.sqlilte をコピーする。
-
必要なライブラリをインストールする
npm i react-hook-form npm i kysely better-sqlite3 snake-camel; npm i -D kysely-codegen @types/better-sqlite3 @hono/zod-openapi prettier eslint typescript-eslint; -
フロントエンドとサーバーサイドをつなぐコードを追加する
import { createFileRoute, useRouter } from '@tanstack/react-router'...import { createServerFn } from '@tanstack/start'import { parseTodoId, parseTodo } from '../domain/todo/domain'import { readTodos, completeTodo, createTodo } from '../domain/todo/usecase'import { todoRepository } from '../domain/todo/infra/repo'const getTodos = createServerFn({ method: 'GET' }).handler(async () => {const data = await readTodos(todoRepository)return data.map((x) => ({...x,id: x.id as number,}))})const updateTodo = createServerFn({ method: 'POST' }).validator((data: number) => data).handler(async ({ data }) => {await completeTodo(todoRepository, parseTodoId(data))})const addTodo = createServerFn({ method: 'POST' }).validator((data: string) => data).handler(async ({ data }) => {await createTodo(todoRepository, data)})export const Route = createFileRoute('/todos')({loader: async () => (await getTodos()).map((x) => parseTodo(x)),component: Todos,})function Todos() {const { data, isPending, error } = useGetTodos();const { mutate } = usePatchTodo();const router = useRouter()const data = Route.useLoaderData()const handleChange = (e: ChangeEvent<HTMLInputElement>) => {const id = e.target.valuemutate({ id: Number(id), done: e.target.checked });updateTodo({ data: Number(id) }).then(() => router.invalidate())}if (isPending) {return <div>Loading... </div>;}if (error) {return <div>Error: {error.message}</div>;}...}function TodoForm() {...const router = useRouter()const submitForm: SubmitHandler<FormData> = (data) => {if (!data.title) returnmutate({ title: data.title }, { onSuccess: () => reset() })addTodo({ data: data.title }).then(() => {router.invalidate()reset()})}...}