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が自動的にトランジション状態を管理します。
'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を更新できます。コンポーネント設計の柔軟性が大幅に向上します。
'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値を条件分岐や早期リターンの後でも読み取れる柔軟性も持っています。
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でキャッシュを更新する完全なパターンを見てみましょう。
// 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と競合する場合がありますが、クライアントコンポーネントでの動的なメタデータ更新に特に有用です。
// 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は削除してください。