【React】Suspenseで更新されたデータを表示する方法

## はじめに
`Suspense`を使用したデータ取得・描画について、更新の際はどうすればよいのかだいぶ悩んだので、備忘録を兼ねて投稿します。

## やりたかったこと
モーダルのフォームに記録を入力して、「登録」ボタンを押すとデータが登録され、画面に即時反映される。

![StudyRecordApp_updateList.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3941608/bd7ce768-c0d4-4d2b-90fa-40599792f958.gif)

## コード
### データ取得用カスタムフック
```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;
};
```

### モーダルコンポーネント
```react:src/components/InputForm.tsx
import { ChangeEvent, useState } from "react";
import {
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  FormControl,
  FormLabel,
  Input,
  NumberInput,
  NumberInputField,
  Button,
} from "@chakra-ui/react";

import { AddRecord } from "../lib/record";
import { refetchRecords } from "../hooks/useFetchData";

type InputFormProps = {
  isOpen: boolean;
  onClose: () => void;
};

export const InputForm = ({ isOpen, onClose }: InputFormProps) => {
  const [title, setTitle] = useState("");
  const [time, setTime] = useState("");

  const onChangeTitle = (e: ChangeEvent<HTMLInputElement>) =>
    setTitle(e.target.value);
  const onChangeTime = (valueAsString: string) => setTime(valueAsString);

  const onClickAdd = () => {
    try {
      AddRecord(title, time);
      setTitle("");
      setTime("");
      onClose();
    } catch (error) {
      console.error(error);
    }
  };

  return (
 （中略）
          <Button onClick={onClickAdd} colorScheme="blue" mr={3}>
            登録
          </Button>

（以下略）
```

### App.tsx
```react:App.tsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button, Heading, useDisclosure } from "@chakra-ui/react";

import { RecordList } from "./components/RecordList";
import { InputForm } from "./components/InputForm";

function App() {
  const { isOpen, onOpen, onClose } = useDisclosure();
  return (
    <>
      <Heading as="h1" data-testid="title">
        学習記録アプリ
      </Heading>
      <ErrorBoundary
        fallback={
          <Heading as="h2" size="md">
            データ取得に失敗しました。
          </Heading>
        }
      >
        <Button onClick={onOpen} colorScheme="blue">
          登録
        </Button>
        <InputForm isOpen={isOpen} onClose={onClose} />
        <Suspense
          fallback={
            <Heading as="h2" size="md">
              Loading...
            </Heading>
          }
        >
          <RecordList />
        </Suspense>
      </ErrorBoundary>
    </>
  );
}

export default App;
```

## 解決策
`Suspense`は`Promise`が解決されると再レンダリングを行います。データ取得用カスタムフックは、キャッシュがない場合は新たなPromiseインスタンスを作成するので、キャッシュクリアする再取得用関数を入れてやればいいだけでした。

```diff_typescript: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;
+ };
```

```diff_typescript:src/components/InputForm.tsx
(前略）

  const onClickAdd = () => {
    try {
      AddRecord(title, time);
      setTitle("");
      setTime("");
      onClose();
+     refetchRecords();
    } catch (error) {
      console.error(error);
    }
  };

（以下略）
```

## おわりに
わかってしまうと簡単なのですが、stateで管理するという発想から抜け出せず、苦労しました。