•21 min read

React 19 Actions実践ガイド: useActionState, useOptimistic & Server Actionの回復性

React 19 Actions実践ガイド: useActionState, useOptimistic & Server Actionの回復性

React 19では、主にActionsを通じて、データのミューテーションとサーバーとのインタラクションの処理方法にパラダイムシフトが導入されます。このガイドでは、Next.js 15 App Routerのコンテキスト内でuseActionState、useOptimistic、およびuseFormStatusを分析し、回復力のあるユーザー中心のフォームを構築する上でのそれらの適用方法を実演します。楽観的更新、エラー処理、多段階ワークフロー、および堅牢なスキーマ検証について説明します。

Audio Briefing
0:00 / 0:00

React Actions: 統一されたミューテーションモデル

React Actionsは、コンポーネントがサーバーサイドのデータミューテーションと対話するための標準化されたメカニズムを提供します。これらはネットワークリクエスト、再検証、および状態管理の複雑さを抽象化し、開発者がビジネスロジックに集中できるようにします。Actionsは<form>要素から直接呼び出すことも、startTransitionを介してプログラムで呼び出すこともできます。

コアとなるフック

  • useActionState: サーバーアクションの状態と戻り値を管理します。アクションの最後の結果と、それを呼び出すためのディスパッチ関数を提供します。重要なことに、保留中の状態を処理し、アクション完了時に再レンダリングを行います。
  • useOptimistic: サーバーアクションの成功を予測して、UIを即座に更新できるようにします。アクションが失敗した場合はロールバックでき、成功した場合は確定できる一時的な「楽観的」状態を提供します。
  • useFormStatus: 親の<form>送信の保留中の状態に関する情報を提供するクライアントサイドのフックです。ボタンの無効化やローディングインジケーターの表示に役立ちます。
Advertisement

アーキテクチャの概要: サーバーコンポーネントとアクション

Next.js 15 App Routerは、React Server Components (RSCs) とServer Actionsを活用して、クライアントとサーバーの境界を曖昧にします。Actionsは'use server'でマークされた関数で、サーバー上でのみ実行されます。これらはServer Components内で直接定義することも、別のファイルで定義することもできます。

Actionsによるデータフロー

  1. クライアントインタラクション: ユーザーがフォームを送信するか、アクションをトリガーします。
  2. アクション呼び出し: アクション関数が呼び出されます。
  3. サーバー実行: サーバーアクションが実行され、データベース操作を実行し、結果を返します。
  4. クライアント更新: Reactはアクションの結果に基づいてコンポーネントを再レンダリングし、データキャッシュを再検証する可能性があります。

本番環境グレードのフォームの実装

検証、楽観的更新、および多段階の進行を必要とするユーザープロファイル更新フォームを考えてみましょう。

Zodによるスキーマ定義

堅牢な検証は非常に重要です。クライアントサイドとサーバーサイドの両方のスキーマ定義にZodを使用します。

// lib/schemas.ts
import { z } from 'zod';

export const profileSchema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters.').max(50, 'Username cannot exceed 50 characters.'),
  email: z.string().email('Invalid email address.'),
  bio: z.string().max(200, 'Bio cannot exceed 200 characters.').optional(),
});

