목차
28 장

React 19 신규 기능 정리

Actions · useActionState · useFormStatus · useOptimistic · use() · React Compiler · ref as prop. 22~27장에 흩어져 등장한 React 19 신기능을 한곳에 묶어 정리합니다.

27장까지로 4부의 본 내용이 마무리됐습니다. 4부에서 우리는 Server Components와 App Router라는 큰 그림을 잡고 그 위에 데이터 페칭, Suspense, Server Actions를 차례로 얹었습니다. 그 과정에서 React 19에서 새로 안정화된 기능들이 곳곳에 등장했는데, 본 챕터에서 한곳에 모아 카탈로그로 정리합니다.

이 챕터는 두 가지 독자를 동시에 대상으로 합니다.

  • 4부를 차근차근 따라온 독자에게는 흩어진 조각을 한 그림으로 묶어 주는 복습 챕터.
  • React 18까지는 익숙한데 19부터는 잘 모르는 독자에게는 이 책을 처음부터 읽을 시간이 없을 때 18→19 변화를 한 번에 따라잡는 진입점.

후자를 위해 본 챕터는 단독으로 읽어도 흐름이 이어지도록 쓰겠습니다. 각 절은 “어떤 문제를 풀려고 했는가” → “API 모양” → “언제 안 써도 되는가” 순서로 진행하겠습니다.

4부의 발자취 — React 19 기능이 등장한 지점 #

챕터등장한 React 19 기능
25장 데이터 페칭과 캐싱(간접) Server Component의 async 함수 모델
26장 Suspense와 use()use() 훅, Promise를 prop으로 넘기는 패턴
27장 Server Actions와 폼Actions API, useActionState, useFormStatus, useOptimistic
전 영역ref as prop, React Compiler, Document Metadata

위 카탈로그를 절별로 다시 한 번 짚어 봅니다.

1. Actions API — 폼과 mutation의 새 표준 #

무엇을 풀려고 했는가 #

React 18까지 폼 mutation은 9장에서 만들었던 패턴이 표준이었습니다.

React 18 시대의 폼 mutation
'use client';

import { useState, type FormEvent } from 'react';

