✅ PR #10051 完了: Admin フォームの IME 問題は100%解決済み
全27ファイルが React Hook Form に移行完了し、以下の問題を解決:
React Hook Form + Unstated Container のハイブリッド構成
この構成により:
import { useForm } from 'react-hook-form';
type FormData = {
fieldName: string;
// ... 他のフィールド
};
const {
register,
handleSubmit,
reset,
} = useForm<FormData>();
重要: defaultValues は指定しない。useEffect で reset() を呼ぶため不要。
Container の state とフォームを同期するため、useEffect で reset() を使用:
useEffect(() => {
reset({
fieldName: container.state.fieldName || '',
// ... 他のフィールド
});
}, [container.state.fieldName, reset]);
const onSubmit = useCallback(async(data: FormData) => {
try {
// 重要: API 呼び出し前に setState の完了を待つ
await Promise.all([
container.changeField1(data.field1),
container.changeField2(data.field2),
]);
await container.updateHandler();
toastSuccess(t('updated_successfully'));
}
catch (err) {
toastError(err);
}
}, [container, t]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* フォームフィールド */}
</form>
);
問題: Unstated Container の setState は非同期処理です。change*() メソッドの後に await せずに API ハンドラーを即座に呼ぶと、API リクエストは古い/古びた値で送信されます。
❌ 間違い:
container.changeSiteUrl(data.siteUrl);
await container.updateHandler(); // 古い値が送信される!
✅ 正しい:
await container.changeSiteUrl(data.siteUrl);
await container.updateHandler(); // 新しい値が送信される
複数フィールドの場合は Promise.all() を使用:
await Promise.all([
container.changeTitle(data.title),
container.changeConfidential(data.confidential),
]);
await container.updateHandler();
問題: ラジオボタンは文字列の値を持ちますが、Container の state は boolean かもしれません。型が一致しないと、選択状態の復元ができません。
❌ 間違い:
// HTML: <input type="radio" value="true" />
reset({
isEmailPublished: true, // boolean - 文字列 "true" とマッチしない
});
✅ 正しい:
reset({
isEmailPublished: String(container.state.isEmailPublished ?? true),
});
チェックボックスは boolean 値を直接使えます(変換不要):
reset({
fileUpload: container.state.fileUpload ?? false,
});
削除したパターン: フォームの変更を watch() と useEffect でリアルタイムに Container に同期し戻すのは不要で、複雑さを増すだけです。
❌ これはやらない:
const watchedValues = watch();
useEffect(() => {
container.changeField(watchedValues.field);
}, [watchedValues]);
✅ submit 時だけ更新:
onSubmit で API ハンドラーを呼ぶ前に更新すればよい問題: disabled フィールドはフォーム送信データから除外されます。
フィールドを編集不可にしたいが、フォームデータには含めたい場合:
disabled の代わりに readOnly を使用useForm() の引数に defaultValues を渡さないこと。
理由:
useEffect で reset() を呼んでいるため、初期値はそちらで設定される他のファイルとパターンを統一
// ❌ 冗長
const { register, reset } = useForm({
defaultValues: { field: container.state.field }
});
useEffect(() => {
reset({ field: container.state.field });
}, [container.state.field]);
// ✅ シンプル
const { register, reset } = useForm();
useEffect(() => {
reset({ field: container.state.field });
}, [container.state.field]);
大規模なフォームは、複数の小さなコンポーネントに分割することを推奨します。
親コンポーネント(統合):
type FormData = {
sessionMaxAge: string;
// Container で管理される他のフィールドは不要
};
const Parent: React.FC<Props> = ({ container }) => {
const { register, handleSubmit, reset } = useForm<FormData>();
useEffect(() => {
reset({
sessionMaxAge: container.state.sessionMaxAge || '',
});
}, [reset, container.state.sessionMaxAge]);
const onSubmit = useCallback(async(data: FormData) => {
try {
// React Hook Form で管理されているフィールドのみ更新
await container.setSessionMaxAge(data.sessionMaxAge);
// 全ての設定を保存(Container 管理のフィールドも含む)
await container.updateGeneralSecuritySetting();
toastSuccess(t('updated'));
}
catch (err) {
toastError(err);
}
}, [container, t]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* React Hook Form 管理のフィールド */}
<SessionMaxAgeSettings register={register} t={t} />
{/* Container 直接管理のフィールド */}
<PageListDisplaySettings container={container} t={t} />
<PageAccessRightsSettings container={container} t={t} />
<button type="submit">{t('Update')}</button>
</form>
);
};
子コンポーネント(React Hook Form 管理):
type Props = {
register: UseFormRegister<{ sessionMaxAge: string }>;
t: (key: string) => string;
};
export const SessionMaxAgeSettings: React.FC<Props> = ({ register, t }) => {
return (
<input
className="form-control"
type="text"
{...register('sessionMaxAge')}
placeholder="2592000000"
/>
);
};
子コンポーネント(Container 直接管理):
type Props = {
container: AdminGeneralSecurityContainer;
t: (key: string) => string;
};
export const PageListDisplaySettings: React.FC<Props> = ({ container, t }) => {
return (
<select
className="form-control"
value={container.state.currentOwnerRestrictionDisplayMode}
onChange={(e) => container.changeOwnerRestrictionDisplayMode(e.target.value)}
>
<option value="Displayed">{t('Displayed')}</option>
<option value="Hidden">{t('Hidden')}</option>
</select>
);
};
複数のセクションを持つフォームでも、Submit ボタンは1つに統一:
onSubmit で処理updateHandler() で全て保存フォーム移行後に必ずテストすること:
全27ファイルを React Hook Form に移行完了:
Container の分析
API レイヤーの分離
update*Handler() メソッドを独立した API 関数に抽出apps/app/src/client/util/apiv3-client.ts パターンに従う段階的な Container の削除
// 理想的な最終形態
import { atom, useAtom } from 'jotai';
import { useForm } from 'react-hook-form';
// グローバル state
const sessionMaxAgeAtom = atom<string>('');
const SecuritySetting = () => {
const [sessionMaxAge, setSessionMaxAge] = useAtom(sessionMaxAgeAtom);
const { register, handleSubmit, reset } = useForm();
useEffect(() => {
reset({ sessionMaxAge });
}, [sessionMaxAge, reset]);
const onSubmit = async(data: FormData) => {
// 直接 API 呼び出し
await apiv3Put('/admin/security-settings', {
sessionMaxAge: data.sessionMaxAge,
// ... 他の設定
});
// Jotai state を更新
setSessionMaxAge(data.sessionMaxAge);
toastSuccess('Updated');
};
return <form onSubmit={handleSubmit(onSubmit)}>{/* ... */}</form>;
};
このガイドラインは以下の Admin フォームに適用可能:
apps/app/src/client/services/Admin*Container.js 配下の Container を使用しているフォーム/admin ルート配下のコンポーネントapps/app/src/client/services/Admin*Container.jsapps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx以下のファイルがベストプラクティスの参考になります:
apps/app/src/client/components/Admin/Security/SecuritySetting/apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsxapps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsxapps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx