React 19正式リリース - Actions、use()、新しいフックの全貌

2025.12.02

React 19.0 TypeScript 5.5+
公式ドキュメント

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 propforwardRef不要

移行の推奨

  • 新規プロジェクト: React 19を採用
  • 既存プロジェクト: 段階的に移行
  • Server Actions: Next.js 14+と組み合わせ

React 19により、より直感的で高速なReactアプリケーション開発が可能になります。

参考リンク

← 一覧に戻る