[Next.js]React Hook FormとMUIでカスタム検索フックスを作成してみる

# 使用技術
- typescript
- Next.js
- MUI
- react hook form

# 概要
react hook formとMUIを使って検索フォームを作成するときの検索用のカスタムフックスを作ってみました。

# 内容

バックエンドの検索APIに対してリクエストを送る場合を考えます

### 作成した検索フックス

引数は初期値とバックエンドへのリクエスト用のサーバーアクションメソッドです。
型引数にはreact hook formのフォームの型と検索結果の型を入れます。
今回はreact hook formの型とAPIのリクエストの型が同じで、検索結果の型とAPIのレスポンスの型が同じ場合で考えました。
型が違う場合は変換処理を検索フックス内に記述するか、引数としてメソッドで渡す必要があります。
初期値についてはサーバコンポーネント側で取得することが多いと思うので、引数として追加しました。
検索フックスの返り値はuseFormの値を丸ごと返しています。
また、isLoadingやerrorも返すことで各コンポーネントで定義せずに簡単にハンドリングできるようになりました。
下のコードではそれぞれの動きの確認用に、遅延処理とエラー発生用のコードも記載しています。
APIのレスポンスに対してのエラーハンドリングに関しては今回は行いませんでした。


```ts:useSearch.ts
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";

interface useSearchProps<TRequest extends FieldValues, TResponse> {
  defaultValue: TResponse;
  searchServerAction: (requestBody: TRequest) => Promise<TResponse>;
}
const useSearch = <TRequest extends FieldValues, TResponse>(
  props: useSearchProps<TRequest, TResponse>
) => {
  const { defaultValue, searchServerAction } = props;
  const form = useForm<TRequest>();
  const [results, setResults] = useState<TResponse | undefined>(defaultValue);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string>();
  //   フォームの型とリクエストの型が異なる場合は変換する必要がある
  const search = form.handleSubmit(async (data: TRequest) => {
    setIsLoading(true);
    setError(undefined);
    setResults(undefined);
    try {
      // 本来はエラーハンドリングを追加
      const searchResults = await searchServerAction(data);
      //   確認用
      //   throw new Error();
      setResults(searchResults);

      // eslint-disable-next-line @typescript-eslint/no-unused-vars
    } catch (err) {
      setError("failed");
    } finally {
      setIsLoading(false);
    }
  });
  return { form, search, results, isLoading, error };
};
export default useSearch;
```

### 検索フックスを呼び出す

`page.tsx`はサーバコンポーネントとして、初期データを取得しています。
その初期データをクライアントコンポーネントである`BookSearchForm`に渡しています。
検索部分に関してはモック化しています。
`search`メソッドが実行されると`results`が変更されます。



```tsx:page.tsx
import { books } from "@/const/const";
import { SearchBooksResponse } from "@/serverActions/searchBooks";
import BookSearchForm from "./BookSearchForm";
import Box from "@mui/material/Box";

export default function Home() {
  // 本来はAPIからデータを取得
  const res: SearchBooksResponse = { status: "200", data: books };
  return (
    <div>
      <Box
        sx={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          minHeight: "100vh",
        }}
      >
        <BookSearchForm defaultValue={res} />
      </Box>
    </div>
  );
}
```


```tsx:BookSearchForm.tsx
"use client";
import React from "react";
import { Controller } from "react-hook-form";
import {
  TextField,
  Button,
  Stack,
  List,
  ListItem,
  Typography,
  CircularProgress,
  Alert,
} from "@mui/material";
import useSearch from "@/hooks/useSearch";
import searchBooks, {
  SearchBooksRequest,
  SearchBooksResponse,
} from "@/serverActions/searchBooks";

interface BookSearchFormProps {
  defaultValue: SearchBooksResponse;
}

const BookSearchForm: React.FC<BookSearchFormProps> = (props) => {
  const { defaultValue } = props;
  const { form, search, results, isLoading, error } = useSearch<
    SearchBooksRequest,
    SearchBooksResponse
  >({ defaultValue: defaultValue, searchServerAction: searchBooks });
  const { control } = form;
  return (
    <div>
      <form onSubmit={search}>
        <Stack direction="row" spacing={2}>
          <Controller
            name="title"
            control={control}
            defaultValue=""
            render={({ field }) => (
              <TextField
                {...field}
                label="本の名前"
                variant="outlined"
                fullWidth
              />
            )}
          />
          <Controller
            name="author"
            control={control}
            defaultValue=""
            render={({ field }) => (
              <TextField
                {...field}
                label="著者名"
                variant="outlined"
                fullWidth
              />
            )}
          />
          <Button type="submit" variant="contained" color="primary">
            検索
          </Button>
        </Stack>
      </form>
      {isLoading && (
        <div style={{ textAlign: "center", marginTop: "20px" }}>
          <CircularProgress />
        </div>
      )}
      {error && (
        <Alert severity="error" style={{ marginTop: "20px" }}>
          error
        </Alert>
      )}

      {!isLoading && results && results.data.length > 0 && (
        <List>
          {results.data.map((book, index) => (
            <ListItem key={index}>
              <Typography variant="h6">{book.title}</Typography>
              <Typography variant="body2">{book.author}</Typography>
            </ListItem>
          ))}
        </List>
      )}
    </div>
  );
};

export default BookSearchForm;

```


引数として渡すサーバーアクションはAPI部分をモック化しています


```tsx:searchBooks.ts
"use server";

import { Book, books } from "@/const/const";

export interface SearchBooksRequest {
  title: string;
  author: string;
}

export interface SearchBooksResponse {
  data: Book[];
  status: string;
}
const searchBooks = async (
  data: SearchBooksRequest
): Promise<SearchBooksResponse> => {
  // 本来はAPIからデータを取得
  console.log(data);
  const result = books.slice(2, 4);
  //   確認用
  // await new Promise((resolve) => setTimeout(resolve, 5000));
  return {
    status: "200",
    data: result,
  };
};
export default searchBooks;

```


(簡略化のため型も混ざています)

```ts:const.ts
export interface Book {
  id: number;
  title: string;
  author: string;
}
export const books: Book[] = [
  {
    id: 1,
    title: "Sample Title 1",
    author: "Author 1",
  },
  {
    id: 2,
    title: "Sample Title 2",
    author: "Author 2",
  },
  {
    id: 3,
    title: "Sample Title 3",
    author: "Author 3", 
  },
  {
    id: 4,
    title: "Sample Title 4",
    author: "Author 4", 
  },
  {
    id: 5,
    title: "Sample Title 5",
    author: "Author 5",
  },
];

```

# まとめ
読んでいただきありがとうございました。
今回はreact hook formとMUIでの検索フックスの作成を行いました。
このフックスは主に検索画面と検索結果が同じページの場合には使えそうでした。
検索画面と結果画面が分離している場合は、グローバルステートで検索フックスを管理するなどが必要そうですが、検索結果画面の表示データに関してサーバーコンポーネントで取得したいと思うので今回のフックスはつかえず、URLのクエリパラメータなど別の手段が必要そうです。