使用技術
- typescript
- Next.js
- MUI
- react hook form
概要
react hook formとMUIを使って検索フォームを作成するときの検索用のカスタムフックスを作ってみました。
内容
バックエンドの検索APIに対してリクエストを送る場合を考えます
作成した検索フックス
引数は初期値とバックエンドへのリクエスト用のサーバーアクションメソッドです。
型引数にはreact hook formのフォームの型と検索結果の型を入れます。
今回はreact hook formの型とAPIのリクエストの型が同じで、検索結果の型とAPIのレスポンスの型が同じ場合で考えました。
型が違う場合は変換処理を検索フックス内に記述するか、引数としてメソッドで渡す必要があります。
初期値についてはサーバコンポーネント側で取得することが多いと思うので、引数として追加しました。
検索フックスの返り値はuseFormの値を丸ごと返しています。
また、isLoadingやerrorも返すことで各コンポーネントで定義せずに簡単にハンドリングできるようになりました。
下のコードではそれぞれの動きの確認用に、遅延処理とエラー発生用のコードも記載しています。
APIのレスポンスに対してのエラーハンドリングに関しては今回は行いませんでした。
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
が変更されます。
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>
);
}
"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部分をモック化しています
"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;
(簡略化のため型も混ざています)
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のクエリパラメータなど別の手段が必要そうです。