React 19が正式リリースされ、Server Actions、新しいフック、フォーム処理の大幅な改善など、多くの新機能が追加されました。本記事では、React 19の主要な新機能を実践的なコード例とともに解説します。
React 19の主な新機能
概要
flowchart TB
subgraph React19["React 19"]
subgraph Hooks["新しいフック"]
H1["use() - Promise/Contextの読み取り"]
H2["useActionState() - フォームアクション状態"]
H3["useFormStatus() - フォーム送信状態"]
H4["useOptimistic() - 楽観的更新"]
end
subgraph Actions["Actions"]
A1["Server Actions - サーバーサイド関数"]
A2["Client Actions - クライアント非同期処理"]
A3["フォーム統合 - form action"]
end
subgraph Others["その他の改善"]
O1["ref as prop - forwardRef不要"]
O2["Document Metadata - title等の直接記述"]
O3["Stylesheet管理 - precedenceによる順序制御"]
O4["Resource Preloading - prefetch/preload API"]
end
end
use() フック
Promiseの読み取り
use()は、レンダリング中にPromiseやContextを読み取るための新しいフックです。
// use() - Promiseの読み取り
import { use, Suspense } from 'react';
// データフェッチ関数
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// コンポーネント
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// Suspenseと組み合わせて使用
const user = use(userPromise);
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// 親コンポーネント
function UserPage({ userId }: { userId: string }) {
// Promiseをpropsとして渡す
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
// 条件付きでuse()を呼び出せる(他のフックとの違い)
function ConditionalData({ shouldFetch, dataPromise }: {
shouldFetch: boolean;
dataPromise: Promise<Data>;
}) {
if (!shouldFetch) {
return <div>No data needed</div>;
}
// 条件分岐の後でも使用可能
const data = use(dataPromise);
return <div>{data.value}</div>;
}
Contextの読み取り
// use() - Contextの読み取り
import { use, createContext } from 'react';
const ThemeContext = createContext<'light' | 'dark'>('light');
function ThemedButton() {
// useContext()の代わりにuse()を使用可能
const theme = use(ThemeContext);
return (
<button className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
Click me
</button>
);
}
// 条件付きでContextを読み取り
function ConditionalTheme({ useTheme }: { useTheme: boolean }) {
if (!useTheme) {
return <button>Default Button</button>;
}
// 条件分岐後でも使用可能
const theme = use(ThemeContext);
return <button className={`theme-${theme}`}>Themed Button</button>;
}
Actions
Server Actions
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// Server Action
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// バリデーション
if (!title || title.length < 3) {
return { error: 'タイトルは3文字以上必要です' };
}
// データベースに保存
const post = await db.post.create({
data: { title, content },
});
// キャッシュの再検証
revalidatePath('/posts');
// リダイレクト
redirect(`/posts/${post.id}`);
}
// 更新アクション
export async function updatePost(id: string, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.update({
where: { id },
data: { title, content },
});
revalidatePath(`/posts/${id}`);
return { success: true };
}
// 削除アクション
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
redirect('/posts');
}
フォームとの統合
// app/posts/new/page.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '../actions';
export default function NewPostPage() {
// useActionState - フォームアクションの状態管理
const [state, formAction, isPending] = useActionState(
createPost,
{ error: null }
);
return (
<form action={formAction}>
<div>
<label htmlFor="title">タイトル</label>
<input
id="title"
name="title"
required
disabled={isPending}
/>
</div>
<div>
<label htmlFor="content">内容</label>
<textarea
id="content"
name="content"
disabled={isPending}
/>
</div>
{state?.error && (
<p className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '投稿中...' : '投稿する'}
</button>
</form>
);
}
useFormStatus
// フォーム送信状態の取得
import { useFormStatus } from 'react-dom';
function SubmitButton() {
// 親の<form>の送信状態を取得
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? (
<>
<Spinner />
送信中...
</>
) : (
'送信'
)}
</button>
);
}
// フォームで使用
function ContactForm() {
async function submitForm(formData: FormData) {
'use server';
// 送信処理
}
return (
<form action={submitForm}>
<input name="email" type="email" required />
<textarea name="message" required />
{/* SubmitButtonは親formの状態を自動で取得 */}
<SubmitButton />
</form>
);
}
useOptimistic - 楽観的更新
// 楽観的更新の実装
import { useOptimistic, startTransition } from 'react';
interface Message {
id: string;
text: string;
sending?: boolean;
}
function ChatMessages({ messages }: { messages: Message[] }) {
// 楽観的な状態管理
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage: Message) => [
...state,
{ ...newMessage, sending: true },
]
);
async function sendMessage(formData: FormData) {
const text = formData.get('message') as string;
const tempId = crypto.randomUUID();
// 楽観的に追加
startTransition(() => {
addOptimisticMessage({
id: tempId,
text,
sending: true,
});
});
// サーバーに送信
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify({ text }),
});
}
return (
<div>
<ul>
{optimisticMessages.map((message) => (
<li
key={message.id}
className={message.sending ? 'opacity-50' : ''}
>
{message.text}
{message.sending && <span> (送信中...)</span>}
</li>
))}
</ul>
<form action={sendMessage}>
<input name="message" required />
<button type="submit">送信</button>
</form>
</div>
);
}
いいねボタンの例
// 楽観的ないいねボタン
function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const [{ liked, count }, setOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state, newLiked: boolean) => ({
liked: newLiked,
count: state.count + (newLiked ? 1 : -1),
})
);
async function toggleLike() {
const newLiked = !liked;
// 楽観的に更新
startTransition(() => {
setOptimistic(newLiked);
});
// サーバーに送信
await fetch(`/api/posts/${postId}/like`, {
method: newLiked ? 'POST' : 'DELETE',
});
}
return (
<button onClick={toggleLike}>
{liked ? '❤️' : '🤍'} {count}
</button>
);
}
ref as prop
React 19では、forwardRefなしでrefをpropsとして受け取れるようになりました。
// React 18以前: forwardRefが必要
const InputOld = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
return <input ref={ref} {...props} />;
});
// React 19: refは通常のpropとして渡せる
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
// 使用例
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<form>
<Input ref={inputRef} placeholder="Enter text" />
<button
type="button"
onClick={() => inputRef.current?.focus()}
>
Focus
</button>
</form>
);
}
Document Metadata
コンポーネント内でメタデータを直接記述できるようになりました。
// メタデータの直接記述
function BlogPost({ post }: { post: Post }) {
return (
<article>
{/* ドキュメントのheadに自動的にホイストされる */}
<title>{post.title} - My Blog</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<link rel="canonical" href={`https://myblog.com/posts/${post.slug}`} />
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
// 複数ページでの使用
function ProductPage({ product }: { product: Product }) {
return (
<div>
<title>{product.name} | My Store</title>
<meta name="description" content={product.description} />
{/* 構造化データ */}
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
price: product.price,
})}
</script>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
Stylesheet管理
// スタイルシートの優先度管理
function ComponentWithStyles() {
return (
<>
{/* precedenceで読み込み順序を制御 */}
<link
rel="stylesheet"
href="/styles/base.css"
precedence="default"
/>
<link
rel="stylesheet"
href="/styles/components.css"
precedence="default"
/>
<link
rel="stylesheet"
href="/styles/utilities.css"
precedence="high"
/>
<div className="styled-component">
Content
</div>
</>
);
}
// 動的スタイルシート
function ThemeSwitcher({ theme }: { theme: 'light' | 'dark' }) {
return (
<>
<link
rel="stylesheet"
href={`/themes/${theme}.css`}
precedence="high"
/>
<div>Themed content</div>
</>
);
}
Resource Preloading
// リソースの事前読み込みAPI
import { prefetchDNS, preconnect, preload, preinit } from 'react-dom';
function ResourceHints() {
// DNS事前解決
prefetchDNS('https://api.example.com');
// 事前接続
preconnect('https://cdn.example.com');
// リソースの事前読み込み
preload('/fonts/custom.woff2', {
as: 'font',
type: 'font/woff2',
crossOrigin: 'anonymous',
});
// スクリプトの事前初期化
preinit('/scripts/analytics.js', {
as: 'script',
});
return <div>Content</div>;
}
// 画像の事前読み込み
function ImageGallery({ images }: { images: string[] }) {
// 次の画像を事前読み込み
useEffect(() => {
images.slice(1, 4).forEach((src) => {
preload(src, { as: 'image' });
});
}, [images]);
return (
<div>
{images.map((src) => (
<img key={src} src={src} alt="" />
))}
</div>
);
}
React Compiler (実験的)
// React Compilerによる自動メモ化
// Before: 手動でuseMemo/useCallbackが必要
function ProductListOld({ products, onSelect }: Props) {
const sortedProducts = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
const handleSelect = useCallback(
(id: string) => onSelect(id),
[onSelect]
);
return (
<ul>
{sortedProducts.map((product) => (
<ProductItem
key={product.id}
product={product}
onSelect={handleSelect}
/>
))}
</ul>
);
}
// After: React Compilerが自動でメモ化
function ProductList({ products, onSelect }: Props) {
// 手動のメモ化不要 - コンパイラが最適化
const sortedProducts = [...products].sort((a, b) => a.price - b.price);
return (
<ul>
{sortedProducts.map((product) => (
<ProductItem
key={product.id}
product={product}
onSelect={(id) => onSelect(id)}
/>
))}
</ul>
);
}
// babel.config.js でReact Compilerを有効化
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// オプション
}],
],
};
エラーハンドリングの改善
// 改善されたエラー表示
// hydrationエラーの詳細表示
// React 19では、差分が具体的に表示される
// エラーバウンダリの改善
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
state = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// React 19: より詳細なスタックトレース
console.error('Error:', error);
console.error('Component Stack:', info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// 使用例
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<MainContent />
</ErrorBoundary>
);
}
マイグレーションガイド
React 18 → 19 への移行
# パッケージの更新
npm install react@19 react-dom@19
# TypeScript型定義
npm install -D @types/react@19 @types/react-dom@19
// 主な変更点
// 1. forwardRef → 通常のprop
// Before
const Input = forwardRef<HTMLInputElement, Props>((props, ref) => (
<input ref={ref} {...props} />
));
// After
function Input({ ref, ...props }: Props & { ref?: Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
// 2. useContext → use (オプション)
// Before
const theme = useContext(ThemeContext);
// After (条件付きで使用する場合に便利)
const theme = use(ThemeContext);
// 3. 非推奨APIの削除
// - defaultProps (関数コンポーネント)
// - propTypes
// - createFactory
// - render (react-dom)
// defaultPropsの代替
// Before
function Button({ size = 'medium' }) { ... }
Button.defaultProps = { size: 'medium' };
// After (デフォルトパラメータを使用)
function Button({ size = 'medium' }: { size?: 'small' | 'medium' | 'large' }) {
// ...
}
まとめ
React 19は、開発者体験を大幅に向上させる多くの新機能を提供しています。
主要な新機能
| 機能 | 用途 |
|---|---|
| use() | Promise/Contextの読み取り |
| useActionState | フォームアクションの状態管理 |
| useFormStatus | 送信状態の取得 |
| useOptimistic | 楽観的更新 |
| Server Actions | サーバーサイド処理 |
| ref as prop | forwardRef不要 |
移行の推奨
- 新規プロジェクト: React 19を採用
- 既存プロジェクト: 段階的に移行
- Server Actions: Next.js 14+と組み合わせ
React 19により、より直感的で高速なReactアプリケーション開発が可能になります。