Suspenseでローディング画面表示を回避しつつデータを即時反映させる方法

## はじめに
[前回](https://qiita.com/IchidaU/items/ec974c3d18d3ec975ff1)の記事で登録したデータの即時反映はできたのですが、登録の度にローディング画面が出てくるのはユーザー体験としてよくないな、と感じ、削除機能の実装と併せてここも改善することにしました。

## やりたかったこと
記録の追加／削除時になるべくローディング画面を表示することなく、画面に即時反映したい。
![StudyRecordApp_listRefresh.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3941608/594b2c24-c733-4cab-ace6-df16f057e66c.gif)


## 解決策
### データ追加時
以前の再取得用関数はPromiseインスタンスのキャッシュクリアのみ行い、`Suspense`の性質を利用して再度データを取得してくるものでした。

<details><summary>データ取得フック</summary>

```react:src/hooks/useFetchData.tsx
import { use } from "react";
import { GetRecords } from "../lib/record";
import { Record } from "../domain/record";

let recordsPromise: Promise<Record[]> | null = null;

export const useFetchData = () => {
  if (!recordsPromise) {
    recordsPromise = new Promise<Record[]>((resolve) => {
      (async () => {
        const data = await GetRecords();
        resolve(data);
      })();
    });
  }

  const records = use(recordsPromise);

  return { records };
};

export const refetchRecords = () => {
  recordsPromise = null;
};
```

</details>

<details><summary>Supabase側処理</summary>

```react:src/lib/record.ts
import { Record } from "../domain/record";
import { supabase } from "../utils/supabase";

export async function GetRecords(): Promise<Record[]> {
  const response = await supabase.from("study-record").select("*");
  if (response.error) {
    throw new Error(response.error.message);
  }

  const recordsData = response.data.map((record) => {
    return new Record(record.id, record.title, record.time);
  });

  return recordsData;
}

export async function AddRecord(
  title: string,
  time: number
): Promise<Record[]> {
  await supabase
    .from("study-record")
    .insert([
      {
        title: title,
        time: time,
      },
    ])
    .select();

  return GetRecords();
}

export async function DeleteRecord(id: string): Promise<Record[]> {
  await supabase.from("study-record").delete().eq("id", id);
  return GetRecords();
}
```

</details>

そこで、データ追加時に`Promise.resolve`を渡し、これをフォームの登録時に渡すようにしました。

```react:src/hooks/useFetchData.tsx
  const updateRecords = (newRecords: Record[]) => {
    recordsPromise = null;
    recordsPromise = Promise.resolve(newRecords);
  };

  return { records, setData: updateRecords };
```

```react:src/components/InputForm.tsx
  const onClickAdd = async () => {
    try {
      const newRecords = await AddRecord(title, time);
      setData(newRecords);
      reset({ title: "", time: 0 });
      onClose();
    } catch (error) {
      console.error(error);
    }
  };
  ```

`use`は既に解決済みの`Promise`を受け取った場合、サスペンドせず、即座に値を返す性質があります。
`AddRecord()`を呼び出すときに既にSupabaseから最新の一覧は受け取っているので、これによってLoading画面が表示されることなく、追加したデータが表示されるようになりました。

### データ削除時
削除の場合は`setData`を呼び出すのみだと上手く動作しなかったので、App.tsxに再マウント用の関数を用意し、削除ボタンを押すとそれを呼び出すようにすることで、削除結果が即時反映されるようになりました。

```react:App.tsx
  const [refreshKey, setRefreshKey] = useState(0);

  const refreshData = () => {
    setRefreshKey((prev) => prev + 1);
  };

  return (
    <>
        <Suspense
          fallback={
            <Heading as="h2" size="md">
              Loading...
            </Heading>
          }
        >
          <RecordList key={refreshKey} onDataChange={refreshData} />
        </Suspense>
    </>
  );
}
```

```react:src/components/RecordList.tsx
  const onClickDelete = async (id: string) => {
    const updateRecords = await DeleteRecord(id);
    setData(updateRecords);
    onDataChange();
  };

  return <Button onClick={() => onClickDelete(record.id)}>削除</Button>
```

## おわりに
本来ならば`startTransition`等を使うべきなのでしょうが、上手くいかずこのような実装になりました。もっといい方法があればご指摘頂けると幸いです。