export const passwordSchema = z.object({
  currentPassword: z.string().min(8, 'Current password must be at least 8 characters.'),
  newPassword: z.string().min(8, 'New password must be at least 8 characters.'),
  confirmNewPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmNewPassword, {
  message: 'New passwords do not match.',
  path: ['confirmNewPassword'],
});

export type ProfileFormState = {
  message: string;
  errors?: {
    username?: string[];
    email?: string[];
    bio?: string[];
    currentPassword?: string[];
    newPassword?: string[];
    confirmNewPassword?: string[];
  };
  fieldValues?: {
    username?: string;
    email?: string;
    bio?: string;
  };
};

サーバーアクション

これらのアクションは、実際のデータミューテーションを処理します。

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { profileSchema, passwordSchema, ProfileFormState } from '@/lib/schemas';
import { z } from 'zod';

// Simulate a database
const usersDb = new Map<string, { username: string; email: string; bio?: string; passwordHash: string }>();
usersDb.set('user123', { username: 'user123', email: 'user@example.com', bio: 'Initial bio.', passwordHash: 'hashed_password_123' });

export async function updateProfile(
  prevState: ProfileFormState,
  formData: FormData
): Promise<ProfileFormState> {
  await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate network delay

  const rawFormData = {
    username: formData.get('username'),
    email: formData.get('email'),
    bio: formData.get('bio'),
  };

  const validatedFields = profileSchema.safeParse(rawFormData);

  if (!validatedFields.success) {
    const fieldErrors = validatedFields.error.flatten().fieldErrors;
    return {
      message: 'Validation failed.',
      errors: fieldErrors,
      fieldValues: rawFormData as any, // Return raw values for re-population
    };
  }

  const { username, email, bio } = validatedFields.data;

  // Simulate DB update
  const currentUser = usersDb.get('user123'); // In real app, get from session
  if (currentUser) {
    currentUser.username = username;
    currentUser.email = email;
    currentUser.bio = bio;
    usersDb.set('user123', currentUser);
  } else {
    return { message: 'User not found.', errors: {} };
  }

  revalidatePath('/profile'); // Invalidate cache for the profile page
  return { message: 'Profile updated successfully!', fieldValues: { username, email, bio } };
}

export async function changePassword(
  prevState: ProfileFormState,
  formData: FormData
): Promise<ProfileFormState> {
  await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate network delay

  const rawFormData = {
    currentPassword: formData.get('currentPassword'),
    newPassword: formData.get('newPassword'),
    confirmNewPassword: formData.get('confirmNewPassword'),
  };

  const validatedFields = passwordSchema.safeParse(rawFormData);

  if (!validatedFields.success) {
    const fieldErrors = validatedFields.error.flatten().fieldErrors;
    return {
      message: 'Password validation failed.',
      errors: fieldErrors,
    };
  }

  const { currentPassword, newPassword } = validatedFields.data;

  // Simulate current password check
  const currentUser = usersDb.get('user123');
  if (!currentUser || currentUser.passwordHash !== 'hashed_password_123') { // In real app, hash and compare
    return { message: 'Incorrect current password.', errors: { currentPassword: ['Incorrect password.'] } };
  }

  // Simulate password update
  currentUser.passwordHash = `hashed_${newPassword}`; // In real app, hash new password
  usersDb.set('user123', currentUser);

  revalidatePath('/profile/security');
  return { message: 'Password updated successfully!' };
}

クライアントコンポーネント: プロファイルフォーム

このコンポーネントはuseActionState、useOptimistic、およびuseFormStatusを使用します。

// app/profile/page.tsx
'use client';

import { useActionState, useOptimistic, useRef, useEffect, useState } from 'react';
import { useFormStatus } from 'react-dom';
import { updateProfile, changePassword } from '@/app/actions';
import { ProfileFormState } from '@/lib/schemas';

// Initial state for useActionState
const initialProfileState: ProfileFormState = {
  message: '',
  errors: {},
  fieldValues: { username: 'user123', email: 'user@example.com', bio: 'Initial bio.' },
};

const initialPasswordState: ProfileFormState = {
  message: '',
  errors: {},
};

function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" aria-disabled={pending} disabled={pending} className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50">
      {pending ? 'Saving...' : label}
    </button>
  );
}

