ReactHookFormとMUIのAutoCompleteの連動

## はじめに
表題通りです。

## 成果物
![react_hook_form_render.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/7328061d-914a-2f2b-d643-42aabf89589a.gif)

## ソースコード
```tsx:src/UpdateProduct.tsx
import type React from "react";
import { Suspense } from "react";
import { Button } from "@mui/material";
import { SearchAutocomplete } from "./form/Form";
import { useProductQuery, useSearchAutocomplete } from "./form/hooks";
import type { Product } from "./form/type";

const PRODUCT_ID = 1;

// 製品データ取得後に表示するコンポーネント
const UpdateProductComponent = ({ product }: { product: Product }) => {
	const {
		control,
		categoryOptions,
		isCategoryLoading,
		subCategoryOptions,
		isSubCategoryLoading,
		setValue,
		dirtyFields,
		onSubmit,
		onCancel,
	} = useSearchAutocomplete(product);

	return (
		<div>
			<h1>UpdateProduct</h1>
			<SearchAutocomplete
				control={control}
				categoryOptions={categoryOptions}
				isCategoryLoading={isCategoryLoading}
				subCategoryOptions={subCategoryOptions}
				isSubCategoryLoading={isSubCategoryLoading}
				setValue={setValue}
			/>
			{dirtyFields && (
				<div>
					{Object.keys(dirtyFields).length > 0
						? "以下のフィールドが変更されました:"
						: "変更はありません"}
					<ul>
						{Object.keys(dirtyFields).map((field) => (
							<li key={field}>{field}</li>
						))}
					</ul>
					<Button
						onClick={onSubmit}
						disabled={Object.keys(dirtyFields).length === 0}
						sx={{
							width: 300,
							backgroundColor:
								Object.keys(dirtyFields).length === 0 ? "gray" : "blue", // disabledの時は灰色
							color: "white",
							"&:hover": {
								backgroundColor:
									Object.keys(dirtyFields).length === 0 ? "gray" : "darkblue", // hover時の色も設定
							},
						}}
					>
						保存
					</Button>
					<Button onClick={onCancel}>キャンセル</Button>
				</div>
			)}
		</div>
	);
};

// Suspenseによるローディング処理のラップ
export const UpdateProduct = () => {
	return (
		<Suspense fallback={<div>Loading...</div>}>
			<ProductLoader />
		</Suspense>
	);
};

// 非同期に製品をロードしてから表示するコンポーネント
const ProductLoader: React.FC = () => {
	const { data: product, isLoading } = useProductQuery(PRODUCT_ID);

	if (isLoading || !product) {
		return <div>Loading...</div>; // ローディング状態の表示
	}

	return <UpdateProductComponent product={product} />;
};
```

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

type Props = {
	control: Control<FormValues, unknown>;
	categoryOptions: Category[];
	isCategoryLoading: boolean;
	subCategoryOptions: Category[];
	isSubCategoryLoading: boolean;
	setValue: UseFormSetValue<FormValues>;
};

// ユーザー検索のためのAutocompleteコンポーネント
export const SearchAutocomplete = ({
	control,
	categoryOptions,
	isCategoryLoading,
	subCategoryOptions,
	isSubCategoryLoading,
	setValue,
}: Props) => {
	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>
	);
};
```

```ts:src/form/hooks.ts
import { useQuery } from "@tanstack/react-query";
import { fetchCategories, fetchProduct, fetchSubCategories } from "./api";
import type { FormValues, Product } 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 useProductQuery = (productId: number) => {
	return useQuery({
		queryKey: ["product", productId], // 製品IDに基づいてクエリを実行
		queryFn: () => fetchProduct(productId), // 製品データの取得
	});
};

export const useSearchAutocomplete = (product: Product) => {
	const {
		control,
		watch,
		setValue,
		formState: { dirtyFields },
		reset,
	} = useForm<FormValues>({
		defaultValues: {
			category: product.category,
			subCategory: product.subCategory,
		},
	});

	// 親カテゴリーの監視
	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);

	const onSubmit = () => {
		console.log("onSubmit", dirtyFields);
	};

	const onCancel = () => {
		reset(); // フォームの値を初期値にリセット
	};

	return {
		control,
		categoryOptions,
		isCategoryLoading,
		subCategoryOptions,
		isSubCategoryLoading,
		setValue,
		dirtyFields,
		onSubmit,
		onCancel,
	};
};
```

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

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

// 製品の型
export type Product = {
	id: number;
	category: Category;
	subCategory: Category;
};

```