현지 기준 2024년 10월 21일, 12월 5일 각각 Next.js 15와 React 19가 공식적으로 릴리스됐습니다.
저는 10월에 개발 중인 프로젝트가 있었고, 11월과 12월에는 기존에 완성해뒀던 프로젝트들의 리팩토링과 성능 개선 작업을 하느라 새로운 버전으로 업그레이드를 하지는 않았습니다. 필요성이 없었다고 하는게 타당하겠네요. 변경점에 대해서도 대충 훑고 지나갔던 터라 미루고 있었던 것도 맞습니다.
이번 포스트에서는 제가 프로젝트의 버전을 업그레이드하면서 찾아본 변경점이나 새롭게 추가된 기능에 대해 이전 버전과 비교하며 조명해보는 시간을 가져보려고 합니다. 제 프로젝트에서 이를 실제로 적용한 코드가 있다면 첨부하면서 설명을 덧붙여나가겠습니다.
제가 생각하는 React 19 버전은 Next.js 15 버전과 같이 쓸 때 시너지를 내기 위한 방향으로 개선된 것 같습니다.
서버 중심 개발을 강화하면서 클라이언트 간 데이터 로딩을 줄이는 것이 핵심인 것 같습니다.
왜 그런건지 하나씩 같이 보시면서 생각해보도록 하죠.
use는 컴포넌트 안에서 Promise가 resolve될 때까지 렌더링하지 않고 기다리는 훅입니다.
React에서 비동기 데이터 처리의 복잡성을 줄이고, 더 간결한 코드를 작성할 수 있도록 도입된
새로운 빌트인 훅입니다. 기능은 서버에서 비동기로 받은 데이터가 resolve될 때까지 기다렸다가 데이터를 반환합니다.
function Comments({ commentsPromise }) {
// use를 통해 Promise가 resolve될 때까지 기다리는 동안 Suspense가 로딩 UI를 렌더링합니다.
const comments = use(commentsPromise)
return comments.map(comment => <p key={comment.id}>{comment}</p>)
}
function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Comments commentsPromise={fetch()} />
</Suspense>
)
}
Comments 컴포넌트는 서버에서 데이터를 가져올 때까지, 즉 commentsPromise가 resolve될 때 까지 실행을 멈추고 기다립니다. 그동안에 UI는 보이지 않습니다. Promise가 resolve를 반환하면 실행을 재개하여 React.Element를 반환하므로 comments에 반환된 값이 저장됩니다.
use를 사용할 때 Suspense도 같이 사용하는 것이 권장됩니다. Suspense는 use가 데이터를 로드하는 동안 로딩 상태를 처리하기 위해 사용됩니다. use 자체만으로도 비동기 데이터를 처리할 수 있지만 사용자 경험을 위해 fallback UI를 제공하는 것이 권장됩니다.
기존 방식과의 차이점
useEffect와 useState로 생성한 상태를 사용해 데이터 로딩 상태를 따로 관리해야 했지만,
use를 사용하면 이 과정을 생략하고 더 직관적으로 데이터를 로드할 수 있습니다.
// 기존 방식
useEffect(() => {
fetchData().then(data => setData(data));
}, []);
// use 사용
const data = use(fetchData());
실사용 예시
이 코드는 제 프로젝트의 컴포넌트 코드 일부를 캡쳐해 온 것입니다.
원래 params의 경우 정적인 객체를 바로 반환했습니다. 그래서 14의 app router에서는 params에서
구조 분해로 바로 내부 속성인 id에 접근할 수 있었습니다만, Next.js 15부터는 비동기적으로 값을 가져오는 것이 기본 설계 철학이 되었기 때문에 use를 사용하여 params를 resolve해서 한 번 unwrapping시켜야 합니다.
이제 params는 더 이상 그 자체로 객체가 아닙니다. Promise를 처리한 뒤에야 비로소 객체로 반환되기 때문에, 비동기적으로 데이터를 처리하는 use 훅을 사용해야 합니다.
PageProps에서 params는 { id: string } 형태로 제공되었지만, 이제 비동기 데이터를 다루는 특성상 Promise<{ id: string }> 타입으로 변경되었습니다. 이를 통해 컴포넌트에서 비동기 데이터를 유연하게 처리할 수 있게 되었습니다.
use의 등장과 서버 중심의 렌더링으로 가는 이유?
핵심은 데이터 로딩과 렌더링을 서버에서 먼저 처리함으로써 클라이언트의 성능 부담을 줄이고, 사용자 경험을 개선하려는 목적을 가지고 있지 않나 생각이 됩니다. use는 서버와 클라이언트 컴포넌트 모두에서 Promise를 처리할 수 있도록 설계되었습니다. 이제 React가 Next.js와 함께 서버 중심의 렌더링 패턴을 자리잡게 하려는 의도를 여기서 볼 수 있습니다.
useTransition은 UI blocking 없이 이전 상태를 유지하면서 사용자의 경험이 끊기지 않고 이어지도록 만들어주기 위해 React 18에서 등장했던 훅입니다. React 18에서는 비동기 함수와 useTransition을 직접적으로 결합하기 어려워, useState를 사용해 수동으로 pending 상태를 관리해야 했습니다. 하지만 React 19에서는 비동기 함수와의 통합이 가능해져, 비즈니스 로직과 UI 상태 관리를 훨씬 간단히 처리할 수 있습니다.
import { useTransition } from 'react';
function SaveButton() {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
await saveData();
});
};
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
);
}
React 19에서는 startTransition으로 핸들러를 감싸는 것만으로, React가 자동으로 pending 상태를 관리하고 UI를 업데이트합니다. UI 상태(pending)와 비즈니스 로직을 깔끔히 분리하여 가독성이 개선되고 코드를 간소화할 수 있다는 장점이 있습니다.
form의 action 결과를 기반으로 상태를 업데이트 할 수 있도록 비동기 작업을 처리하는 데 유용한 훅입니다. 비동기 함수의 실행 결과를 자동으로 추적하고, 상태를 업데이트합니다. initialState를 설정하여 기본 상태를 지정하고 permalink를 활용해 특정 URL에 기반한 상태 관리를 구현할 수 있습니다. 또, 상태 추적과 렌더링을 통합하여 특정 요소의 상태를 쉽게 업데이트할 수 있습니다.
const [state, formAction] = useActionState(
fn, // 비동기 함수 (Promise 반환 - essential)
initialState, // 초기 상태 (option)
permalink?, // 영구 링크로 상태를 연결 (option)
);
사용 예시
이전에 비동기 폼 데이터를 처리하던 방식과 비교하여 어떻게 useActionState가 동작하는지 설명해보겠습니다.
async function submitForm(data) {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
});
return await response.json();
}
function FormComponent() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (event) => {
event.preventDefault();
setLoading(true);
setError(null);
try {
const data = new FormData(event.target);
const response = await submitForm(data);
console.log(response);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={loading}>
{loading ? 'Submitting...' : 'Submit'}
</button>
{error && <p>{error}</p>}
</form>
);
}
기존 방식에서는 loading, error를 각각 상태로 생성하여 setter로 핸들러 함수 내부에서 실행될 때 한 번, 작업이 완료된 후 한 번, 총 두 번 상태를 업데이트하고 있습니다.
아래는 useActionState를 사용해 폼 데이터를 전송하고, 결과를 상태에 반영하는 예시입니다.
import { useActionState } from 'react';
async function submitForm(data) {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
});
return await response.json();
}
function FormComponent() {
const [state, formAction] = useActionState(submitForm, { loading: false });
const handleSubmit = async (event) => {
event.preventDefault();
const data = new FormData(event.target);
formAction({ loading: true });
await formAction(data);
};
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={state.loading}>
{state.loading ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
useActionState는 formAction을 호출하기만 하면 상태가 자동으로 업데이트됩니다.
formAction을 호출하면 state.loading이 자동으로 true로 설정되고, 비동기 작업이 완료되면 false로 다시 설정됩니다. loading과 error 상태를 수동으로 업데이트할 필요가 없으므로, 기존 방식에 비해 훨씬 간결한 코드 작성이 가능합니다. 그리고 상태를 객체로 관리하므로 컴포넌트의 상태를 추적하거나 UI 요소에 반영하는 작업이 더 쉬워졌습니다.
따라서 useActionState를 사용하면 이전보다 복잡한 상태 관리 로직을 단순화하고, 폼 데이터 전송 및 결과를 처리하는 모든 과정을 깔끔하게 관리할 수 있습니다.
React 19와 Next.js App Router에서 제공하는 useFormStatus는 서버 액션(Server Actions)과 폼 상태 관리를 간단히 처리할 수 있도록 설계된 새로운 훅입니다. 정확히 말하면, 폼과 서버 액션의 통합을 강화하기 위해 도입된 훅으로, 주로 form과 함께 사용됩니다.
이 훅을 사용하면 서버 액션의 진행 상태(로딩, 에러 등)를 쉽게 추적할 수 있으며, 클라이언트와 서버 간의 상호작용을 더 직관적으로 관리할 수 있습니다. 특히, 폼 제출 중 상태를 자동으로 관리해 UI를 동적으로 업데이트하는 데 매우 유용합니다.
pending 상태: 폼 액션이 진행 중일 때 true를 반환합니다.
UI 상태 업데이트: 로딩 상태를 추적해 버튼 비활성화, 로딩 메시지 표시 등의 작업을 간단히 구현할 수 있습니다.
서버 액션과의 통합: 서버에서의 폼 처리 결과를 UI에 바로 반영할 수 있습니다.
사용 예시
'use client';
import { useFormStatus } from 'react';
export default function ContactForm() {
const { pending } = useFormStatus();
return (
<form action="/api/contact" method="POST">
<label>
Name:
<input type="text" name="name" required />
</label>
<label>
Email:
<input type="email" name="email" required />
</label>
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
pending은 서버 액션이 진행 중인지 여부를 나타냅니다. 액션 실행 중일 때 true, 완료되면 false로 업데이트됩니다. 그래서버튼의 disabled 속성에 pending을 전달해 폼 제출 중에 버튼을 비활성화합니다. 그리고 로딩 중일 때 "Submitting..." 메시지를 표시해 사용자 경험을 개선합니다.
이 훅이 등장한 목적은 form 태그의 action 속성과 서버 액션을 연결하여 서버 중심의 상태 관리를 수행하기 위함이라고 볼 수 있습니다.
useOptimistic은 React 19에서 낙관적 UI 업데이트(Optimistic UI Updates)를 간단히 구현할 수 있도록 도입된 상태 관리 훅입니다. 주로 서버 상태를 변경하는 작업(API 호출, 데이터 업데이트)을 수행할 때, 서버 응답을 기다리지 않고 예상 결과를 클라이언트 UI에 미리 반영하려는 상황에서 사용됩니다. 사용자 경험을 최우선으로 하기 위한 목적이 큽니다.
정의: 서버 상태 변경 작업이 성공할 것이라고 가정하고 결과를 서버 응답 전에 UI에 미리 반영하는 기법.
목적: 서버 응답 지연으로 인해 사용자 경험이 단절되지 않도록 합니다.
ex) 사용자가 좋아요 버튼을 클릭했을 때, 서버 응답을 기다리지 않고 즉시 좋아요 상태를 UI에 반영
사용 예시
import { useOptimistic } from 'react';
export default function LikeButton({ initialLikes, postId }) {
// 초기 좋아요 수 상태를 설정
const [likes, setLikes] = useOptimistic(
initialLikes, // 초기 상태
(state, action) => {
if (action.type === 'like') {
return state + 1; // 좋아요 수 증가
} else if (action.type === 'unlike') {
return state - 1; // 좋아요 수 감소
}
return state;
}
);
const handleLike = async () => {
setLikes({ type: 'like' });
try {
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
} catch (error) {
console.error('Failed to like the post:', error);
}
};
return (
<button onClick={handleLike}>
👍 {likes}
</button>
);
}
리듀서 함수는 상태를 업데이트하는 로직을 정의하며, 액션에 따라 state를 증가 또는 감소시킵니다.
setLikes({ type: 'like' }) 호출 시, 서버 응답을 기다리지 않고 즉시 좋아요 수를 증가시킵니다. 낙관적 업데이트 후 실제 서버에 요청을 보내 상태를 동기화합니다. 요청이 실패하더라도 UI는 낙관적 상태를 유지합니다.
React 19는 서버 중심 렌더링(Server-Driven Rendering)을 강조하며, 서버 컴포넌트를 본격적으로 지원하기 시작했습니다. Next.js 14에서 use server를 명시적으로 선언하여 서버 컴포넌트를 활용했던 방식과 유사하게, React 19에서도 서버 컴포넌트를 손쉽게 구현할 수 있습니다.
서버 컴포넌트는 SSR과 SSG를 모두 지원하며, 클라이언트 컴포넌트와 결합해 강력한 기능을 제공합니다.
이러한 변화는 SPA와 CSR의 대표 라이브러리로 자리 잡았던 React가 서버 중심의 로직 처리를 강화하는 방향으로 확장되었다는 점에서 다소 의외로 느껴질 수 있습니다. 그러나 복잡한 비즈니스 로직과 UI를 가진 서비스에서는 클라이언트 사이드에서 모든 작업을 처리하는 데 한계가 있기 때문에, 서버에서 로직을 처리할 수 있는 방향으로 진화하고 있다고 볼 수 있습니다.
주요 기능
클라우드와 서버리스 아키텍처의 확산 등의 이유로 서버에서 비즈니스 로직을 처리하고 클라이언트는 가볍게 유지하는 패턴이 점점 보편화되고 있습니다. 그리고 초기 로딩 속도와 SEO가 단점인 CSR 중심의 React가 서버 관련 기능이 강화되면서 이 또한 커버되는 긍정적인 효과가 있겠네요.
React 18에서는 화살표 함수로 생성된 자식 컴포넌트에 forwardRef를 사용해서 ref를 건네받을 수 있었지만, React 19부터는 함수 선언식으로 생성한 자식 컴포넌트에서 바로 prop으로 전달받을 수 있습니다. 이건 정말 편하게 바뀐거 같습니다!
ref의 callback 함수 내부에서 실행되는 클린업 함수를 도입했습니다.
사용 예시
import { useRef } from 'react';
function Example() {
const ref = useRef(null, (current) => {
if (current === null) {
// Cleanup 로직
console.log('Ref cleanup');
} else {
// Ref가 설정될 때 실행되는 로직
console.log('Ref attached');
}
});
return <div ref={ref}>Click me</div>;
}
useRef의 두 번째 매개변수로 클린업 함수를 전달합니다.
ref가 null로 설정되면 클린업 함수가 호출됩니다.
ref가 DOM 요소와 연결될 때 초기화 작업을 수행할 수 있습니다.
덕분에 useEffect 없이 ref와 관련된 초기화 및 클린업 작업을 수행할 수 있습니다. 그리고 DOM 요소가 연결되거나 해제될 때 바로 클린업 작업을 수행하므로, 불필요한 리소스 사용을 줄일 수 있습니다.
React는 head 태그를 렌더링하지 않고 head 태그로부터 멀리 떨어져 있어 설정하는 데에 어려움이 많았습니다. 그래서 SEO를 위한 작업이 React에서는 쉽지 않았죠. 이를 기존에는 useEffect와 react-helmet을 사용하여 head 태그 안에 삽입하는 방식으로 메타 태그를 설정해야만 했습니다.
React 19에서는 컴포넌트에서 메타 태그를 렌더링할 수 있는 기능을 지원하기 시작했습니다.
사용 예시
function BlogPost({post}) {
return (
<article>
<h1>{post.title}</h1>
<title>{post.subTitle}</title>
<meta name="author" content="Josh" />
<link rel="author" href="https://twitter.com/joshcstory/" />
<meta name="keywords" content={post.keywords} />
<p>
This is a blog post from windowook.log
</p>
</article>
);
}
이제부터 컴포넌트 내부에서 title, link, meta 태그를 사용하면 React가 자동으로 문서의 head 태그 내부로 호이스팅시켜줍니다. 서버와 클라이언트 컴포넌트 모두 적용됩니다.
React 19에서는 동적 우선순위 조정이 개선되어 useDeferredValue로 처리되는 값의 우선순위가 더 세밀하게 조정됩니다. 예를 들어, 사용자가 상호작용 중이라면 더 높은 우선순위의 업데이트가 지연 없이 처리되고 낮은 우선순위의 작업은 효율적으로 지연됩니다. 그리고 Suspense와의 통합이 강화되어 로딩 상태 관리가 더 직관적으로 이루어질 수 있습니다. 지연된 값이 렌더링되기 전에 Suspense의 fallback을 활용해 대체 UI를 표시할 수 있습니다.
사용 예시
import { useState, useDeferredValue, Suspense } from 'react';
function SearchResults({ query }) {
// 검색 결과를 비동기로 가져오는 가정
const results = simulateSearchAPI(query);
return <div>{results.map((result) => <p key={result}>{result}</p>)}</div>;
}
export default function App() {
const [input, setInput] = useState('');
const deferredInput = useDeferredValue(input);
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type to search..."
/>
<Suspense fallback={<div>Loading results...</div>}>
<SearchResults query={deferredInput} />
</Suspense>
</div>
);
}
function simulateSearchAPI(query) {
return query ? Array(10).fill(`Result for "${query}"`) : [];
}
기존에는 에러 바운더리를 사용하여 컴포넌트 내에서 발생한 에러를 잡을 수 있었습니다. 하지만 에러를 잡았더라도 콘솔에 에러가 출력되거나, 일부 처리되지 않은 에러로 인해 애플리케이션 동작이 중단되는 경우가 있었습니다.
React 19에서는 createRoot를 사용하여 DOM 요소를 선택해 에러를 핸들링할 수 있도록 onCaughtError, onUncaughtError을 제공합니다. 기존에는 에러 바운더리가 에러를 잡았는데도 콘솔에 에러가 출력되던 문제가 있었죠. 잡은 에러를 처리하기 위해 onCaughtError를 사용하여 핸들링할 수 있게 됐습니다.
import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';
function ErrorProneComponent() {
const [count, setCount] = useState(0);
if (count > 3) {
throw new Error('Count exceeds limit!');
}
return (
<button onClick={() => setCount((prev) => prev + 1)}>
Click me! Count: {count}
</button>
);
}
function App() {
return (
<div>
<h1>Error Handling in React 19</h1>
<ErrorProneComponent />
</div>
);
}
// 에러 핸들링
const root = ReactDOM.createRoot(document.getElementById('root'), {
onCaughtError: (error) => {
console.error('Caught error:', error.message);
},
onUncaughtError: (error) => {
console.error('Uncaught error:', error.message);
// fallback 액션 추가
},
});
root.render(<App />);
React 19의 onCaughtError와 onUncaughtError는 개발자가 에러를 더 세밀하게 관리하고, 애플리케이션의 안정성을 강화할 수 있도록 돕습니다.
기존에는 index.html 내에서 필요한 스타일 시트를 모두 연결하여 로드해왔습니다.
이제 React 19에서는 지정한 스타일 시트의 우선순위에 따라서 DOM 내부에 스타일 시트를 삽입합니다.
사용 예시
function ComponentOne() {
return (
<Suspense fallback="loading...">
<link rel="stylesheet" href="foo" precedence="default" />
<link rel="stylesheet" href="bar" precedence="high" />
<article class="foo-class bar-class">
{...}
</article>
</Suspense>
)
}
function ComponentTwo() {
return (
<div>
<p>{...}</p>
<link rel="stylesheet" href="baz" precedence="default" /> <-- will be inserted between foo & bar
</div>
)
}
React 19에서는 link 태그의 precedence 속성을 사용하여 스타일 시트의 우선순위를 명시적으로 설정할 수 있습니다. 브라우저는 precedence에 따라 스타일 시트를 DOM에 삽입하며, 우선순위가 높은 스타일이 먼저 렌더링됩니다.
따라서 스타일 시트를 컴포넌트 수준에서 관리할 수 있게 되어 특정 컴포넌트가 렌더링 될 때 필요한 스타일만 로드할 수 있게 됐습니다. 그래서 불필요한 스타일 로드를 막고 초기 렌더링 성능도 향상시킬 수 있게 됐네요.
또 Suspense를 사용하여 스타일 로드 완료 상태를 제어하거나 대체 UI를 표시할 수 있습니다.
이번 변경에서는 Suspense를 정말 많이 이용하게 되는 것 같습니다.
React 19에서는 필요한 자원을 브라우저에서 미리 로드할 수 있도록 합니다.
어떤 API들을 제공하는지 보시죠.
preinit
preinit('https://example.com/script.js', { as: 'script' });
특정 스크립트를 로드하고 실행할 준비를 합니다.
이 메서드는 JavaScript 코드 실행을 미리 준비하는 데 유용합니다.
preload
preload('https://example.com/font.woff', { as: 'font' });
preload('https://example.com/styles.css', { as: 'style' });
특정 자원을 미리 로드합니다.
자원이 필요한 시점보다 미리 로드하여 사용자가 기다릴 필요 없이 빠르게 제공됩니다.
prefetchDNS
prefetchDNS('https://example.com');
특정 도메인의 DNS를 미리 확인하여, 네트워크 요청이 필요한 경우 더 빠르게 연결할 수 있도록 준비합니다.
자원이 반드시 사용되지 않을 가능성이 있을 때 유용합니다.
preconnect
preconnect('https://example.com');
특정 도메인과의 네트워크 연결을 미리 준비합니다.
자원이 반드시 사용될 가능성이 높은 경우 유용합니다.
React 19부터는 Context API를 사용할 때 Provider를 따로 생성할 필요없이 그대로 Context를 사용하도록 변경되었습니다.
import React, { createContext, useContext } from 'react';
// Context 생성
const MyContext = createContext({ user: 'Default User' });
// Context 바로 사용
function ChildComponent() {
const context = useContext(MyContext);
return <div>User: {context.user}</div>;
}
export default function App() {
return (
<MyContext>
<ChildComponent />
</MyContext>
);
}
Provider 컴포넌트를 생성하여 하위 트리에 감싸주던 방식에서 Context 그 자체를 감싸주면 되는 방식으로 간편하게 변경되었습니다.
React 19에서는 전반적으로 UX, DX 모두 엄청난 개선이 이루어진 것 같습니다.
코드 간소화와 서버 쪽 로직 처리에 pending UI를 기본적으로 탑재하게 된 훅들이 많고 아마 Next.js 15와 같이 사용할 때 시너지가 더 좋지 않을까 생각이 됩니다.
다음 포스트는 그래서 Next.js 15에 대해서 다뤄보겠습니다.