Zenn
🌊

React Tokyo トレンドレポート #4: Formの話がしたい!

2025/04/02に公開
3

こんにちは!React Tokyoサポートメンバーのふるしょうです。
React TokyoのDiscordサーバーでは、Reactに関する最新技術の動向から、日々の開発で直面する具体的な課題まで、メンバー間で活発な情報共有が日々交わされています。

React TokyoトレンドレポートはDiscordサーバー内の情報・質問小部屋で盛り上がったトピックを定期的にまとめて紹介するレポートです!
過去の3回の記事はこちら

https://zenn.dev/react_tokyo/articles/db90a5397364aa

https://zenn.dev/react_tokyo/articles/77375d84125b76

https://zenn.dev/react_tokyo/articles/f590adb2345c67

第4回目の今回は、3月に白熱した「Formの話がしたい!」というチャンネルでの具体的な議論に焦点を当てて共有・解説させていただきます!

Formの話がしたい!

Webアプリケーション開発において、フォームは避けて通れない要素ですが、その実装は複雑化しがちです。React Tokyoの Formの話がしたい! チャンネルでは、ライブラリの活用・独自実装から、Reactの進化に伴う設計思想の変化まで、多岐にわたる活発な議論が交わされました!

Formライブラリの活用と抽象化層を設ける設計パターン

チャンネル開設のきっかけの一つとなったのは、React Tokyo #2じょうげんさんによる発表「Formの複雑さに立ち向かう」でした。この発表内容を深掘りした記事 ConformでFormの複雑さに立ち向かう が共有されました。

続いて、React Hook Form(v7)を使ったコンポーネント設計案 も紹介されました。
https://zenn.dev/manalink_dev/articles/manalink-react-hook-form-v7

これらの記事で提案されているのは、UIコンポーネントとフォームライブラリ(RHFやConform)の間に抽象化レイヤーを設けるアプローチです。
これにより、フォーム実装の複雑な関心事を分離し、UIコンポーネントをライブラリの詳細から独立させることができます。
この設計パターンは、特に大規模なアプリケーションにおいて、コードの再利用性や保守性を高める上で有効な手段として評価されています!

useController を利用する例
// 参照記事: https://zenn.dev/manalink_dev/articles/manalink-react-hook-form-v7 
import {
  useForm,
  useController,
  UseControllerProps,
  FieldValues,
  FieldPath,
  Control,
} from 'react-hook-form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

type FormValues = { name: string };

// RHFの useControllerProps を継承し、独自のPropsを追加
type RhfInputProps<T extends FieldValues> = UseControllerProps<T> & {
  label: string;
};

const RhfInput = <T extends FieldValues>({
  name,
  control,
  label,
  ...rest
}: RhfInputProps<T>) => {
  const {
    field,
    fieldState: { error },
  } = useController({ name, control, ...rest });

  const inputId = `${name}-input`;

  return (
    <div className="space-y-2">
      <Label htmlFor={inputId}>{label}</Label>
      <Input id={inputId} {...field} aria-invalid={!!error} className={error ? "border-red-500" : ""} />
      {error && <p className="text-sm text-red-500">{error.message}</p>}
    </div>
  );
};

// フォーム本体
const MyFormWithAbstraction = () => {
  const { control, handleSubmit } = useForm<FormValues>({
    mode: 'onChange',
    defaultValues: { name: '' },
  });
  const onSubmit = (data: FormValues) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <RhfInput control={control} name="name" label="お名前" />
      <button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">送信</button>
    </form>
  );
};

ライブラリを使わない選択肢とその難しさ

一方で、ライブラリに依存しない独自実装のアプローチも議論されました。React Hook FormをやめてuseReducerを使用した話 という記事が共有され、その具体的な実装内容が紹介されました。独自実装はライブラリの制約を受けずに柔軟な設計が可能になる一方で、状態管理やレンダリング最適化、アクセシビリティ対応などを自前で行う必要があり、その開発コストや複雑さについての意見交換が行われました。特に、大規模なフォームや動的なフォームに実装が課題となりうる可能性が高く、再利用性と拡張性を考慮したメンテナンスについても気になります。

https://zenn.dev/makumattun/articles/a1a4477a1a5e6c

「ボトムアップ vs トップダウン」・「制御 vs 非制御」