function Form() {
  const [text, setText] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setSubmitting(true);
    try {
      const res = await fetch('/api/items', { method: 'POST', body: JSON.stringify({ text }) });
      if (!res.ok) throw new Error('실패');
      setText('');
    } catch (err) {
      setError(err instanceof Error ? err.message : '에러');
    } finally {
      setSubmitting(false);
    }
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

매번 같은 보일러플레이트가 반복됐습니다. submitting state, error state, 결과 state, 그리고 이들을 묶는 try/catch/finally.

React 19의 Actions API는 이 패턴을 표준화합니다. “비동기 함수를 폼에 직접 연결하면 pending과 결과를 React가 알아서 추적한다"는 모델입니다.

API 모양 #

Action은 인자를 받아 Promise를 반환하는 비동기 함수입니다.

Action 함수의 시그니처
async function action(prevState: State, payload: FormData): Promise<State> {
  // 검증, mutation, 결과 반환
}

여기에서 핵심은 두 가지.

  1. 첫 인자가 이전 state. useActionState가 이 슬롯에 직전 호출의 반환값을 자동으로 끼워 넣습니다.
  2. 반환값이 다음 호출의 prevState가 됩니다. state를 자체적으로 관리하는 셈입니다.

Next.js의 'use server' 디렉티브가 붙으면 Server Action이 되고, 클라이언트에서 호출하면 자동으로 서버에서 실행됩니다 (27장에서 다룬 RPC 메커니즘).

<form action={fn}> — 브라우저 네이티브와의 만남 #

React 19는 <form>action prop이 함수도 받도록 확장했습니다.

action prop에 함수 직접 연결
<form action={myAction}>
  <input name="text" />
  <button>제출</button>
</form>

브라우저의 표준 폼 제출이 일어나면 React가 그것을 가로채 myAction(formData)을 호출합니다. 자바스크립트가 비활성화되어 있어도 폼은 평범한 POST 요청으로 동작합니다 (Server Action일 경우). 27장에서 본 progressive enhancement의 기반이 바로 이 모델입니다.

언제 안 써도 되는가 #

폼이 아니라 단순한 mutation(예: 좋아요 버튼 클릭)이라면 Action API를 강제로 끼울 필요는 없습니다. onClick 핸들러 안에서 fetch나 Server Action을 직접 호출하면 됩니다. Actions의 가치는 폼 데이터 + pending + 결과 state가 함께 따라올 때 빛납니다.

2. useActionState — 폼 상태를 하나의 hook에 묶기 #

무엇을 풀려고 했는가 #

위 Actions API만 있으면 폼 제출 자체는 되지만, 결과 메시지를 화면에 표시하려면 별도 state가 필요합니다.

결과 표시 — 어수선한 버전
const [result, setResult] = useState<{ message: string } | null>(null);

async function action(formData: FormData) {
  const res = await myAction(formData);
  setResult(res);
}

useActionState는 위 패턴을 한 hook에 묶습니다.

API 모양 #

useActionState 사용
const [state, formAction, isPending] = useActionState(
  async (prevState, formData) => {
    // ... 검증 및 mutation
    return { message: '완료', success: true };
  },
  { message: '', success: false },  // 초기 state
);

return (
  <form action={formAction}>
    {state.message && <p>{state.message}</p>}
    <input name="text" />
    <button disabled={isPending}>제출</button>
  </form>
);

반환값 세 개.

  • state: Action의 마지막 반환값 (또는 초기 state)
  • formAction: <form action={...}>에 그대로 넘길 래핑된 함수
  • isPending: 현재 제출 중인지 boolean

검증 에러를 화면에 표시 #

서버에서 검증한 결과를 그대로 화면에 흘려 보낼 수 있습니다.

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

type State = { error?: string; success?: boolean };

export async function createPost(prevState: State, formData: FormData): Promise<State> {
  const title = (formData.get('title') ?? '').toString().trim();
  if (!title) return { error: '제목을 입력해 주세요' };
  if (title.length > 100) return { error: '제목은 100자 이내로' };

  await db.posts.insert({ title });
  return { success: true };
}

검증을 서버에서 하니 클라이언트에서 우회할 수 없고, 결과는 hook이 자동으로 화면에 반영합니다.

언제 안 써도 되는가 #

폼 제출의 결과를 화면에 표시할 필요가 없거나 (예: 단순 GET 폼), useFormStatus만으로 충분한 자식 컴포넌트 단위 표시면 useActionState까지는 필요하지 않습니다.

3. useFormStatus — 자식 컴포넌트에서 pending 받기 #

무엇을 풀려고 했는가 #

useActionStateisPending은 같은 컴포넌트에서만 쓸 수 있습니다. 그런데 SubmitButton 같은 재사용 가능한 컴포넌트가 부모 폼의 pending을 알아야 한다면, prop으로 일일이 내려야 했습니다.

useFormStatus가장 가까운 부모 <form>의 상태를 자동으로 구독합니다.

API 모양 #

src/app/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export default function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending, data, method, action } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '제출 중...' : children}
    </button>
  );
}

반환값 중 가장 자주 쓰이는 건 pending. 나머지(data, method, action)는 디버깅이나 진행률 표시 같은 특수 케이스에 씁니다.

주의 — react-dom에서 import #

useFormStatusreact가 아니라 react-dom에서 옵니다. 이는 hook이 DOM의 <form> 요소에 묶여 있기 때문입니다.

언제 안 써도 되는가 #

폼 안에 SubmitButton이 한 개이고 같은 컴포넌트에서 정의된다면 useActionStateisPending만으로 충분합니다. 별도의 hook이 필요한 경우는 자식 컴포넌트가 부모 폼의 상태를 알아야 할 때입니다.

4. useOptimistic — 낙관적 업데이트 #

무엇을 풀려고 했는가 #

mutation의 응답이 도착할 때까지 UI가 “이전 상태"인 채로 머무르면 체감 속도가 떨어집니다. 트위터의 좋아요 버튼이 클릭 즉시 빨갛게 변하는 식의 UX를 만들려면 응답을 기다리지 않고 미리 화면을 갱신해야 합니다.

전통적으로 이건 손으로 state를 따로 관리해야 했고, 실패 시 롤백 로직도 직접 짜야 했습니다. useOptimistic이 이 패턴을 표준화합니다.

API 모양 #

useOptimistic 사용
'use client';

import { useOptimistic } from 'react';

type Item = { id: string; text: string };

