🌐

SWR2.0をTodoリストをつくりながら試す

2022/12/13に公開約8,900字

はじめに

先日、SWR 2.0がリリースされました。このリリースにはミューテーションに関する改善が含まれており、楽観的UIの更新が柔軟に行えるようになったり、新しいHookでミューテーションを行えるようになったりしました。今回はSWR2.0をTodoリストをつくりながら試していこう思います。
https://swr.vercel.app/ja/blog/swr-v2

また、データはブラウザのメモリだけでなく、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のフックを使い実装します。

src/components/TaskList.tsx
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となります。

https://swr.vercel.app/ja/blog/swr-v2#isloading

ですが、なぜかタスク削除の際、キャッシュがあるはずなのに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 }]);
};

最終的なタスク追加のコードは以下のようになります。

src/components/AddTaskForm.tsx
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");
  };


タスクの追加

タスクを削除する

次にタスク削除を実装します。まずは普通に実装してみます。

src/components/TaskItem.tsx
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>
  );
};

タスク削除は、

  1. 削除ボタンを押す
  2. deleteTaskが走りバックエンドのDBから削除される
  3. ミューテーションが呼ばれデータの再検証が行われる
  4. UIに反映される

という流れになっています。

  const handleDeleteTask = async () => {
    await deleteTask(task.id);
    mutate("/tasks");
  };

削除ボタン押下からUIの反映までに少しラグがあるため、ユーザーからすると削除したはずのタスクが一瞬残っているように感じてしまいます。


タスクの削除

楽観的にUIを更新する

そこで楽観的UIの更新を取り入れてみます。

楽観的UIの更新とは、ユーザーが操作した直後にレスポンスを待たずにUIを更新することです。

いいねボタンを例にとると、いいねをした直後にリクエストが成功したものとしていいねボタンの色が変わり、同時にリクエストが走ります。

https://kaminashi-developer.hatenablog.jp/entry/optimistic-update-in-spa

楽観的UIの更新を採用するにはいくつか条件があります。

上記の記事から引用すると

  • API のリライアビリティがあり、到達性が保証される
  • フロントエンド側でレスポンスの結果を予測できる
  • フロントエンド側、事前にエラーになる要素を排除できる

タスクの削除はこれらを満たしているため、楽観的UIの更新が適用できます。

一方、タスクの追加では、idやcreated_atなどDBで生成される値があり、レスポンスの結果を予測できないため、楽観的UIの更新に適さないということになります。

swr2.0ではmutateoptimisticDataに関数を渡すことにより楽観的UIの更新を簡単に実現できます。

src/components/TaskItem.tsx
  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になっています)

src/components/TaskItem.tsx
  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の更新など割と面倒な実装も簡単に実装することができました。

最終的なコードは以下から確認できます。
https://github.com/hiromu617/swr_todo

vercelにデプロイしたものは以下のURLから動作確認できます。楽観的UIの更新によって同期的にUIが更新されるのが実感できると思います。

(ユーザーによってタスクを分けていないので同時に動作すると予期せず更新されます...)

https://swr-todo.vercel.app/

Discussion

ログインするとコメントできます