次に、状態管理ライブラリ Jotai をベースとした jotai-form が話題に上がり、その設計思想や使用感について意見が交わされました。

  • フォーム全体の最適化への懸念: ボトムアップなアプローチ(個々のフィールドの状態からフォーム全体を構築していく方式)に対する懸念点が共有されました。フォーム全体の構造をスキーマなどでトップダウンに定義する方が見通しが良いと感じる点や、フィールド単位の状態管理が細かくなりすぎると、フォーム全体の最適化が難しくなる可能性があるという意見が出ました。
  • 制御コンポーネントへの疑問: また、jotai-form の公式サイトのサンプルが主に制御コンポーネント(Reactのstateで入力値を管理する方式)に基づいている点や、非制御コンポーネント(DOM自身が状態を持つ方式)での利用に関する情報が少ない点も議論の的となりました。プロジェクトによっては非制御コンポーネントを採用したいケースもあるため、ライブラリの設計思想としてどちらを主眼に置いているかが注目されました。
  • 望ましいアプローチの模索: こうした議論を経て、 (TanStack Form) のようにコンポーネントベースのAPIを提供しレンダリングを最適化するアプローチや、 (Valtio) のようにProxyオブジェクトを用いてアクセスされた状態のみを効率的に再レンダリングするアプローチなど、他のライブラリが採用する設計への期待感が高まっている点が印象的でした!

TanStack Form への期待と制御コンポーネントの再評価

制御コンポーネントを採用する場合の選択肢として TanStack Form が有力視されました。

  • TanStack Form の利点: TanStack Form は、フォームの各フィールドをコンポーネントとして提供する点が特徴です。これにより、フォーム全体ではなく、変更があったフィールドのみを効率的に再レンダリングすることが容易になります。また、useForm フックから返されるメソッドチェーンを通じて多くのAPIが提供されており、React Hook FormController コンポーネントのような追加の抽象化を必要とせずに直感的に利用できる点が、学習コストの低さにつながるのではないかと評価されました。
  • V1リリースと検証: ちょうど TanStack Form v1.0.0 がリリース されたタイミングでもあり、その新機能や安定性に期待が寄せられ、実際に検証目的で試してみるメンバーも現れました!
TanStack Form のコンポーネントベースAPI例
import { useForm, FieldApi } from '@tanstack/react-form';
import { z } from 'zod';


type FormValues = { name: string };

const formSchema = z.object({
  name: z.string().min(1, '名前を入力してください'),
});

const MyTanStackForm = () => {
  const form = useForm<FormValues>({ 
    defaultValues: { name: '' },
    onSubmit: async ({ value }) => {
      console.log('Submit data:', value);
      await new Promise((resolve) => setTimeout(resolve, 500));
    },
    validators: {
      onChange: formSchema,
    },
  });

  return (
    <form.Provider>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          e.stopPropagation();
          void form.handleSubmit(); // フォームのサブミットを実行 (内部でバリデーションが走る)
        }}
        className="space-y-4"
      >
        <form.Field
          name="name"
          children={(field) => (
            <div className="space-y-2">
              <label htmlFor={field.name} className="block text-sm font-medium">名前:</label>
              <input
                id={field.name}
                name={field.name}
                value={field.state.value} 
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
                aria-invalid={field.state.meta.errors.length > 0}
                className={`w-full px-3 py-2 border rounded-md ${field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-gray-300'}`}
              />
              {field.state.meta.errors ? (
                <div role="alert" className="text-sm text-red-500">
                  {field.state.meta.errors.join(', ')}
                </div>
              ) : null}
            </div>
          )}
        />
        <form.Subscribe
          // selectorで購読する状態を選択
          selector={(state) => [state.canSubmit, state.isSubmitting]}
          // children propsに関数を渡し、購読した状態を受け取る
          children={([canSubmit, isSubmitting]) => (
            <button 
              type="submit" 
              disabled={!canSubmit}
              className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {isSubmitting ? '送信中...' : '送信'}
            </button>
          )}
        />
      </form>
    </form.Provider>
  );
};

React 19 の登場と非制御コンポーネントの可能性

議論は、React 19の登場がフォーム実装のトレンドに与える影響へと移っていきました。特に、React 19の新機能によって非制御コンポーネントがより扱いやすくなるのではないか、という期待感が示されました。

  • React 19の新機能と非制御の推進: React 19 Betaのブログ記事 や公式ドキュメントを参照しつつ、<form>action プロパティに関数を渡せる機能や、useActionState, useFormStatus といった新しいフックが、非制御コンポーネントの利用を後押しする可能性について意見が交わされました。これにより、FormData を活用したサーバーアクションとの連携などが、よりシンプルに記述できるようになると期待されています。

https://react.dev/blog/2024/12/05/react-19

React 18 以前の基本的な制御コンポーネント例
import { useState, FormEvent } from 'react';

function ControlledForm() {
  const [name, setName] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState<string | null>(null);

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setIsLoading(true);
    setMessage(null);
    try {
      await new Promise(resolve => setTimeout(resolve, 1000));
      console.log('制御:', name);
      setMessage(`ようこそ、${name}さん!`);
    } catch (error) {
      setMessage('エラーが発生しました。');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div className="space-y-2">
        <label htmlFor="name-input" className="block text-sm font-medium">名前:</label>
        <input
          id="name-input"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          disabled={isLoading}
          className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:opacity-50"
        />
      </div>
      <button 
        type="submit" 
        disabled={isLoading}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {isLoading ? '送信中...' : '送信 (制御)'}
      </button>
      {message && <p className="mt-2 text-sm">{message}</p>}
    </form>
  );
}
React 19 の action を使った非制御コンポーネント例
import { useFormState, useFormStatus } from 'react-dom';