function ItemList({ items }: { items: Item[] }) {
  const [optimisticItems, addOptimistic] = useOptimistic<Item[], Item>(
    items,
    (state, newItem) => [...state, newItem],
  );

  async function handleAdd(formData: FormData) {
    const text = (formData.get('text') ?? '').toString();
    const tempItem: Item = { id: 'temp-' + crypto.randomUUID(), text };
    addOptimistic(tempItem);
    await addItemAction(formData);
  }

  return (
    <>
      <ul>
        {optimisticItems.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
      <form action={handleAdd}>...</form>
    </>
  );
}

useOptimistic(state, reducer) 시그니처. reducer는 (현재 state, 입력) => 새 state. 클릭 즉시 reducer가 호출돼 화면이 갱신되고, 서버 응답으로 진짜 state가 갱신되면 React가 두 트랙을 자동으로 동기화합니다.

실패 시 자동 롤백 #

낙관적으로 그렸던 UI는 mutation이 끝나면 실제 props로 대체됩니다. 실패해서 props가 갱신되지 않으면 자동으로 이전 상태로 되돌아갑니다. 즉 롤백 로직을 직접 쓸 필요가 없습니다.

언제 안 써도 되는가 #

  • 응답이 빠른 mutation (50ms 미만): 굳이 낙관 업데이트 안 해도 사용자가 느끼지 못합니다.
  • 결과가 입력과 다르게 보일 수 있는 mutation (예: 서버에서 ID나 timestamp를 부여): 낙관 표시와 실제 표시가 어긋나 위화감을 줍니다.
  • 일관성이 중요한 데이터 (예: 결제, 잔액): 낙관적으로 보였다가 실패 시 롤백되는 게 더 큰 혼란입니다.

5. use() — Promise/Context를 푸는 새 hook #

무엇을 풀려고 했는가 #

Server Component에서 await이 가능하다는 모델은 강력하지만, Client Component에서도 Promise를 풀고 싶을 때가 있습니다. 26장에서 본 “Server에서 만든 Promise를 Client로 넘기기” 패턴이 그 예시입니다.

기존 hook 규칙은 “조건문이나 반복문 안에서 hook을 호출하면 안 된다"였습니다. useContext도 마찬가지였습니다. use()는 이 두 가지를 모두 푼 새 hook입니다.

API 모양 — Promise #

src/app/ItemList.tsx (Client)
'use client';

import { use } from 'react';

type Item = { id: string; text: string };

export default function ItemList({ itemsPromise }: { itemsPromise: Promise<Item[]> }) {
  const items = use(itemsPromise);
  return <ul>{items.map(i => <li key={i.id}>{i.text}</li>)}</ul>;
}

use(promise)는 Promise가 도착할 때까지 Suspense fallback을 보여 주고, 도착하면 그 값을 반환합니다. 호출 컴포넌트의 위쪽에 <Suspense>가 있어야 합니다.

API 모양 — Context #

조건부 Context 사용
'use client';

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

function Card({ showTheme }: { showTheme: boolean }) {
  if (showTheme) {
    const theme = use(ThemeContext);
    return <div className={theme}>...</div>;
  }
  return <div>...</div>;
}

useContext였다면 컴포넌트 최상위에서만 호출 가능했지만, use조건문 안에서도 호출 가능합니다.

언제 안 써도 되는가 #

  • Server Component에서는 그냥 await이 더 단순합니다. use(promise)는 주로 Client Component가 Promise를 prop으로 받았을 때 의미가 있습니다.
  • Context를 항상 읽는다면 기존 useContext로 충분합니다. use의 가치는 조건부 호출이 필요할 때입니다.

6. React Compiler — 자동 memoization #

무엇을 풀려고 했는가 #

14장에서 본 memo / useMemo / useCallback은 강력하지만, 언제 어디에 두어야 할지 결정하는 비용이 컸습니다. 그리고 의존성 배열을 잘못 적으면 stale closure 버그가 났습니다.

React Compiler(React 19 시점에 RC 단계)는 빌드 시점에 코드를 분석해 자동으로 memoization을 적용합니다. 손으로 useMemo/useCallback을 쓰는 부담이 크게 줄어듭니다.

도입 #

React Compiler 설치
pnpm add -D babel-plugin-react-compiler

Next.js 15에서는 next.config.ts에 옵션을 켜 줍니다.

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

export default nextConfig;

ESLint 플러그인 (eslint-plugin-react-hooks의 React Compiler 규칙)도 함께 켜면 Compiler가 분석에 실패한 지점을 미리 알려 줍니다.

Compiler가 있어도 손으로 해야 하는 경계 #

자동 memoization이 만능은 아닙니다. 다음 경우는 여전히 손으로 해야 합니다.

  • 참조 동일성을 외부에 노출하는 코드 — 라이브러리에 콜백을 넘길 때 그 라이브러리가 동일 콜백을 가정하면 손으로 useCallback.
  • 명시적인 의존성 추적이 필요한 코드useEffect의 의존성 배열은 그대로 손으로.
  • Compiler가 분석을 포기하는 경우 — 동적 indexing이나 매우 동적인 코드. ESLint 플러그인이 경고로 알려 줍니다.

언제 안 써도 되는가 #

작은 프로젝트나 성능이 병목이 아닌 경우엔 Compiler를 켜지 않아도 됩니다. 다만 14장의 기본 원리(“재렌더링이 비용을 만든다”) 자체는 그대로 유효하니, Compiler 유무와 무관하게 익혀 두면 좋습니다.

7. ref as prop — forwardRef의 종말 #

무엇을 풀려고 했는가 #

React 18까지 자식 컴포넌트에 ref를 넘기려면 forwardRef로 감싸야 했습니다.

React 18까지의 forwardRef
import { forwardRef } from 'react';

const FancyButton = forwardRef<HTMLButtonElement, { label: string }>(
  function FancyButton(props, ref) {
    return <button ref={ref}>{props.label}</button>;
  },
);

타입을 두 번 적어야 하고, 함수 시그니처도 일반 함수와 달라 학습 부담이 있었습니다.

React 19부터는 ref를 일반 prop처럼 받을 수 있습니다.

API 모양 #

React 19 — ref as prop
type Props = {
  label: string;
  ref?: React.Ref<HTMLButtonElement>;
};

function FancyButton({ label, ref }: Props) {
  return <button ref={ref}>{label}</button>;
}

forwardRef가 사라지고, ref는 그냥 다른 prop과 똑같이 다룹니다. 20장 (Context와 제네릭 컴포넌트)에서 만든 제네릭 컴포넌트에 ref를 다는 경우도 훨씬 단순해집니다.

마이그레이션 #

기존 forwardRef 코드는 한동안 그대로 동작합니다 (deprecated 경고가 뜨다가 후속 메이저에서 제거 예정). 새 코드는 ref as prop으로, 기존 코드는 시간 있을 때 마이그레이션 — 정도가 안전한 흐름입니다.

8. 기타 변경 — 작지만 알아 둘 만한 #

Document Metadata #

<title>, <meta>, <link>를 컴포넌트 안 어디서 렌더해도 React가 자동으로 <head>로 호이스팅합니다.

컴포넌트 안에서 <title>
function PostPage({ post }: { post: Post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </article>
  );
}

Next.js에서는 보통 export const metadatagenerateMetadata를 쓰지만, 라이브러리 컴포넌트가 자기 metadata를 자체적으로 가지고 있는 경우에 유용합니다.

Hydration 에러 메시지 #

React 18에서는 hydration mismatch가 나면 메시지가 모호했습니다. React 19부터는 어떤 노드의 무엇이 어긋났는지 구체적으로 알려 줍니다. 디버깅 시간이 크게 줄어듭니다.

Asset Loading — <link rel="preload" /> #

리소스를 컴포넌트 안에서 직접 preload할 수 있는 API가 정리됐습니다.

이미지 preload
import { preload } from 'react-dom';

function HeroSection() {
  preload('/hero.jpg', { as: 'image' });
  return <img src="/hero.jpg" alt="히어로" />;
}

<link rel="preload">를 자동으로 만들고 중복 호출을 dedup합니다. 31장 (성능과 Web Vitals)에서 LCP를 끌어내리는 도구 중 하나로 다시 만나게 됩니다.

18 → 19 변화 요약 카드 #

처음 18→19로 점프하는 독자를 위한 한 페이지 카드.

영역React 18React 19
폼 mutationuseState + fetch + try/catch<form action={fn}> + Actions
폼 결과 state직접 useStateuseActionState
pending 표시직접 useStateuseActionStateisPending 또는 useFormStatus
낙관적 업데이트직접 state 관리 + 롤백useOptimistic
Promise 풀기Server Component에서 await만+ use(promise) (Client)
조건부 Context불가 (hook 규칙)use(Context) 조건부 OK
memoization직접 memo / useMemo / useCallback+ React Compiler (자동)
ref 전달forwardRefref as prop
Document head외부 라이브러리 또는 metadata API컴포넌트 안 <title> / <meta> 자동 호이스팅
Asset preload<link> 수동react-dompreload

직접 해보기 — 18 → 19 미니 마이그레이션 #

작은 폼 하나를 React 18 스타일에서 19 스타일로 옮겨 보겠습니다. 4부 27장의 방명록 폼을 거꾸로 18 스타일로 작성한 뒤, 절을 하나씩 적용해 가며 19로 바꿔 보세요.

  1. 시작점 — React 18 스타일: 27장에서 만든 방명록의 MessageFormuseState로 text/submitting/error를 따로 관리하고, <form onSubmit={...}>에 fetch를 직접 거는 코드로 다시 쓰세요. (의도적으로 React 18 시대 코드를 재현)
  2. 1단계 — Actions 도입: 'use server'가 붙은 postMessage 함수를 만들고, <form action={postMessage}>로 연결하세요. submitting state가 어떻게 사라지는지 관찰합니다.
  3. 2단계 — useActionState: 결과 메시지를 위한 useState를 useActionState로 교체하세요. 검증 에러를 서버에서 던지고, 그 결과가 자동으로 화면에 표시되는 흐름을 확인합니다.
  4. 3단계 — useFormStatus: 제출 버튼을 별도 SubmitButton 컴포넌트로 분리하고, useFormStatus로 부모 폼의 pending을 받게 하세요. 버튼 코드를 한 번 만들어 두면 모든 폼에서 재사용된다는 점을 확인합니다.

세 단계를 거치고 나면 React 18 시대의 폼 코드와 React 19 시대의 폼 코드가 같은 일을 얼마나 다르게 표현하는지 손에 깊이 박힙니다.

연습문제 #

  1. 기본 원리 글로 정리. Actions API의 “함수가 state를 가진다"는 모델과 React 18 시대의 “컴포넌트가 state를 가진다"는 모델이 어떻게 다른지 5문장 안에 정리해 보세요. useActionState의 두 번째 반환값(formAction)이 React가 함수에 state를 부여하는 메커니즘이라는 점을 짚으면 답에 가까워집니다.
  2. useOptimistic을 안 쓸 경우. 다음 세 mutation 중 useOptimistic을 적용하면 오히려 UX가 나빠질 수 있는 것은 무엇이고 왜 그런지 설명해 보세요. (a) Todo 항목 추가, (b) 결제 처리, (c) 댓글 좋아요. 각각에 대해 mutation이 실패할 때 사용자가 받는 인상이 어떻게 다른지 묘사해 보세요.
  3. React Compiler 영향 평가. 14장의 useMemo / useCallback 예제 코드를 React Compiler가 켜져 있다고 가정하고 다시 읽어 보세요. 어떤 코드가 그대로 두어도 자동으로 최적화되고, 어떤 코드는 여전히 손으로 의도를 표현해야 하는지 분류해 보세요. 손으로 둘 코드의 공통점이 “참조 동일성을 외부에 노출"한다는 점에 도달하면 됩니다.

한 줄 요약: React 19는 폼/mutation의 모든 보일러플레이트를 Actions API (useActionState · useFormStatus · useOptimistic) 한 세트로 정리하고, Promise/Context를 더 유연하게 다루는 use() 훅을 추가했다. React Compiler가 자동 memoization으로 14장의 손작업을 줄이고, forwardRef는 ref as prop으로 단순해졌다. 4부 22~27장이 위 도구들 위에서 동작하고, 다음 5부와 6부도 같은 토대 위에 쌓인다.

다음 챕터 #

본 챕터로 4부 (모던 Next.js)가 완전히 마무리됩니다. 다음 29장 컴포넌트 테스팅 — Vitest + Testing Library부터 5부 (운영 · 테스트 · 배포)가 시작됩니다. 5부는 “리액트를 만들 줄 안다"에서 “리액트로 일한다"로 넘어가는 다리입니다. 첫 챕터인 29장에서는 우리가 4부까지 만든 컴포넌트들에 어떻게 안전망을 입히는지, Vitest와 React Testing Library의 기본 원리부터 시작해 mocking과 훅 테스트까지 정리합니다.

X