ReactHookForm×AutoCompleteで2つの項目の機能連動を実現する(Step4:項目間連動)

## はじめに
[前回](https://qiita.com/koji0705/items/db643f59358beb13073c)までの記事の続きです。Form内の項目で1つの項目に連動し、もう1つの項目の一覧が変更されるという機能です。
UXを高めるためのよくある実装ですが、なかなか実装に苦労します。僕はこれが実装したくて、試行錯誤してStep4まで来てしまいました💦💦💦

### 成果物
![react_hook_form_kinourendo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/c547d9fc-b226-a0d6-aa89-0aee8da16c90.gif)

https://github.com/kouji0705/rhf_autocomplete


## ソースコード
```shell:ディレクトリ構成
~/develop/HITOTSU/rhf_autocomplete  (feat/auto_complete_bunrui)$ tree src/
src/
├── App.tsx
├── form
│   ├── Form.tsx
│   ├── api.ts
│   ├── hooks.ts
│   └── type.ts
├── main.tsx
├── mock-server
│   └── server.js //　server用のコード
```

今回はサーバ用のコードに注目したいため、server用のコードは下記に折りたたんでいます。
<details><summary>server用のコード</summary>

```js:src/mock-server/server.js
import express from "express";
import cors from "cors";
const app = express();
app.use(cors());
const port = 3000;

const categories = [
	{ id: 1, key: "food", name: "食べ物" },
	{ id: 2, key: "animal", name: "動物" },
	{ id: 3, key: "vehicle", name: "乗り物" },
];

const subCategories = {
	1: [
		{ id: 1, key: "apple", name: "りんご" },
		{ id: 2, key: "banana", name: "バナナ" },
		{ id: 3, key: "orange", name: "オレンジ" },
	],
	2: [
		{ id: 1, key: "dog", name: "犬" },
		{ id: 2, key: "cat", name: "猫" },
		{ id: 3, key: "rabbit", name: "うさぎ" },
	],
	3: [
		{ id: 1, key: "car", name: "車" },
		{ id: 2, key: "bike", name: "自転車" },
		{ id: 3, key: "bus", name: "バス" },
	],
};

// 大分類を検索するAPI
app.get("/api/category", (req, res) => {
	const searchName = req.query.searchName || ""; // クエリパラメータを取得
	const filteredCategories = categories.filter(
		(category) => category.name.includes(searchName), // 名前にsearchNameが含まれるかでフィルタリング
	);
	res.json(filteredCategories);
});
app.get("/api/category/:id", (req, res) => {
	const searchName = req.query.searchName || ""; // クエリパラメータを取得
	const subCategory = subCategories[req.params.id];

	if (subCategory) {
		const filteredSubCategories = subCategory.filter(
			(item) => item.name.includes(searchName), // 名前にsearchNameが含まれるかでフィルタリング
		);
		res.json(filteredSubCategories);
	} else {
		res.status(404).send("Not found");
	}
});

app.listen(port, () => {
	console.log(`Mock API server running at http://localhost:${port}`);
});
```
</details>

```tsx:src/App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SearchAutocomplete } from "./form/Form";

function App() {
	const queryClient = new QueryClient();
	return (
		<QueryClientProvider client={queryClient}>
			<SearchAutocomplete />
		</QueryClientProvider>
	);
}

export default App;

```

```tsx:src/form/Form.tsx
import { Controller } from "react-hook-form";
import { TextField, Autocomplete } from "@mui/material";
import { useSearchAutocomplete } from "./hooks";
import type { Category } from "./type";