async function submitAction(
  previousState: { message: string | null; error: string | null } | null, 
  formData: FormData
) {
  const name = formData.get('name') as string;
  
  if (!name || name.trim() === '') {
    return { 
      message: null, 
      error: '名前を入力してください' 
    };
  }

  try {
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    return { 
      message: `ようこそ、${name}さん!`, 
      error: null 
    };
  } catch (error) {
    return { 
      message: null, 
      error: 'エラーが発生しました。' 
    };
  }
}

function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button 
      type="submit" 
      disabled={pending}
      className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {pending ? '送信中...' : '送信 (非制御)'}
    </button>
  );
}

function UncontrolledForm() {
  const [state, formAction] = useFormState(
    submitAction, 
    { message: null, error: null }
  );

  return (
    <form action={formAction} className="space-y-4">
      <div className="space-y-2">
        <label htmlFor="name-input" className="block text-sm font-medium">名前:</label>
        <input
          id="name-input"
          type="text"
          name="name"
          className="w-full px-3 py-2 border border-gray-300 rounded-md"
        />
      </div>
      <SubmitButton />
      {state.error && <p className="text-sm text-red-500">{state.error}</p>}
      {state.message && <p className="text-sm text-green-500">{state.message}</p>}
    </form>
  );
}
Server Actionを使用した例
async function serverAction(formData: FormData) {
  'use server';
  
  const name = formData.get('name') as string;
  
  if (!name || name.trim() === '') {
    return { success: false, message: '名前を入力してください' };
  }
  
  // 実際のデータベース処理などを行う
  await new Promise(resolve => setTimeout(resolve, 500));
  
  return { success: true, message: `${name}さん、データを保存しました!` };
}

// サーバーアクションを使用した非制御フォーム
function ServerActionForm() {
  const [state, formAction] = useFormState(serverAction, { success: false, message: null });
  
  return (
    <form action={formAction} className="space-y-4">
      <div className="space-y-2">
        <label htmlFor="server-name" className="block text-sm font-medium">名前:</label>
        <input 
          id="server-name" 
          type="text" 
          name="name" 
          className="w-full px-3 py-2 border border-gray-300 rounded-md"
        />
      </div>
      <SubmitButton />
      {state.message && (
        <p className={`text-sm ${state.success ? 'text-green-500' : 'text-red-500'}`}>
          {state.message}
        </p>
      )}
    </form>
  );
}

汎用的な状態管理ライブラリの活用

最後に、フォームの状態管理を、フォーム専用ライブラリに限定せず、Jotai のような状態管理ライブラリで行うという事例(複雑なフォームと複雑な状態管理にどう向き合うか)について共有されました。

この資料では、アプリケーションにおける「UI」と「コアロジック」のどちらがビジネス上の主要な価値を持つかによって、技術選択が変わる可能性が示唆されています。
UIのインタラクションや体験が重要であればフォームライブラリの選定が鍵となりますが、複雑なビジネスロジックやデータ処理が中心であれば、汎用的な状態管理ライブラリ (Jotai, Zustand, XState など) でフォームを含むアプリケーション全体の状態を一元管理する方が、見通しや保守性の観点から合理的である場合があります。
この視点は、単なるライブラリ比較に留まらず、プロジェクト全体のアーキテクチャやビジネス価値から技術選定を行うことの重要性を示しており、議論に深みを与えました。
筆者も現職では、複雑なビジネスロジックや、更新する関数内で別の状態を参照して実現する必要のある複雑なフォームにはZustandを使用しており、この記事に大変共感しました!

まとめ

React Tokyoの Formの話がしたい! チャンネルでは、単なるライブラリの比較に留まらず、その背景にある設計思想、React 19の新機能、依存関係の管理、そしてプロジェクト全体のコンテキストまで考慮した、多角的で深い議論が展開されました。
具体的な記事やライブラリ、最新のReactの動向に言及しながら、それぞれのメリット・デメリット、トレードオフを実践者の視点から活発に意見交換する様子は、React Tokyoならではの価値だなと改めて感じました!

終わりに

今回ご紹介した議論に興味を持たれた方、Reactについてもっと深く語り合いたい方は、ぜひReact TokyoのDiscordサーバーへお越しください!初心者の方からエキスパートの方、エンジニア以外の職種の方までどなたでも大歓迎です!

React Tokyoへの参加はこちら!

https://discord.com/invite/5B9jYpABUy

https://react-tokyo.vercel.app/

GitHubで編集を提案
3
React Tokyo

Discussion

ログインするとコメントできます