export default function ProfileSettings() {
  const [profileState, profileAction] = useActionState(updateProfile, initialProfileState);
  const [passwordState, passwordAction] = useActionState(changePassword, initialPasswordState);

  // Optimistic state for profile updates
  const [optimisticProfile, addOptimisticProfile] = useOptimistic(
    profileState.fieldValues,
    (currentValues, newValues: Partial<typeof profileState.fieldValues>) => ({
      ...currentValues,
      ...newValues,
    })
  );

  const profileFormRef = useRef<HTMLFormElement>(null);
  const passwordFormRef = useRef<HTMLFormElement>(null);

  // Effect to reset form fields or show success messages
  useEffect(() => {
    if (profileState.message === 'Profile updated successfully!') {
      // Optionally clear form or show a toast
      console.log('Profile update success:', profileState.message);
    }
    if (passwordState.message === 'Password updated successfully!') {
      passwordFormRef.current?.reset(); // Clear password fields
      console.log('Password update success:', passwordState.message);
    }
  }, [profileState, passwordState]);

  const handleProfileSubmit = async (formData: FormData) => {
    // Optimistically update UI
    addOptimisticProfile({
      username: formData.get('username') as string,
      email: formData.get('email') as string,
      bio: formData.get('bio') as string,
    });
    await profileAction(formData);
  };

  const [currentStep, setCurrentStep] = useState(1);

  return (
    <div className="max-w-2xl mx-auto p-6 bg-white shadow-md rounded-lg">
      <h1 className="text-2xl font-bold mb-6">Profile Settings</h1>

      {/* Step 1: Profile Details */}
      {currentStep === 1 && (
        <section className="mb-8">
          <h2 className="text-xl font-semibold mb-4">Update Profile</h2>
          <form ref={profileFormRef} action={handleProfileSubmit} className="space-y-4">
            <div>
              <label htmlFor="username" className="block text-sm font-medium text-gray-700">Username</label>
              <input
                type="text"
                id="username"
                name="username"
                defaultValue={optimisticProfile?.username || ''}
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {profileState.errors?.username && (
                <p className="text-red-500 text-sm mt-1">{profileState.errors.username[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
              <input
                type="email"
                id="email"
                name="email"
                defaultValue={optimisticProfile?.email || ''}
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {profileState.errors?.email && (
                <p className="text-red-500 text-sm mt-1">{profileState.errors.email[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="bio" className="block text-sm font-medium text-gray-700">Bio</label>
              <textarea
                id="bio"
                name="bio"
                rows={3}
                defaultValue={optimisticProfile?.bio || ''}
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              ></textarea>
              {profileState.errors?.bio && (
                <p className="text-red-500 text-sm mt-1">{profileState.errors.bio[0]}</p>
              )}
            </div>
            {profileState.message && profileState.message !== 'Validation failed.' && (
              <p className={`text-sm ${profileState.message.includes('successfully') ? 'text-green-600' : 'text-red-600'}`}>
                {profileState.message}
              </p>
            )}
            <div className="flex justify-between items-center">
              <SubmitButton label="Save Profile" />
              <button
                type="button"
                onClick={() => setCurrentStep(2)}
                className="text-blue-600 hover:underline"
              >
                Next: Change Password &rarr;
              </button>
            </div>
          </form>
        </section>
      )}

      {/* Step 2: Change Password */}
      {currentStep === 2 && (
        <section>
          <h2 className="text-xl font-semibold mb-4">Change Password</h2>
          <form ref={passwordFormRef} action={passwordAction} className="space-y-4">
            <div>
              <label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700">Current Password</label>
              <input
                type="password"
                id="currentPassword"
                name="currentPassword"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {passwordState.errors?.currentPassword && (
                <p className="text-red-500 text-sm mt-1">{passwordState.errors.currentPassword[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="newPassword" className="block text-sm font-medium text-gray-700">New Password</label>
              <input
                type="password"
                id="newPassword"
                name="newPassword"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {passwordState.errors?.newPassword && (
                <p className="text-red-500 text-sm mt-1">{passwordState.errors.newPassword[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="confirmNewPassword" className="block text-sm font-medium text-gray-700">Confirm New Password</label>
              <input
                type="password"
                id="confirmNewPassword"
                name="confirmNewPassword"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {passwordState.errors?.confirmNewPassword && (
                <p className="text-red-500 text-sm mt-1">{passwordState.errors.confirmNewPassword[0]}</p>
              )}
            </div>
            {passwordState.message && passwordState.message !== 'Password validation failed.' && (
              <p className={`text-sm ${passwordState.message.includes('successfully') ? 'text-green-600' : 'text-red-600'}`}>
                {passwordState.message}
              </p>
            )}
            <div className="flex justify-between items-center">
              <button
                type="button"
                onClick={() => setCurrentStep(1)}
                className="text-blue-600 hover:underline"
              >
                &larr; Previous: Profile Details
              </button>
              <SubmitButton label="Change Password" />
            </div>
          </form>
        </section>
      )}
    </div>
  );
}

コンポーネントの説明

  • ProfileSettings: これはクライアントコンポーネントです ('use client')。
    • useActionState(updateProfile, initialProfileState): プロファイル更新アクションの状態を初期化します。profileStateは最後の結果(メッセージ、エラー、フィールド値)を保持し、profileActionはディスパッチ関数です。
    • useOptimistic(profileState.fieldValues, ...): optimisticProfileを作成します。addOptimisticProfileが呼び出されると、optimisticProfileが即座に更新されます。profileActionが失敗した場合、optimisticProfileはprofileState.fieldValuesに戻ります。成功した場合、profileState.fieldValuesが更新され、optimisticProfileは自然に新しい確定された状態を反映します。
    • useFormStatus(): フォーム送信中にボタンを無効にし、二重送信を防ぐためにSubmitButton内で使用されます。
    • useEffectフック: パスワード変更が成功した場合にパスワードフィールドをクリアし、メッセージをログに記録します。
    • 多段階フォーム: currentStep状態は、フォームのどのセクションが表示されているかを管理します。
    • エラー表示: profileState.errorsとpasswordState.errorsは、それぞれの入力フィールドの下にレンダリングされます。
    • defaultValue: 重要なことに、defaultValueは、ReactがuseOptimisticとuseActionStateが返すfieldValuesで入力状態を効果的に管理するために、入力に使用されます。

アーキテクチャの比較: Actions vs. 従来のAPI呼び出し

機能React Actions (Next.js App Router)従来のREST/GraphQL (クライアントサイドフェッチ)
データフロークライアントからサーバーへの直接関数呼び出し (RPCライク)。クライアントからAPIエンドポイントへのHTTPリクエスト (GET/POST/PUT/DELETE)。
バリデーションアクション内で直接サーバーサイドバリデーション (例: Zod)。APIハンドラーでのサーバーサイドバリデーション。クライアントサイドバリデーションはしばしば重複。
状態管理ミューテーション状態にuseActionState、useOptimistic、useFormStatus。ミューテーション状態にuseState、useReducer、React Query/SWR。
再検証自動キャッシュ無効化にrevalidatePath、revalidateTag。手動キャッシュ無効化 (例: queryClient.invalidateQueries)。
楽観的UI組み込みのuseOptimisticフック。useStateとロールバックロジックによる手動実装。
エラー処理エラーを含む状態オブジェクトをアクションが返す。fetchのcatchブロックまたはライブラリ固有のエラー処理。
ネットワーク層React/Next.jsによって抽象化される。明示的なfetchまたはAxios呼び出し。
バンドルサイズサーバーロジックが削除されるため、クライアントバンドルが小さくなる可能性。クライアントバンドルにはデータフェッチと状態管理ライブラリが含まれる。
複雑さ新しいメンタルモデルの初期学習曲線。馴染みのあるHTTPリクエストモデル。
Advertisement

本番環境での注意点とトラブルシューティング

  1. 「Actions must be functions」エラー:

    • 症状: TypeError: Actions must be functions. Received: [object Object]または類似のエラー。
    • 原因: アクションファイルまたは関数の先頭に'use server'を付け忘れている。または、<form>に非関数をアクションプロップとして渡そうとしている。
    • 修正: すべてのサーバーアクションが'use server'で正しくマークされていることを確認します。<form>のアクションプロップが実際に関数であることを確認します。
  2. revalidatePath / revalidateTagが機能しない:

    • 症状: revalidatePathを使用しても、成功したアクションの後にページのデータが更新されない。
    • 原因:
      • 再検証されるパスがNext.jsによってキャッシュされていない(例:キャッシュを許可する方法でアクセスされていない動的ルートであるか、クライアントサイドでデータをフェッチするクライアントコンポーネントである)。
      • アクションがクライアントコンポーネントから呼び出され、再検証は行われるが、クライアントコンポーネント自体がデータを再フェッチしない。
      • fetchをcache: 'force-cache'またはnext.tagsなしで使用している。
    • 修正:
      • 再検証を期待するデータが、適切なキャッシュヘッダーまたはnext.tagsを使用してServer ComponentまたはServer Actionでfetchを使用してフェッチされていることを確認します。
      • クライアントコンポーネントの場合、再検証を行うServer Componentからプロップを受け取っているか、アクション完了後にクライアントサイドの再フェッチをトリガーしていることを確認します(ただし、これはActionsの目的を損なうことが多いです)。
      • revalidatePathの引数が実際にレンダリングされているパスと一致していることを確認します。
  3. 楽観的更新がロールバックされない:

    • 症状: サーバーアクションが失敗した後も、UIが楽観的状態を表示する。
    • 原因: useActionStateが返すstate(useOptimisticがそのベースとして使用するもの)が、失敗を正しく反映していない。これは、サーバーアクションがエラー状態を返す代わりに、未処理のエラーをスローした場合によく発生します。
    • 修正: サーバーアクションが失敗した場合でも、常にProfileFormState(または同等の)オブジェクトを返すようにします。アクション内でエラーをキャッチし、適切なエラーメッセージとerrorsオブジェクトを返します。useOptimisticは、ロールバックするためにuseActionStateからのベース状態に依存しています。
  4. useFormStatusが更新されない:

    • 症状: 送信ボタンが誤って有効/無効のままになる。
    • 原因: useFormStatusは、<form>要素の直接の子孫であるコンポーネント、または<form>要素内でレンダリングされるコンポーネント内で使用する必要があります。コンテキストを壊す他のコンポーネントが間に挟まっている場合、ツリーの上位にあるフォームのステータスを読み取ることはできません。
    • 修正: useFormStatusを使用するコンポーネント(SubmitButtonなど)を<form>の内部に直接配置するか、その親が<form>の内部に直接配置されていることを確認します。
  5. 複雑なオブジェクトでのFormDataの問題:

    • 症状: FormDataを介して複雑なJavaScriptオブジェクト(例:ネストされた配列、オブジェクト)を直接渡すことができない。
    • 原因: FormDataは、主に文字列またはFileオブジェクトのような単純なキーと値のペア向けに設計されています。複雑な構造をフラット化します。
    • 修正: 複雑なデータの場合、JSONにシリアライズし、単一の文字列フィールドとしてFormDataに追加し、サーバーで解析することを検討してください。あるいは、<form>要素を使用しない場合は、サーバーアクションに直接引数を渡すことができます。
    // Client component
    import { myComplexAction } from './actions';
    
    async function handleSubmit(data: MyComplexDataType) {
      await myComplexAction(data); // Direct argument passing
    }
    
    // Server action
    'use server';
    import { MyComplexDataType } from '@/lib/types';
    
    export async function myComplexAction(data: MyComplexDataType) {
      // Process complex data directly
    }
    

よくある質問

1. useActionStateとサーバーアクションを直接呼び出すのは、いつ使い分けるべきですか?

useActionStateは、保留中のステータス、エラーメッセージ、成功した結果など、サーバーアクションによって返される状態を管理し、これらの状態がコンポーネントの再レンダリングをトリガーする必要がある場合に最適です。アクションのライフサイクルを処理するための構造化された方法を提供します。サーバーアクションを直接呼び出す(例:await myAction(formData))ことは、完全な状態管理機能が必要ない場合や、他の状態管理ソリューションと統合する場合など、より単純なシナリオに適しています。

2. React ActionsをReact Routerのようなクライアントサイドルーティングライブラリと一緒に使用できますか?

React Actionsは、ReactのレンダリングモデルとNext.jsのApp Routerと深く統合して、サーバーサイドのインタラクションとデータの再検証を行うように主に設計されています。技術的にはReact Routerアプリ内のコンポーネントからサーバーアクションを呼び出すことはできますが、revalidatePathとrevalidateTagの自動再検証の利点は失われ、データフェッチとキャッシュを手動で管理する必要があります。Next.js App Routerの場合、Actionsが慣用的なアプローチです。

3. アクションからのグローバルエラーや予期せぬサーバー障害はどのように処理すればよいですか?

サーバーアクションは、理想的には独自のエラーをキャッチし、構造化されたエラー状態を返す必要があります(ProfileFormStateで示されているように)。未処理の例外や重大なサーバー障害の場合、Next.jsは通常エラーページ(error.tsxまたはglobal-error.tsx)をレンダリングします。アクションの実行フェーズ中にエラーをキャッチするためにクライアントサイドのエラー境界を実装することもできますが、アクション自体は予測可能な状態を返すことを目指すべきです。ネットワークレベルの障害の場合、useActionStateは最終的にエラーで解決され、それを表示できます。

4. FormDataを介して機密データをサーバーアクションに渡すのは安全ですか?

はい、<form action={serverAction}>を使用する場合、FormDataはPOSTリクエストを介して送信されます。これは、接続がHTTPSである限り、サーバーへの他のPOSTリクエストと同様に安全です。データはURLに公開されません。送信方法に関係なく、常にサーバー上のすべての受信データを検証およびサニタイズしてください。

5. Actionsはバンドルサイズとパフォーマンスにどのように影響しますか?

サーバーアクション自体は、クライアントサイドのJavaScriptバンドルサイズには追加されません。そのコードはサーバー上でのみ実行されるためです。Reactは、サーバー関数を呼び出すための小さなクライアントサイドのスタブを自動的に生成します。これにより、APIクライアントライブラリや複雑な状態管理ロジックが含まれる可能性のある従来のクライアントサイドのデータフェッチと比較して、クライアントバンドルが小さくなる可能性があります。パフォーマンスは、クライアントサイドのJavaScriptの削減、ネットワークラウンドトリップの減少(データミューテーションと再検証がしばしばバッチ処理されるため)、およびサーバーからの直接データベースアクセスにより、一般的に向上します。

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement