SWR2.0をTodoリストをつくりながら試す
はじめに
先日、SWR 2.0がリリースされました。このリリースにはミューテーションに関する改善が含まれており、楽観的UIの更新が柔軟に行えるようになったり、新しいHookでミューテーションを行えるようになったりしました。今回はSWR2.0をTodoリストをつくりながら試していこう思います。
また、データはブラウザのメモリだけでなく、DBに保存する形式とします。
バックエンドにSupabase、UIフレームワークにChakra UI、ビルド環境にViteを使用します。
事前準備
SupabaseのDBにtasksテーブルを追加しておきます。
Name | Type | Default Value |
---|---|---|
id | int8 | - |
title | text | - |
created_at | timestamptz | now() |
done | boolean | false |
適当にUIも作成しておきます。
タスクを取得する
まずはタスクを取得するところから始めます。Supabaseではweb上のエディターからレコードを追加できるので、あらかじめレコードを追加しておき動作確認を行います。
今まで通り、useSWR
のフックを使い実装します。
import { Stack, Spinner, Center } from "@chakra-ui/react";
import { FC } from "react";
import { supabase } from "../client";
import { Task } from "../types";
import useSWR from "swr";
import { TaskItem } from "./TaskItem";
const fetcher = async (): Promise<Task[]> => {
const { data, error } = await supabase
.from("tasks")
.select(`*`)
.order("created_at", { ascending: false });
if (error) throw error;
if (data) return data;
return [];
};
export const TaskList: FC = () => {
const {
data: tasks,
isLoading,
isValidating,
} = useSWR<Task[]>("/tasks", fetcher);
if ((isLoading && !isValidating) || !tasks)
return (
<Center my={10}>
<Spinner size="lg" />
</Center>
);
return (
<Stack my={4} gap={4}>
{tasks.map((task) => (
<TaskItem task={task} key={task.id} />
))}
</Stack>
);
};
isLoading
はswr2.0から新しく追加されたuseSWRの戻り値で初期ロード時などdataがundefinedの時にtrueになるフラグです。isValidating
は今まで通り再検証時にtrueとなります。
ですが、なぜかタスク削除の際、キャッシュがあるはずなのにisLoading
がtrueとなってしまったためisLoading && !isValidating
で初期ロード状態であることを表現しています。(ここら辺の挙動はまだ理解しきれてない...)
supabaseのエディターからレコードを追加して動作確認してみます。
タスクの取得
タスクを追加する
2.0で追加されたuseSWRMutation
のフックを使って実装します。
useSWRMutation
の戻り値isMutation
でミューテーションをおこなっているかどうかの状態を得ることができます。
const { trigger, isMutating } = useSWRMutation("/tasks", addTask);
v1では、
const [isSubmitting, setIsSubmitting] = useState(false)
のようなstateを自前で用意してSubmit中であることをUIに反映していましたが、その必要がなくなりました。
useSWRMutation
の第二引数にミューテーションを行う関数を渡しtrigger
を呼ぶことでミューテーションが実行されます。
trigger
の引数がミューテーションを行う関数の第二引数に渡されます。第一引数にはキーが渡されます。(今回は関数内で使用しないので_
にしています)
const addTask = async (_: string, { arg }: { arg: string }) => {
await supabase.from("tasks").insert([{ title: arg }]);
};
最終的なタスク追加のコードは以下のようになります。
import { Flex, Input, Button } from "@chakra-ui/react";
import { FC, FormEvent, useState, ChangeEvent } from "react";
import { supabase } from "../client";
import { Task } from "../types";
import useSWRMutation from "swr/mutation";
const addTask = async (_: string, { arg }: { arg: string }) => {
await supabase.from("tasks").insert([{ title: arg }]);
};
export const AddTaskForm: FC = () => {
const { trigger, isMutating } = useSWRMutation("/tasks", addTask);
const [input, setInput] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
trigger(input);
setInput("");
};
const onChange = async (e: ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
return (
<Flex gap={4} as="form" onSubmit={handleSubmit}>
<Input placeholder="Task" value={input} onChange={onChange} />
<Button
colorScheme="blue"
type="submit"
isLoading={isMutating}
disabled={isMutating}
>
追加する
</Button>
</Flex>
);
};
ちなみに以前の書き方だとhandleSubmit
は以下のようになるのでだいぶスッキリしたことがわかると思います。
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
await addTask(input);
setIsSubmitting(false);
setInput("");
mutate("/tasks");
};
タスクの追加
タスクを削除する
次にタスク削除を実装します。まずは普通に実装してみます。
import { Card, CardBody, Text, Checkbox, IconButton } from "@chakra-ui/react";
import { FC } from "react";
import { CloseIcon } from "@chakra-ui/icons";
import { supabase } from "../client";
import { useSWRConfig } from "swr";
import { Task } from "../types";
type Props = {
task: Task;
};
const deleteTask = async (id: number) => {
const { error } = await supabase.from("tasks").delete().eq("id", id);
if (error) {
throw error;
}
};
export const TaskItem: FC<Props> = ({ task }) => {
const { mutate } = useSWRConfig();
const handleDeleteTask = async () => {
await deleteTask(task.id);
mutate("/tasks");
};
return (
<Card key={task.id}>
<CardBody
display="flex"
justifyContent="center"
alignItems="center"
gap={4}
>
<Checkbox size="lg" />
<Text flex="1">{task.title}</Text>
<IconButton
aria-label="Delete Task"
size="sm"
colorScheme="red"
icon={<CloseIcon />}
onClick={handleDeleteTask}
/>
</CardBody>
</Card>
);
};
タスク削除は、
- 削除ボタンを押す
-
deleteTask
が走りバックエンドのDBから削除される - ミューテーションが呼ばれデータの再検証が行われる
- UIに反映される
という流れになっています。
const handleDeleteTask = async () => {
await deleteTask(task.id);
mutate("/tasks");
};
削除ボタン押下からUIの反映までに少しラグがあるため、ユーザーからすると削除したはずのタスクが一瞬残っているように感じてしまいます。
タスクの削除
楽観的にUIを更新する
そこで楽観的UIの更新を取り入れてみます。
楽観的UIの更新とは、ユーザーが操作した直後にレスポンスを待たずにUIを更新することです。
いいねボタンを例にとると、いいねをした直後にリクエストが成功したものとしていいねボタンの色が変わり、同時にリクエストが走ります。
楽観的UIの更新を採用するにはいくつか条件があります。
上記の記事から引用すると
- API のリライアビリティがあり、到達性が保証される
- フロントエンド側でレスポンスの結果を予測できる
- フロントエンド側、事前にエラーになる要素を排除できる
タスクの削除はこれらを満たしているため、楽観的UIの更新が適用できます。
一方、タスクの追加では、idやcreated_atなどDBで生成される値があり、レスポンスの結果を予測できないため、楽観的UIの更新に適さないということになります。
swr2.0ではmutate
のoptimisticData
に関数を渡すことにより楽観的UIの更新を簡単に実現できます。
const handleDeleteTask = async () => {
- await deleteTask(task.id);
- mutate("/tasks");
+ await mutate("/tasks", deleteTask(task.id), {
+ optimisticData: (tasks: Task[]) => tasks.filter((t) => t.id !== task.id),
});
};
楽観的UIの更新を用いてタスク削除をすると↓のようになります。
タスクの削除(楽観的UIの更新バージョン)
GIFでは少しわかりにくいですが、削除ボタンを押すと即座にUIが更新されるようになりました。
エラー時のロールバック
楽観的UIの更新はリクエストが成功したものとみなしているため、リクエストが失敗した時、UIの表示を元に戻す必要があります。
optionのrollbackOnError
をtrueにすることにより簡単に実装できます。(デフォルトでtrueになっています)
const handleDeleteTask = async () => {
await mutate("/tasks", deleteTask(task.id), {
optimisticData: (tasks: Task[]) => tasks.filter((t) => t.id !== task.id),
rollbackOnError: true,
});
};
意図的にエラーを発生させて挙動を確認してみます。
タスクを更新する
タスクの更新も削除と同様に楽観的UIの更新を用いて実装します。
+ const updateTask = async (id: number, done: boolean) => {
+ const { error } = await supabase.from("tasks").update({ done }).eq("id", id);
+ if (error) {
+ throw error;
+ }
+ };
export const TaskItem: FC<Props> = ({ task }) => {
const { mutate } = useSWRConfig();
const handleDeleteTask = async () => {
await mutate("/tasks", deleteTask(task.id), {
optimisticData: (tasks: Task[]) => tasks.filter((t) => t.id !== task.id),
revalidate: false,
});
};
+ const handleTaskUpdate = async () => {
+ await mutate("/tasks", updateTask(task.id, !task.done), {
+ optimisticData: (tasks: Task[]) =>
+ tasks.map((t) => {
+ if (t.id === task.id) return { ...t, done: !t.done };
+ return t;
+ }),
+ revalidate: true,
+ });
+ };
return (
<Card key={task.id}>
<CardBody
display="flex"
justifyContent="center"
alignItems="center"
gap={4}
>
- <Checkbox size="lg" isChecked={task.done} />
+ <Checkbox size="lg" isChecked={task.done} onChange={handleTaskUpdate} />
<Text flex="1">{task.title}</Text>
<IconButton
aria-label="Delete Task"
size="sm"
colorScheme="red"
icon={<CloseIcon />}
onClick={handleDeleteTask}
/>
</CardBody>
</Card>
);
};
おわりに
swr2.0を使用してTodoリストを実装しました。楽観的UIの更新など割と面倒な実装も簡単に実装することができました。
最終的なコードは以下から確認できます。
vercelにデプロイしたものは以下のURLから動作確認できます。楽観的UIの更新によって同期的にUIが更新されるのが実感できると思います。
(ユーザーによってタスクを分けていないので同時に動作すると予期せず更新されます...)
Discussion
こんにちわ。こちらの記事とGitHubのプロジェクトをめちゃくちゃ参考にさせてもらいました。ありがとうございます。
コードをこちらで公開しているのですが、もし、ライセンス等の問題があれば教えて下さい!役立ててもらえて嬉しいです!ありがとうございます
一応MITつけといたので現状通り自由に使用してもらって問題ないです!
ありがとうございます!!