React 19가 2024년 12월 5일 공식 stable 버전으로 npm에 배포되었습니다.
이번 글에서는 React 19의 핵심 변경 사항들을 정리해봅니다.
참고 자료
폼에서 이름을 수정하는 기능을 만든다고 하면, "저장 중..." 표시, 에러 메시지, 성공 시 페이지 이동을 전부 직접 관리해야 했습니다.
function UpdateName() {
const [name, setName] = useState('');
const [errorMsg, setErrorMsg] = useState(null);
const [saving, setSaving] = useState(false);
const handleClick = async () => {
setSaving(true); // 저장 시작
const result = await saveName(name); // API 호출
setSaving(false); // 저장 끝
if (result.error) {
setErrorMsg(result.error);
return;
}
redirect('/profile');
};
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={handleClick} disabled={saving}>저장</button>
{errorMsg && <p>{errorMsg}</p>}
</div>
);
}
setSaving(true) → API 호출 → setSaving(false) 이 패턴을 모든 비동기 작업마다 반복해야 합니다. 까먹으면 버튼이 영원히 비활성화되거나, 에러가 나도 아무 표시가 없는 버그가 생깁니다.
React 19부터는 startTransition에 async 함수를 넘길 수 있습니다.
이렇게 비동기 트랜지션을 활용하는 함수를 Action 이라고 부릅니다.
useTransition()은 두 가지를 돌려줍니다.
const [isPending, startTransition] = useTransition();
// ↑ 실행 중인지 여부 ↑ 안에 넣은 코드가 실행되는 동안 isPending이 자동으로 true가 됨
아래 코드에서는 변수명을 saving, startSaving으로 지었을 뿐 역할은 동일합니다.
function UpdateName() {
const [name, setName] = useState('');
const [errorMsg, setErrorMsg] = useState(null);
const [saving, startSaving] = useTransition();
const handleClick = () => {
startSaving(async () => {
const result = await saveName(name);
if (result.error) {
setErrorMsg(result.error);
return;
}
redirect('/profile');
});
};
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={handleClick} disabled={saving}>저장</button>
{errorMsg && <p>{errorMsg}</p>}
</div>
);
}
startSaving 안에 넣은 코드가 실행되는 동안 saving이 자동으로 true가 됐다가, 끝나면 false로 돌아옵니다. setSaving(true/false)를 직접 호출할 필요가 없어졌습니다.
Actions가 자동으로 처리해주는 것은 다음과 같습니다.
true, 완료되면 자동으로 falseuseOptimistic과 함께 사용 가능<form action={...}> 방식 사용 시 제출 후 폼 입력값이 자동으로 초기화useActionState위에서 본 Action 패턴에서 반복되는 코드를 더 줄여주는 훅입니다.
pending 상태, 액션 함수, 결과값을 한 번에 묶어서 줍니다.
const [state, submitAction, isPending] = useActionState(
async (prevState, formData) => {
const result = await saveName(formData.get('name'));
if (result.error) return { error: result.error }; // 반환값이 그대로 state에 저장됨
redirect('/profile');
return null;
},
null // state 초기값
);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>저장</button>
{state?.error && <p>{state.error}</p>}
</form>
);
useActionState가 돌려주는 세 가지는
state : 액션 함수가 return한 값이 여기 담깁니다.submitAction : <form>의 action prop에 넘기면 폼 제출 시 자동으로 실행되는 함수isPending : 현재 제출 중인지 여부 (true / false)💡 예전 React Canary 버전에서
ReactDOM.useFormState라는 이름으로 먼저 나왔다가, 정식 출시되면서useActionState로 이름이 바뀌었습니다.
useFormStatus디자인 시스템을 만들 때 공통으로 쓰는 <SubmitButton> 같은 컴포넌트가 있다고 합시다.
이 버튼은 폼이 제출 중일 때 비활성화되어야 하는데, 그러려면 "지금 폼이 제출 중인지"를 알아야 합니다.
기존에는 부모 컴포넌트에서 isPending을 prop으로 직접 내려줘야 했습니다.
useFormStatus를 쓰면 prop 없이도 부모 <form>의 상태를 바로 읽어올 수 있습니다.
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>저장</button>;
}
단, 이 훅을 쓰는 컴포넌트는 반드시 <form> 태그 안에서 렌더링되어야 합니다. <form> 바깥에서 쓰면 동작하지 않습니다.
useOptimistic서버에 저장 요청을 보내면 응답이 올 때까지 UI가 그대로 멈춰있습니다.
useOptimistic은 응답을 기다리지 않고 일단 UI를 먼저 바꿔놓고, 나중에 실제 결과로 교체하는 패턴을 쉽게 구현할 수 있게 해줍니다.
function ProfileName({ currentName, onSave }) {
const [displayName, setDisplayName] = useOptimistic(currentName);
const handleSubmit = async (formData) => {
const newName = formData.get('name');
setDisplayName(newName); // 서버 응답 안 기다리고 즉시 화면에 반영
const saved = await saveName(newName); // 실제 저장 요청
onSave(saved);
};
return (
<form action={handleSubmit}>
<p>현재 이름: {displayName}</p>
<input type="text" name="name" />
<button type="submit">변경</button>
</form>
);
}
저장이 성공하면 실제 서버 값으로 확정되고, 실패하면 원래 currentName으로 자동 롤백됩니다.
useuse는 렌더링 중에 Promise나 Context의 값을 꺼내 쓸 수 있는 새로운 API입니다.
기존 훅(useContext 등)과 다르게 if문이나 early return 이후에도 호출할 수 있다는 게 특징입니다.
function CommentList({ commentsPromise }) {
const comments = use(commentsPromise); // Promise가 완료될 때까지 렌더링 대기
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
function Page({ commentsPromise }) {
return (
<Suspense fallback={<p>댓글 불러오는 중...</p>}>
<CommentList commentsPromise={commentsPromise} />
</Suspense>
);
}
use(promise)를 만나면 React는 Promise가 완료될 때까지 렌더링을 잠깐 멈추고, 가장 가까운 <Suspense>의 fallback을 보여줍니다. Promise가 완료되면 렌더링을 이어서 진행합니다.
⚠️ 컴포넌트 안에서 직접
new Promise(...)로 만든 Promise를use에 넘기면 안 됩니다. 컴포넌트는 렌더링될 때마다 실행되는데, 그때마다 새 Promise가 만들어지고, 그 Promise를 읽으려고 또 렌더링이 일어나고... 무한 루프에 빠집니다.
Next.js의fetch나 React의cache()같이 한 번 만든 Promise를 재사용하는 방법을 써야 합니다.
React 훅에는 "조건문이나 early return 이후에 호출하면 안 된다"는 규칙이 있습니다.
그래서 useContext를 쓰려면 항상 컴포넌트 최상단에서 호출해야 했는데, use는 이 제약이 없습니다.
function Title({ children }) {
if (!children) return null; // 여기서 리턴해도
const theme = use(ThemeContext); // 아래에서 써도 됩니다
return <h1 style={{ color: theme.color }}>{children}</h1>;
}
ref가 이제 그냥 prop이다ref는 DOM 요소나 컴포넌트 인스턴스에 직접 접근할 때 쓰는 특별한 값입니다.
예를 들어 input에 포커스를 줄 때 inputRef.current.focus() 이런 식으로 씁니다.
문제는 기존에 함수 컴포넌트에서 ref를 받으려면 반드시 forwardRef로 감싸야 했다는 겁니다.
// Before: forwardRef 없이는 ref를 받을 수가 없었음
const MyInput = forwardRef(function MyInput({ placeholder }, ref) {
return <input placeholder={placeholder} ref={ref} />;
});
React 19부터는 ref도 그냥 일반 prop처럼 받을 수 있습니다.
// After: 그냥 props에서 꺼내 쓰면 됩니다
function MyInput({ placeholder, ref }) {
return <input placeholder={placeholder} ref={ref} />;
}
forwardRef는 React 19에서 당장 없어지는 건 아니고, 앞으로 나올 버전에서 단계적으로 없어집니다. 먼저 "이제 쓰지 마세요" 경고(deprecated)가 붙고, 나중에 아예 삭제됩니다.
(기존 코드를 한 번에 바꾸기 어려울 수 있어서, React 팀에서 자동으로 코드를 변환해주는 도구(codemod)도 제공할 예정이라고 합니다.)
💡 TypeScript 사용자 주의 : 기존
forwardRef기반 컴포넌트를 새 방식으로 바꿀 때,ref가 props 타입 안에 포함되도록React.ComponentPropsWithRef<'input'>같은 타입을 명시해줘야 할 수 있습니다.그리고 클래스 컴포넌트의
ref는 이번 변경과 무관합니다. 클래스 컴포넌트에서ref는 컴포넌트 인스턴스 자체를 가리키는 용도로만 쓰이고, props로 넘어오지 않습니다.
<Context>를 바로 Provider로Context는 props를 일일이 내려주지 않아도 하위 컴포넌트 어디서든 값을 읽을 수 있게 해주는 기능입니다. 기존에는 Context로 값을 제공할 때 .Provider를 붙여야 했는데, 이게 없어졌습니다.
// Before: .Provider를 붙여야 했음
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
// After: Context 자체가 Provider 역할을 함
<ThemeContext value="dark">
{children}
</ThemeContext>
기존 <Context.Provider> 방식도 당분간은 그대로 동작하지만, 앞으로 나올 버전에서는 경고가 붙고 결국 사라질 예정입니다.
💡 이 변경은 값을 제공하는 쪽(Provider)만 바뀐 겁니다.
값을 읽는 쪽에서 쓰는<Context.Consumer>는 변경 없이 그대로<Context.Consumer>로 써야 합니다. 다만 요즘은useContext(Context)또는use(Context)로 읽는 게 일반적입니다.
ref에 함수를 넘기면, DOM 요소가 마운트될 때 그 요소를 인자로 받아 실행됩니다.
이걸 ref 콜백이라고 합니다.
기존에는 ref 콜백 안에서 이벤트 리스너나 옵저버를 등록하면, 컴포넌트가 사라질 때 정리하는 코드를 useEffect의 cleanup 함수 같은 곳에 따로 작성해야 했습니다.
React 19부터는 ref 콜백에서 함수를 반환하면, 컴포넌트가 언마운트될 때 React가 그 함수를 자동으로 호출해줍니다. 등록과 해제를 한 곳에서 관리할 수 있게 됩니다.
<div
ref={(el) => {
const observer = new ResizeObserver(() => { /* 크기 변화 감지 */ });
observer.observe(el);
// 컴포넌트가 사라질 때 자동으로 실행됨
return () => {
observer.disconnect();
};
}}
/>
이 기능이 추가되면서 TypeScript에서 주의할 점이 생겼습니다.
ref 콜백이 뭔가를 반환하면 React는 그게 클린업 함수라고 간주합니다.
그래서 아래처럼 화살표 함수 단축 문법으로 값을 반환하는 형태가 되면 타입 에러가 납니다.
// 타입 에러: el(HTMLDivElement)을 반환하는 것처럼 보임
<div ref={el => (instance = el)} />
// 중괄호로 감싸서 반환값이 없게 만들기
<div ref={el => { instance = el; }} />
<title>, <meta>, <link> 같은 태그들은 원래 HTML의 <head> 안에만 있어야 합니다. 그런데 React에서는 페이지 제목이나 메타 정보를 실제로 결정하는 로직이 컴포넌트 안에 있는 경우가 많습니다.
기존에는 이걸 <head>에 넣으려면 react-helmet 같은 외부 라이브러리를 쓰거나, useEffect 안에서 직접 document.title = ... 같은 DOM 조작을 해야 했습니다.
React 19부터는 컴포넌트 JSX 안에 그냥 선언하면 React가 자동으로 <head> 안으로 옮겨줍니다.
function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<title>{post.title}</title> {/* 자동으로 <head>로 이동 */}
<meta name="description" content={post.summary} /> {/* 자동으로 <head>로 이동 */}
<p>{post.content}</p>
</article>
);
}
위치를 옮겨주는 것뿐 아니라 중복 제거도 합니다.
여러 컴포넌트에서 동일한 href의 <link>를 선언해도 <head>에는 하나만 삽입됩니다.
클라이언트 렌더링, SSR 스트리밍, Server Components 모두에서 동작합니다.
CSS는 나중에 선언된 스타일이 앞선 스타일을 덮어씁니다. 그래서 여러 컴포넌트가 각자 스타일시트를 불러올 때 삽입 순서가 뒤섞이면 의도치 않은 스타일이 적용될 수 있습니다.
<link rel="stylesheet">에 precedence prop을 주면 React가 우선순위에 맞게 삽입 순서를 알아서 관리해줍니다. 같은 스타일시트를 여러 컴포넌트에서 참조해도 중복 없이 한 번만 로드됩니다.
<link rel="stylesheet" href="reset.css" precedence="low" />
<link rel="stylesheet" href="theme.css" precedence="high" />
<script async src="..."> 를 컴포넌트 트리 어디서든 선언해도 React가 자동으로 중복을 제거하고 한 번만 로드합니다.
function Analytics() {
return (
<div>
<script async src="/tracker.js" />
</div>
);
}
// Analytics가 여러 곳에서 렌더링되더라도 tracker.js는 한 번만 로드됩니다
페이지가 처음 뜰 때 폰트, 스크립트, CSS 같은 리소스를 미리 불러두면 로딩이 빨라집니다. React 19에서는 이를 코드에서 선언적으로 처리할 수 있는 API들이 react-dom에 추가되었습니다.
import { prefetchDNS, preconnect, preload, preinit } from 'react-dom';
function App() {
preinit('https://cdn.example.com/script.js', { as: 'script' }); // 즉시 다운로드 & 실행
preload('https://cdn.example.com/font.woff', { as: 'font' }); // 미리 다운로드만
prefetchDNS('https://api.example.com'); // DNS만 미리 조회
preconnect('https://api.example.com'); // TCP 연결까지 미리
}
각 API의 차이는 다음과 같습니다.
| API | 하는 일 | 언제 쓰나 |
|---|---|---|
preinit | 즉시 다운로드 + 실행 | 이 스크립트가 반드시 필요할 때 |
preload | 다운로드만 미리 해둠 | 곧 쓸 건데 아직 실행은 아닐 때 |
prefetchDNS | DNS 조회만 미리 | 이 서버에 요청할지 아직 확실하지 않을 때 |
preconnect | TCP 연결까지 미리 맺어둠 | 요청은 보낼 건데 뭘 요청할지 모를 때 |
Error Boundary가 에러를 잡으면 기존엔 콘솔에 같은 에러가 최대 3번씩 출력됐는데, React 19부터는 한 번만 출력됩니다.
SSR(서버에서 HTML을 미리 만들어서 내려주는 방식)을 쓸 때, 서버에서 만든 HTML과 클라이언트에서 그리는 결과가 다르면 하이드레이션 에러가 납니다.
기존에는 이런 식으로 같은 에러가 여러 번 쏟아졌습니다.
// Before
Warning: Text content did not match. Server: "Server" Client: "Client"
Warning: An error occurred during hydration...
Warning: Text content did not match. Server: "Server" Client: "Client"
React 19부터는 에러를 하나로 합치고, 어디가 어떻게 다른지 diff도 보여줍니다.
// After
Uncaught Error: Hydration failed because the server rendered HTML didn't match the client.
<App>
<span>
+ Client
- Server
실제로 Next.js 프로젝트 하다가 마주친 에러인데, 이제 이런 식으로 바로 확인할 수 있습니다.
createRoot / hydrateRoot에 에러 종류별로 콜백을 등록할 수 있게 되었습니다. 에러 로깅 서비스에 연동할 때 유용합니다.
createRoot(document.getElementById('root'), {
onCaughtError: (error) => { /* Error Boundary가 잡은 에러 */ },
onUncaughtError: (error) => { /* 아무도 못 잡은 에러 */ },
onRecoverableError: (error) => { /* React가 자동으로 복구한 에러 */ },
});
Next.js 같은 프레임워크를 통해 사용하는 기능입니다. React 19에서 공식 stable로 포함되었습니다.
기존 React 컴포넌트는 전부 브라우저에서 실행됩니다.
Server Components는 서버에서 실행되는 컴포넌트입니다. 빌드 타임에 한 번 실행되거나, 요청이 들어올 때마다 서버에서 실행되어 결과를 HTML로 만들어 클라이언트에 보냅니다.
DB 조회나 파일 시스템 접근 같은 서버에서만 할 수 있는 작업을 컴포넌트 안에서 직접 할 수 있고, 해당 코드는 브라우저에 전달되지 않아 번들 사이즈를 줄일 수 있습니다.
클라이언트 컴포넌트(브라우저)에서 서버 함수를 직접 호출할 수 있는 기능입니다.
"use server" 지시어를 함수 안에 넣으면, 프레임워크가 자동으로 그 함수를 HTTP 요청으로 연결해줍니다.
async function saveProfile(formData) {
'use server';
await db.users.update({ name: formData.get('name') });
}
💡 헷갈리기 쉬운 부분 :
"use server"는 Server Action에 붙이는 지시어입니다. Server Component를 만들 때 쓰는 게 아닙니다. Server Component에는 별도의 지시어가 없습니다.
React 19의 변경 사항을 한 줄로 요약하면 "비동기 처리를 더 선언적으로, 반복 코드는 줄이고, 성능은 올리자" 입니다.
특히 Actions / useActionState / useOptimistic 조합은 폼 처리 방식을 편리하게 할 것 같고, ref와 Context 관련 문법 단순화도 코드베이스를 많이 깔끔하게 만들어줄 것 같습니다.
업그레이드 가이드는 공식 React 19 Upgrade Guide를 참고하시면 됩니다.