이 글은 누구를 위한 것인가
- React 18에서 19로 마이그레이션을 계획하는 팀
use()훅과useActionState로 비동기 패턴을 단순화하려는 개발자- Server Components와 Client Components의 경계를 올바르게 설계하려는 팀
들어가며
React 19는 비동기 데이터 처리를 근본적으로 변경한다. use()로 Promise를 컴포넌트 내에서 직접 읽고, useActionState로 폼 상태를 서버 액션과 통합하며, useOptimistic으로 낙관적 업데이트를 선언적으로 구현한다.
이 글은 bluefoxdev.kr의 React 19 신기능 가이드 를 참고하여 작성했습니다.
1. React 19 주요 변경사항
[React 19 핵심 추가 기능]
use(promise):
Suspense와 연동해 Promise 직접 읽기
try/catch 대신 Suspense/ErrorBoundary
기존: useState + useEffect + loading 상태
React 19: use(promise) + <Suspense>
use(context):
조건부로 Context 읽기 가능
(기존 useContext는 최상위에서만 가능)
useActionState:
Server Action과 폼 상태 통합
loading, error, data 상태 자동 관리
Progressive Enhancement 지원
useFormStatus:
폼 내부 컴포넌트에서 제출 상태 접근
별도 prop drilling 없음
useOptimistic:
비동기 작업 중 낙관적 UI 업데이트
작업 실패 시 자동 롤백
[Server Components 변경]
ref 전달: forwardRef 불필요 (ref가 prop처럼 전달)
Context: Server Context 지원
Actions: 비동기 트랜지션 처리
2. React 19 새 기능 구현
// use() 훅 - Promise 직접 읽기
import { use, Suspense } from 'react';
// 서버 컴포넌트에서 데이터 프리패치
async function ProductsPage() {
// Promise를 만들어서 클라이언트 컴포넌트로 전달
const productsPromise = fetchProducts(); // 미리 시작
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductList productsPromise={productsPromise} />
</Suspense>
);
}
// 클라이언트 컴포넌트에서 use()로 읽기
'use client';
function ProductList({ productsPromise }: { productsPromise: Promise<Product[]> }) {
// use()는 Promise가 resolve될 때까지 Suspense로 폴백
const products = use(productsPromise);
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
// 조건부 Context 읽기 (React 19)
function ThemeAwareButton({ showTheme }: { showTheme: boolean }) {
// 조건부로 use() 가능 (기존 useContext는 불가)
if (showTheme) {
const theme = use(ThemeContext);
return <button style={{ background: theme.primary }}>버튼</button>;
}
return <button>기본 버튼</button>;
}
// useActionState - 폼과 서버 액션 통합
'use server';
type FormState = {
error: string | null;
success: boolean;
data: { id: string } | null;
};
export async function createProduct(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const name = formData.get('name') as string;
const price = Number(formData.get('price'));
if (!name || name.length < 2) {
return { error: '상품명은 2자 이상 입력해주세요', success: false, data: null };
}
if (!price || price <= 0) {
return { error: '올바른 가격을 입력해주세요', success: false, data: null };
}
try {
const product = await db.product.create({ data: { name, price } });
revalidatePath('/products');
return { error: null, success: true, data: { id: product.id } };
} catch {
return { error: '상품 생성에 실패했습니다', success: false, data: null };
}
}
// 클라이언트에서 useActionState 사용
'use client';
import { useActionState } from 'react';
import { createProduct } from './actions';
function CreateProductForm() {
const [state, action, isPending] = useActionState(createProduct, {
error: null,
success: false,
data: null,
});
if (state.success) {
return <p>✅ 상품이 생성됐습니다! ID: {state.data?.id}</p>;
}
return (
<form action={action}>
<div>
<label htmlFor="name">상품명</label>
<input id="name" name="name" required />
</div>
<div>
<label htmlFor="price">가격</label>
<input id="price" name="price" type="number" min="0" required />
</div>
{state.error && (
<p role="alert" className="text-red-500">{state.error}</p>
)}
<SubmitButton />
</form>
);
}
// useFormStatus - 폼 내부 컴포넌트에서 상태 접근
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? '저장 중...' : '저장'}
</button>
);
}
// useOptimistic - 낙관적 업데이트
'use client';
import { useOptimistic, startTransition } from 'react';
interface Comment {
id: string;
text: string;
sending?: boolean;
}
function CommentList({ initialComments, postId }: {
initialComments: Comment[];
postId: string;
}) {
const [comments, addOptimisticComment] = useOptimistic<Comment[], Comment>(
initialComments,
// 낙관적 업데이트 함수: 현재 상태 + 새 값 → 새 상태
(currentComments, newComment) => [
...currentComments,
{ ...newComment, sending: true },
]
);
async function handleAddComment(formData: FormData) {
const text = formData.get('text') as string;
const optimisticComment: Comment = {
id: crypto.randomUUID(), // 임시 ID
text,
sending: true,
};
// 낙관적 업데이트: 즉시 UI에 반영
startTransition(() => {
addOptimisticComment(optimisticComment);
});
try {
// 실제 서버 요청
await addComment(postId, text);
// 성공: 서버 응답이 실제 데이터를 채움
} catch {
// 실패: useOptimistic이 자동으로 이전 상태로 롤백
}
}
return (
<div>
<ul>
{comments.map(comment => (
<li key={comment.id} style={{ opacity: comment.sending ? 0.5 : 1 }}>
{comment.text}
{comment.sending && <span> (전송 중...)</span>}
</li>
))}
</ul>
<form action={handleAddComment}>
<input name="text" required />
<button type="submit">댓글 추가</button>
</form>
</div>
);
}
// React 19: ref as prop (forwardRef 불필요)
function Input({ ref, ...props }: React.ComponentProps<'input'>) {
return <input ref={ref} {...props} />;
}
// 사용
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <Input ref={inputRef} type="text" />;
}
interface Product { id: string; name: string }
async function fetchProducts(): Promise<Product[]> { return []; }
async function addComment(postId: string, text: string) {}
import { revalidatePath } from 'next/cache';
마무리
React 19의 use() 훅은 "데이터 패칭은 useEffect로"라는 패턴을 완전히 대체한다. Promise를 Suspense 경계로 감싸고 use()로 직접 읽으면 loading 상태 관리 코드가 사라진다. useOptimistic은 서버 응답을 기다리지 않고 즉시 UI를 업데이트하고, 실패 시 자동으로 롤백해 복잡한 낙관적 업데이트 로직을 단순화한다. Server Components와 Client Components의 경계 설계가 올바르면 이 모든 기능이 자연스럽게 맞물린다.