ReactReact 19TypeScriptNext.jsServer Actions

React 19の新機能を実プロジェクトで活用する完全ガイド

読了約 11

React 19は2024年末にリリースされ、フォーム処理、非同期状態管理、メタデータ管理など多くの領域で大きな変化をもたらしました。単なる機能追加ではなく、Reactアプリの設計パターンそのものを変える可能性を持つバージョンです。この記事では、実際のプロジェクトで使える新機能を具体的なコード例とともに解説します。

React 19の主要な変更点の概要

React 19の変更点は大きく5つのカテゴリに分けられます。Actionsによるフォーム処理の刷新、新しいHooksの追加(useActionState、useFormStatus、useOptimistic)、use() APIによるリソースの直接利用、Document Metadata APIの統合、そしてパフォーマンスの改善です。特にActionsとServer Actionsの連携は、Next.jsを使ったフルスタック開発のパターンを大きく変えます。

  • Actions: フォーム送信の新しいパターン(非同期トランジション)
  • useActionState: アクションの状態(ペンディング、エラー、成功)を管理する新Hook
  • useFormStatus: フォームの送信状態を子コンポーネントから取得できる新Hook
  • use(): PromiseやContextを直接コンポーネント内で扱える新API
  • Document Metadata: title、meta、linkタグをコンポーネント内に記述できる機能

Actions:フォーム送信の新パターン

React 19以前は、フォーム送信時のローディング状態、エラーハンドリング、成功後の処理をすべて手動で管理する必要がありました。React 19のActionsは、これらをエレガントに解決します。form要素のaction属性に非同期関数を渡すことで、Reactが自動的にトランジション状態を管理します。

typescript
'use client'

import { useActionState } from 'react'

interface FormState {
  error: string | null
  success: boolean
  data: { id: string } | null
}

// Server Action(別ファイルに定義)
// async function createPost(formData: FormData): Promise<FormState>

export function CreatePostForm({
  createPost,
}: {
  createPost: (formData: FormData) => Promise<FormState>
}) {
  const [state, formAction, isPending] = useActionState(createPost, {
    error: null,
    success: false,
    data: null,
  })

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">タイトル</label>
        <input
          id="title"
          name="title"
          type="text"
          required
          disabled={isPending}
        />
      </div>

      <div>
        <label htmlFor="content">内容</label>
        <textarea
          id="content"
          name="content"
          required
          disabled={isPending}
        />
      </div>

      {state.error && (
        <p role="alert" style={{ color: 'red' }}>
          {state.error}
        </p>
      )}

      {state.success && (
        <p role="status" style={{ color: 'green' }}>
          投稿が作成されました!
        </p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? '送信中...' : '投稿する'}
      </button>
    </form>
  )
}

useActionState・useFormStatusの使い方

useActionStateは前の例で示しましたが、useFormStatusはさらに強力です。フォームの送信状態を子コンポーネントから取得できるため、送信ボタンを別コンポーネントに切り出しつつ、フォームの状態に基づいてUIを更新できます。コンポーネント設計の柔軟性が大幅に向上します。

typescript
'use client'

import { useFormStatus } from 'react-dom'
import { useActionState } from 'react'

// フォームの状態に応じて変化するボタンコンポーネント
function SubmitButton() {
  const { pending, data, method, action } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      aria-busy={pending}
    >
      {pending ? (
        <>
          <span aria-hidden="true">⏳</span>
          <span>送信中...</span>
        </>
      ) : (
        '送信する'
      )}
    </button>
  )
}

// フォームコンポーネント:SubmitButtonは内部でuseFormStatusを使う
interface ContactState {
  message: string | null
  success: boolean
}

async function sendContact(
  prevState: ContactState,
  formData: FormData
): Promise<ContactState> {
  'use server'

  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  if (!name || !email || !message) {
    return { message: 'すべてのフィールドを入力してください', success: false }
  }

  try {
    // メール送信ロジック
    await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify({ name, email, message }),
      headers: { 'Content-Type': 'application/json' },
    })
    return { message: 'メッセージを送信しました!', success: true }
  } catch {
    return { message: '送信に失敗しました。再度お試しください。', success: false }
  }
}

export function ContactForm() {
  const [state, formAction] = useActionState(sendContact, {
    message: null,
    success: false,
  })

  return (
    <form action={formAction}>
      <input name="name" placeholder="お名前" required />
      <input name="email" type="email" placeholder="メールアドレス" required />
      <textarea name="message" placeholder="メッセージ" required />

      {state.message && (
        <p style={{ color: state.success ? 'green' : 'red' }}>
          {state.message}
        </p>
      )}

      <SubmitButton />
    </form>
  )
}

use() APIでPromiseをコンポーネント内で直接扱う

React 19のuse() APIは、SuspenseとError Boundaryと組み合わせることで、Promiseを直接コンポーネント内で解決できます。これにより、データフェッチのコードが劇的にシンプルになります。また、useContext()の代替として、Context値を条件分岐や早期リターンの後でも読み取れる柔軟性も持っています。

typescript
import { use, Suspense } from 'react'

interface Post {
  id: number
  title: string
  body: string
  userId: number
}