// ユーザー検索のためのAutocompleteコンポーネント
export const SearchAutocomplete = () => {
	const {
		control,
		categoryOptions,
		isCategoryLoading,
		subCategoryOptions,
		isSubCategoryLoading,
		setValue,
	} = useSearchAutocomplete();
	return (
		<form>
			{/* 親カテゴリーのAutocomplete */}
			<Controller
				name="category"
				control={control}
				render={({ field }) => (
					<Autocomplete
						{...field}
						sx={{ width: 300 }}
						options={categoryOptions}
						getOptionLabel={(option: Category) => option.name} // nameフィールドを表示
						loading={isCategoryLoading}
						value={field.value}
						onChange={(event, value) => {
							field.onChange(value); // 親カテゴリー選択時にフィールドを更新
							setValue("subCategory", null); // 親カテゴリー変更時にサブカテゴリーをリセット
						}}
						renderInput={(params) => (
							<TextField
								{...params}
								label="親カテゴリー検索"
								variant="outlined"
								InputProps={{
									...params.InputProps,
								}}
							/>
						)}
					/>
				)}
			/>

			{/* サブカテゴリーのAutocomplete */}
			<Controller
				name="subCategory"
				control={control}
				render={({ field }) => (
					<Autocomplete
						{...field}
						sx={{ width: 300, marginTop: "16px" }}
						options={subCategoryOptions}
						getOptionLabel={(option: Category) => option.name} // nameフィールドを表示
						loading={isSubCategoryLoading}
						value={field.value}
						onInputChange={(event, value) => {
							if (field.value) {
								setValue("subCategory", {
									...field.value,
									name: value,
									id: field.value.id ?? 0,
								});
							}
						}} // サブカテゴリーの入力変更時にsearchNameを更新
						onChange={(event, value) => field.onChange(value)} // サブカテゴリー選択時にフィールドを更新
						renderInput={(params) => (
							<TextField
								{...params}
								label="サブカテゴリー検索"
								variant="outlined"
								InputProps={{
									...params.InputProps,
								}}
							/>
						)}
					/>
				)}
			/>
		</form>
	);
};
```
:::note info
難しいのはSubCategoryの実装ですね💦
親カテゴリーが再選択された場合、子カテゴリーの値のリセットや親カテゴリーに付随する一覧の再取得が必要になります。なかなか苦労しました😅
:::


```ts:src/form/hooks.ts
import { useQuery } from "@tanstack/react-query";
import { fetchCategories, fetchSubCategories } from "./api";
import type { FormValues } from "./type";
import { useForm } from "react-hook-form";

// カスタムフック: カテゴリのオプションを取得
export const useCategoryOptions = (searchName: string) => {
	return useQuery({
		queryKey: ["categories", searchName], // クエリキーをsearchNameに基づいて更新
		queryFn: () => fetchCategories(searchName), // クエリ関数
		enabled: true, // 初期ロード時もクエリを実行
	});
};

// サブカテゴリのオプションを取得するカスタムフック
export const useSubCategoryOptions = (
	categoryId: number | null,
	searchName: string,
) => {
	return useQuery({
		queryKey: ["subCategories", categoryId, searchName], // categoryIdとsearchNameに基づいてクエリを実行
		queryFn: () => {
			if (categoryId === null) {
				return Promise.resolve([]); // categoryIdがnullの場合は空配列を返す
			}
			return fetchSubCategories(categoryId, searchName); // サブカテゴリーのAPI呼び出し
		},
		enabled: !!categoryId, // categoryIdがnullでない場合のみ実行
	});
};

export const useSearchAutocomplete = () => {
	const { control, watch, setValue } = useForm<FormValues>({
		defaultValues: {
			category: null,
			subCategory: null,
		},
	});

	// 親カテゴリーの監視
	const selectedCategory = watch("category");
	const categoryId = selectedCategory?.id || null;

	// 親カテゴリーの検索に基づくオプションを取得
	const { data: categoryOptions = [], isLoading: isCategoryLoading } =
		useCategoryOptions(selectedCategory?.name || "");

	// サブカテゴリー検索のための監視
	const subCategorySearchName = watch("subCategory")?.name || "";

	// サブカテゴリーのオプションを取得
	const { data: subCategoryOptions = [], isLoading: isSubCategoryLoading } =
		useSubCategoryOptions(categoryId, subCategorySearchName);

	return {
		control,
		categoryOptions,
		isCategoryLoading,
		subCategoryOptions,
		isSubCategoryLoading,
		setValue,
	};
};
```
:::note info
今回の肝は`useSearchAutocomplete`です。watchをうまく活用し、useStateを利用せずに実現しています。
:::



```ts:src/form/api.ts
import axios from "axios";
import type { Category } from "./type";

export const apiClient = axios.create({
	baseURL: "http://localhost:3000", // 共通のBaseURL
});

// APIからカテゴリリストを取得する関数
export const fetchCategories = async (
	searchName?: string,
): Promise<Category[]> => {
	const response = await apiClient.get("/api/category", {
		params: { searchName }, // searchNameクエリパラメータをAPIに送る
	});
	return response.data; // APIから取得したデータをそのまま返す
};

// APIからサブカテゴリリストを取得する関数
export const fetchSubCategories = async (
	categoryId: number,
	searchName?: string,
): Promise<Category[]> => {
	const response = await apiClient.get(`/api/category/${categoryId}`, {
		params: { searchName }, // searchNameクエリパラメータとして渡す
	});
	return response.data;
};
```

```ts:src/form/type.ts
export type Category = {
	id: number;
	key: string;
	name: string;
};

// FormValuesの型定義
export type FormValues = {
	category: Category | null; // 親カテゴリー
	subCategory: Category | null; // サブカテゴリー
};

```

## おわりに
ReactHookFormは多彩な機能があって良いなと感じました！！