// データフェッチ関数(サーバーコンポーネントから呼び出す)
async function fetchPost(id: number): Promise<Post> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
  if (!response.ok) throw new Error('Failed to fetch post')
  return response.json() as Promise<Post>
}

// use()でPromiseを直接解決するコンポーネント
function PostContent({ postPromise }: { postPromise: Promise<Post> }) {
  // Suspenseの境界内でPromiseを解決
  const post = use(postPromise)

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  )
}

// 親コンポーネント:Promiseを作成してSuspenseで包む
export default function PostPage({ params }: { params: { id: string } }) {
  // Promiseを作成(awaitしない!)
  const postPromise = fetchPost(parseInt(params.id))

  return (
    <Suspense fallback={<div>記事を読み込み中...</div>}>
      <PostContent postPromise={postPromise} />
    </Suspense>
  )
}

// Contextをuse()で読み取る(条件分岐後でも可能)
import { createContext } from 'react'

const ThemeContext = createContext<'light' | 'dark'>('light')

function ThemeAwareButton({ children }: { children: React.ReactNode }) {
  const isVisible = Math.random() > 0.5  // 仮の条件

  if (!isVisible) return null

  // use()はuseContext()と違い、条件分岐の後でも使える
  const theme = use(ThemeContext)

  return (
    <button className={`btn btn-${theme}`}>
      {children}
    </button>
  )
}

Server Actionsとの連携

Next.jsとReact 19のServer Actionsを組み合わせることで、クライアントとサーバーの境界が透明になります。Server Actions内でデータベースを直接操作し、revalidatePathやrevalidateTagでキャッシュを更新する完全なパターンを見てみましょう。

typescript
// app/actions/todo.ts
'use server'

import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'

interface TodoActionState {
  error: string | null
  success: boolean
}

export async function createTodo(
  prevState: TodoActionState,
  formData: FormData
): Promise<TodoActionState> {
  const title = formData.get('title') as string

  if (!title || title.trim().length === 0) {
    return { error: 'タイトルを入力してください', success: false }
  }

  try {
    await db.todo.create({
      data: {
        title: title.trim(),
        completed: false,
      },
    })

    // Todoリストのキャッシュを無効化
    revalidatePath('/todos')

    return { error: null, success: true }
  } catch {
    return { error: 'Todoの作成に失敗しました', success: false }
  }
}

export async function toggleTodo(id: string): Promise<void> {
  const todo = await db.todo.findUnique({ where: { id } })
  if (!todo) return

  await db.todo.update({
    where: { id },
    data: { completed: !todo.completed },
  })

  revalidatePath('/todos')
}

// app/todos/TodoForm.tsx
'use client'

import { useActionState } from 'react'
import { createTodo } from '../actions/todo'

export function TodoForm() {
  const [state, formAction, isPending] = useActionState(createTodo, {
    error: null,
    success: false,
  })

  return (
    <form action={formAction} className="flex gap-2">
      <input
        name="title"
        placeholder="新しいTodoを入力..."
        disabled={isPending}
        className="flex-1 border rounded px-3 py-2"
      />
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-600 text-white px-4 py-2 rounded"
      >
        {isPending ? '追加中...' : '追加'}
      </button>
      {state.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}

新しいDocument Metadata API

React 19では、title、meta、linkタグをコンポーネント内に直接記述できるようになりました。これにより、SEOメタデータをコンポーネントのロジックと一緒に管理できます。Next.jsのMetadata APIと競合する場合がありますが、クライアントコンポーネントでの動的なメタデータ更新に特に有用です。

typescript
// React 19のDocument Metadata API
function BlogPost({ post }: { post: Post }) {
  return (
    <>
      {/* titleタグをコンポーネント内に直接記述 */}
      <title>{post.title} - NextAI Blog</title>

      {/* metaタグの記述 */}
      <meta name="description" content={post.description} />
      <meta property="og:title" content={post.title} />
      <meta property="og:description" content={post.description} />
      <meta property="og:type" content="article" />

      {/* stylesheetの読み込み(コンポーネント内から) */}
      <link rel="stylesheet" href="/styles/blog-post.css" />

      <article>
        <h1>{post.title}</h1>
        <p>{post.content}</p>
      </article>
    </>
  )
}

// Next.jsの場合はgenerateMetadataを使う方が推奨
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: `${post.title} - NextAI Blog`,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.publishedAt,
    },
  }
}

移行時の注意点

React 18からReact 19への移行は、Next.js 15を使っている場合は比較的スムーズです。ただしいくつかの変更点があります。

  • useReducerのシグネチャが変更:useReducer(reducer, initialState)の形式が推奨
  • ref prop:関数コンポーネントにforwardRefなしでrefを渡せるようになった
  • Context.Provider → Context:<Context>として使えるようになった
  • hydrateRoot / createRootの動作変更:厳格なハイドレーションチェック
  • ReactDOM.render の削除:createRootへの完全移行が必要

⚠️ 注意

React 19のuseActionStateはReact 18のuseFormState(react-domから)の後継です。移行時は'react'からuseActionStateをインポートし、react-domのuseFormStateは削除してください。

ReactReact 19TypeScriptNext.jsServer Actions

AI協働開発のご相談はこちら

この記事の内容を実際のプロジェクトに活用したい方、 開発のご依頼・ご質問はお気軽にどうぞ。

無料相談を申し込む