React 19 긍정 검토하기
서론
React 18을 넘어 React 19가 공식적으로 stable하게 릴리즈 된지 한달이 넘었다. React 18이 나왔을 때에는 새로나온 내용들을 바로 공부하지 않고 어느정도 시간이 지난 후에 공부하게 되었는데 갈수록 변화가 빨라지고있다는 생각에 이번에는 조금 더 빠른 시점에 새로운 버전을 공부해보기로 했고 공식문서에 있는 내용들을 살펴보며 간단하게 정리해보려 한다.
바로 시작해보겠다.
Actions
Stable된 릴리즈 노트의 가장 위에 나오는 내용은 바로 Action 이다.
React 19에서 새롭게 추가된 여러가지 훅은 Action을 지원하기 위해 나왔는데, 노트의 순서 상 실제 Action에 대한 설명은 조금 더 아래에 나오지만 Action에 대해 먼저 알고가야 앞으로 이해하기 편하기때문에 이 설명부터 보자.
React 19 부터는 비동기 Transition을 사용하는 함수는 Action 이라고 부릅니다.
- 펜딩 상태 : 액션은 요청이 시작되면 펜딩 상태를 제공하고 업데이트가 반영되면 자동적으로 해당 상태를 리셋합니다.
- 낙관적 업데이트 : 액션은 새로운 훅인 useOptimistic을 지원하여 사용자가 요청이 제출되는 동안 즉각적으로 피드백을 볼 수 있도록 합니다.
- 에러 핸들링 : 액션은 에러 핸들링에 대한 것을 제공하여 에러 상황이 발생하면 진행하던 업데이트를 되돌리고 에러 경계에게 처리를 위임합니다.
- 폼 : form 엘리먼트는 이제 Action을 받을 수 있는 action, formAction prop을 가질 수 있습니다. 해당 prop에 함수를 넘겨 제출 후에 자동적으로 폼이 리셋될 수 있도록 합니다.
결국 React는 비동기 전환 함수를 Action이라고 정의하고 이러한 액션을 다방면으로 더 완벽하게 지원하기 위해 React 19에서 여러가지 개선사항을 내놓은 것으로 보인다.
그 중 첫번째 useTransition을 보자.
useTransition
사실 useTransition은 React 18에서 처음 나왔으며, lane model을 기반으로 해당 훅 안에 넣은 동작을 Transition Lane으로 넣어 React 내부의 스케줄러 내에서 batch 처리 및 concurrent mode가 가능하도록 해준다. 이는 다른 중요한 이벤트가 발생했을 때 이 작업은 잠시 홀드하고 중요한 작업을 먼저 하도록 하여 일반적으로는 대규모의 상태 업데이트로 인해 input이 변경되지 못하고 밀리는 상황에서 이를 해소하기 위해 사용하는 예시를 많이 보게된다. (덜 긴급한 일에 이를 사용하여 후순위로 미루는 것이 일반적인 사용 방식이다.)
기존의 useTransition은 비동기 동작을 넣어주면 이를 Transition Lane 내에서 동작을 수행하다가 에러가 발생하는 경우와 같은 상황에 대한 대응이 어려웠지만 이제는 이에 대한 대응이 가능해졌다.
추가적으로 기존에 React 18에서는 단순히 클릭이 일어났을때 상태를 바꿔주고, 이 상태에 따라 버튼을 disabled 상태로 만들어주는 로직으로 만든 중복클릭 방지 케이스는 제대로 중복 클릭을 방지할 수 없다. 이는 React의 이벤트 시스템 역시 비동기로 동작하기때문에 너무 빠른 속도로 더블클릭을 수행한다면 제대로 막지 못하는데 이는 React-Testing-Library로의 테스트가 아닌 조금 더 브라우저 환경에 가까운 Cypress로의 dbclick 테스트를 수행해보면 확인해볼 수 있다.
하지만 React 19에서의 useTransition을 사용하면 정밀한 이벤트 시스템 내에서 원하는 방향대로 제대로 동작할 수 있도록 해주다보니 이러한 이슈들을 공식적으로 방어할 수 있도록 지원해주는 것으로 보인다.
[공식문서 발췌]
The async transition will immediately set the isPending state to true, make the async request(s), and switch isPending to false after any transitions. This allows you to keep the current UI responsive and interactive while the data is changing.
// 출처 : React 공식문서
// Before Actions
function UpdateName({}) {
const [name, setName] = useState("");
const [error, setError] = useState(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
const error = await updateName(name);
setIsPending(false);
if (error) {
setError(error);
return;
}
redirect("/path");
};
return (
<div>
<input value={name} onChange={(event) => setName(event.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
Update
</button>
{error && <p>{error}</p>}
</div>
);
}
// After Actions
function UpdateName({}) {
const [name, setName] = useState("");
const [error, setError] = useState(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
const error = await updateName(name);
// 기존에는 불가능하던 에러에 대한 처리가 가능해졌다.
if (error) {
setError(error);
return;
}
redirect("/path");
})
};
return (
<div>
<input value={name} onChange={(event) => setName(event.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
Update
</button>
{error && <p>{error}</p>}
</div>
);
}
useActionState
다음은 React에서 이러한 액션을 실행하는 것에 대한 처리를 쉽게 해주는 useActionState 훅이다.
useActionState는 인자로 넘어온 액션을 기반으로 return값으로 error, submitAction, isPending 값을 주어 해당 액션을 수행함에 있어서 부수적으로 필요한 error와 pending 상태에 대한 지원을 React 렌더링 사이클에 맞게 완벽하게 지원해준다.
function ChangeName({ name, setName }) {
const [error, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const error = await updateName(formData.get("name"));
if (error) {
return error;
}
redirect("/path");
return null;
},
null,
);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>Update</button>
{error && <p>{error}</p>}
</form>
);
}
여담으로 이 useActionState 훅은 Canary 버전에서는 useFormState였지만 이를 deprecate시키고 useActionState로 이름을 바꾸었다고 한다. (https://github.com/facebook/react/pull/28491)
이유는 useFormState 라고 이름을 정할 경우 Form에서만 사용할 수 있는 훅이라고 오해할 가능성도 있으며, 기존의 구현체는 React-Dom에서만 사용할 수 있도록 만들어져 이를 React로 옮겨 여러 환경에서도 사용할 수 있도록 한 것이다.
기존의 useFormState에서 구현하고자했던 것은 useActionState로 마이그레이션하고, useFormState의 이름에 맞는 역할을 가진 useFormStatus 라는 훅을 별도로 만들었다고한다.
useFormStatus
useFormStatus는 위에서 언급한 바와 같이 form 상태를 다루는 데에 최적화된 훅이다.
이는 form뿐 아니라 button과 같은 컴포넌트에도 사용할 수 있는 useActionState와는 달리 form에만 사용이 가능하며 form 관련 작업에 특화되어 pending, data, method, action 을 반환해주는데 이 중 data는 제출하려는 form에 담긴 데이터를 반환해주고, method는 form의 HTTP 메서드를 반환해주며 action은 form의 action prop에 넘겨줄 함수를 반환해준다.
import { useFormStatus } from "react-dom";
import action from './actions';
function Submit() {
const status = useFormStatus(); // pending, data, method, action을 반환해준다.
return <button disabled={status.pending}>Submit</button>
}
export default function App() {
return (
<form action={action}>
<Submit />
</form>
);
}
useOptimistic
비동기 요청을 할때 보이는 또 다른 흔한 UI 패턴은 액션을 수행하고 예상되는 결과에 대해 미리 즉각적으로 반영해두는 낙관적 업데이트 (Optimistic Update)가 있다. 이렇게 하면 네트워크 요청과 같은 백그라운드 작업이 완료되기 전에 사용자 인터페이스를 먼저 업데이트해주어 더 반응적으로 느껴지도록 도와주는데 React 19에서는 이를 하나의 훅으로 제공하여 훨씬 쉽게 제공할 수 있도록 한다.
function ChangeName({ currentName, onUpdateName }) {
const [optimisticName, setOptimisticName] = useOptimistic(currentName);
const submitAction = async formData => {
const newName = formData.get("name");
setOptimisticName(newName);
const updatedName = await updateName(newName);
onUpdateName(updatedName);
};
return (
<form action={submitAction}>
<p>Your name is: {optimisticName}</p>
<p>
<label>Change Name:</label>
<input
type="text"
name="name"
disabled={currentName !== optimisticName}
/>
</p>
</form>
);
}
React DOM : <form> actions
액션은 React-DOM에서 React 19의 새로운 form 특성과 함께 통합된다. 액션을 사용하여 양식을 자동으로 제출할 수 있도록 <form>, <input>, <button> 요소의 action 및 formAction prop 으로 함수를 전달하는 기능을 추가했다.
use hook
React 19에서 생긴 변화 중 여러가지가 있지만 단연코 use hook은 가장 중요하다고 볼 수 있겠다.
use hook의 경우 등장 전부터 꽤 많은 관심을 받아왔다.
기본적으로 해당 훅이 나타난 시점은 리액트에서 서버 컴포넌트를 지원하기 시작할때부터였는데, 서버 컴포넌트는 async로 컴포넌트를 감싸 컴포넌트 내에서 await를 사용할 수 있는 반면 클라이언트는 기술적인 한계로 그렇게할 수 없었기 때문에 이에 대한 고민이 시작된것으로 보인다. 클라이언트 컴포넌트에서 async await를 사용할 수 없었던 이유에 대해서는 아래 내용을 진행하는 과정에서 마저 설명하겠다.
import { use } from 'react';
function Comments({ commentsPromise }) {
const comments = use(commentsPromise);
return comments.map(comment => <p key={comment.id}>{comment}</p>);
}
function Page({ commentsPromise }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
)
}
use의 기본 동작을 살펴보면 use는 async await에서 await가 async function 안에서만 수행되는것처럼 리액트 컴포넌트와 훅 안에서만 실행이 가능하다.
또한 use는 다른 훅들이 하지 못하는 특별한 능력을 가지고있는데 그건 조건문, block scope, 루프 안에서 호출될 수 있다는 점이다.
단지 use를 통해 unwrapped되는 타입은 Promise만 있는 것은 아니고 Context도 가능하다.
그렇다면 async await 방식을 두고 이러한 use hook을 만들게 된 이유는 무엇일까?
그 내용은 https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md 에서 알 수 있는데 간단히 정리해보겠다.
Use Hook 생성에 대한 동기부여
1. JavaScript 생태계와 더 긴밀하게 통합 시키고자 했다.
Promise는 비동기 값임을 바로 인지할 수 있는 대표적인 값이라고 할 수 있다. 하지만 서버 컴포넌트의 원래 제안은 이러한 Promise based인 API에 접근하는게 어려웠다.
원래는 서버컴포넌트, 클라이언트 컴포넌트, 공유 컴포넌트에서 데이터를 가로지를 수 있는 일관된 API를 만들고 싶어했지만 어려움을 겪었고 이를 해결할 수 있는 방법은 두가지 중 하나였다. 클라이언트 컴포넌트를 async, await를 사용할 수 있도록 할지 아니면 서버 컴포넌트에서 사용하는 Promise를 React 고유의 hook으로 묶어서 사용하게 할지를 고민했다고 한다.
이렇게 원래는 통합된 방식을 사용하고싶어했지만 서버 컴포넌트에서 비동기/대기의 이점이 클라이언트에서 다른 API를 사용하는 것의 단점보다 더 크다고 판단했다고 한다.
이렇게 판단한 이유는 서버 컴포넌트가 비동기 연산을 수행해야하는 빈도를 과소평가했기 때문인데 Promise 기반의 API는 JS 서버 애플리케이션 어디에나 존재하며 모든 API에 대해 React 전용 바인딩을 사용하는건 너무 비효율적이라고 생각했다고 한다.
생각해보면 React가 서버 컴포넌트와 클라이언트 컴포넌트를 나누는 과정에서 일반적으로 서버로부터 데이터를 가지고 오는 작업은 서버 컴포넌트가 수행할 것으로 생각한다면 async 컴포넌트가 더 필요한 부분은 서버 컴포넌트일 것이다. 이러한 부분때문에 위와 같은 생각을 한게 아닐까 생각이 들었다.
2. 서버와 클라이언트 사이에 인지 허들을 없애려고 했다.
서버 컴포넌트와 클라이언트 컴포넌트가 나누어지면서 둘의 데이터 접근 방식이 다르면 어떤 환경에서 작업하고 있는지 추적하기가 더 쉬워진다는 장점도 있다.
서버컴포넌트는 클라이언트와 유사하지만 너무 유사하기를 바라지는 않았다고 한다. 각 환경(클라이언트, 서버)는 애플리케이션 구조로서 개발자들에게 있어 명확하게 구분되기때문이라고 한다.
서버와 클라이언트 컴포넌트를 쉽게 구분할 수 있도록 하면 개발자는 어떤 환경에서 어떤 컴포넌트가 실행되는지 파악하는데에 사용하는 정신적 에너지를 줄일 수 있기 때문이다.
Async function은 너무 명확한 시그널을 제공한다. 만약 컴포넌트가 async function으로 작성되어있다면 이것은 서버 컴포넌트다 라고 인지할 것이기 때문이다.
서버와 클라이언트 컴포넌트를 인지함에 있어 어려움을 없애고자 했다.
3. 데이터를 가지고오는 것과 읽어내는 것에 대한 불필요한 커플링을 방지하려고 했다.
await의 가장 큰 특징은 데이터를 가지고오는 방식과는 무관하게 단순하게 받아온 Promise를 unwrap하는 역할만 한다는 점이다.
use에서도 이러한 특징을 그대로 따라가려고 했으며 일반적으로 사용할 것으로 기대하는 패턴 중 하나는 현재 렌더링을 차단하지 않고 이후 렌더링에서 사용될 것을 미리 가지고 오는 것이라고 한다.
function TooltipContainer({showTooltip}) {
// 이것은 논블로킹 fetch로 요청은 했지만 아직 unwrapped되지 않은 결과이다.
const promise = fetchInfo();
if (!showTooltip) {
// 만약 showToolTipe이 false라면 데이터 로딩을 기다릴 필요 없이 즉시 null을 return
return null;
} else {
// showTooltip이 true라면 unwrap해주는 use를 사용해서
// promise를 기다리고 Tooltip을 렌더하기 전에 해당 데이터를 불러와서 읽어준 형태로 넘겨줄 수 있다.
return <Tooltip content={use(promise)} />;
}
}
function Note({id}) {
// note를 동기적으로 가지고오는 것 같지만 실제로는 비동기적으로 가지고온다.
const note = use(fetchNote(id));
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
</div>
);
}
이러한 고민 사항들을 가지고 use 를 만들기 시작했다고 한다. 그렇다면 Use Hook의 특징은 무엇일까 ?
Use Hook의 특징
use는 기본적으로 async/await와 비슷한 프로그래밍 모델로 설계되었다. 하지만 async/await와 다르게 중단 후 이어서 실행하지 않으며 중단한 후에는 다시 실행하는 구조이다.
하지만 이러한 Promise방식은 React의 선언적 모델과 맞지 않았는데 Promise는 일반적으로는 그 자체로 동기적으로 동작하지는 않기 때문이다. React에서 이를 도입할때에도 이러한 문제때문에 고민하게되었는데 React는 현재 props, state 기반으로 UI를 표현하고자 하는데 비동기인 Promise로 인해 현재 값을 바로 알 수 없었기 때문이다.
// JavaScript의 Promise 특성
const promise = Promise.resolve(42);
console.log(promise.value); // undefined - 동기적 접근 불가
// React의 선언적 모델
function Component({ id }) {
// React는 현재 props/state 기반으로 UI를 표현하고 싶은데
// Promise는 비동기라서 현재 값을 바로 알 수 없음
const data = fetchData(id);
return <div>{data}</div>;
}
// async/await: 중단점에서 다시 시작
async function AsyncComponent() {
const user = await fetchUser(); // 여기서 중단되고, resolve 후 여기서 재개
return (<div>{user.name}</div>);
}
// use: 컴포넌트 전체를 다시 실행
function UseComponent() {
const user = use(fetchUser()); // 중단 후 컴포넌트 전체가 다시 실행됨
return <div>{user.name}</div>;
}
결국 React는 Promise가 pending 상태라면 컴포넌트를 중단하는 Suspend 전략을 선택했고 이 Promise가 resolve된 후에는 해당 컴포넌트를 처음부터 다시 실행시키는 방법을 선택했다. 물론 Suspend 상태에서는 상위 Suspense의 fallback이 그려지는 패턴이다. 이로 인해 비동기 작업을 React의 선언적 모델에 통합할 수 있게 된 것이다.
// 첫 번째 실행
function UserProfile({ userId }) {
const userPromise = fetchUser(userId);
// Promise가 pending 상태라면 여기서 컴포넌트 실행 중단한다
const user = use(userPromise);
// 이 아래 코드는 실행되지 않는다.
console.log('유저 데이터:', user);
return <div>{user.name}</div>;
}
// Promise resolve 후 두 번째 실행
function UserProfile({ userId }) {
const userPromise = fetchUser(userId);
const user = use(userPromise); // 이제 resolved된 값을 바로 사용
console.log('유저 데이터:', user);
return <div>{user.name}</div>;
}
다만 위에서 언급한 것과 같이 클라이언트 컴포넌트에서는 async를 사용하지 못한 이유가 존재하는데 이는 클라이언트 컴포넌트의 특징 상 props가 변경되면 실제 Promise에 영향을 주는 값이 바뀐것이 아니더라도 새로운 Promise 인스턴스가 생성된다는 것이다.
그런 과정에서 React 내부적으로 Memoization 해두었던 캐시가 무효화되어 불필요한 렌더링이 발생하고, async/await를 사용하게 된다면 해당 작업이 microtask 큐가 비워질때까지 대기했다가 Promise resolve 이후 컴포넌트를 재실행하는 번거로운 작업이 존재해 클라이언트 컴포넌트에서는 async를 사용하지 않기로 한 것이다.
물론 이 문제는 내부적으로 cache를 사용해서 동일한 Promise에 대해서는 항상 같은 Promise를 반환하도록 하는 방식을 채택해 일부는 해결했지만 아직은 완벽한 해결책이 아니기때문에 결국 use hook을 사용하기로 결정한 것이다.
제일 중요하다고 생각한 Use Hook에 대해 깊게 살펴보았으니 나머지도 빠르게 훑어보겠다.
Context as a provider
이름 그대로 Provider는 이제 그 자체로서 Context가 동작할 수 있도록 되었다.
const ThemeContext = createContext('');
function App({children}) {
return (
<ThemeContext value="dark">
{children}
</ThemeContext>
);
}
Ref as a prop
이제는 ref를 prop으로 받기 위해 forwardRef를 사용하지 않아도 된다. props에 ref를 추가하고 이를 그대로 사용할 수 있게 되었다.
function MyInput({placeholder, ref}) {
return <input placeholder={placeholder} ref={ref} />
}
//...
<MyInput ref={ref} />
Cleanup functions for refs
ref에 대한 cleanup을 공식적으로 지원하기 시작했다.
사실 Ref에 대한 cleanup은 실제로 개발하는 입장에서 unmount 시에 node가 null이 되어 호출되기때문에 이에 대한 체크 로직을 두어 처리해주어야하는 번거로움이 있어 이러한 니즈가 꽤 있었고 (https://github.com/facebook/react/issues/15176), 이러한 번거로움으로 인해 기존에 토스에서 운영하던 오픈소스 라이브러리 Slash에도 이를 구현해둔 훅이 있었다.
(https://github.com/toss/slash/blob/main/packages/react/react/src/hooks/useRefEffect.ts)
React 19에서는 ref에서 cleanup이 가능하도록 지원하기때문에 이제 이런 유틸 훅 없이 ref 자체에서 직접 Cleanup을 수행해줄 수 있게 되었다.
// ❌ 이전 방식
const ref = useCallback((node: HTMLElement | null) => {
if (node) {
// 요소가 마운트될 때
const observer = new IntersectionObserver(() => {});
observer.observe(node);
} else {
// 컴포넌트 언마운트 시 node가 null로 전달됨
// cleanup 로직을 여기서 처리해야 했음
observer?.disconnect();
}
}, []);
// ✅ React 19 간단한 방식
function Component() {
const ref = use((node: HTMLElement | null) => {
if (!node) return; // 언마운트 시 자동으로 cleanup
const observer = new IntersectionObserver(() => {});
observer.observe(node);
return () => observer.disconnect(); // cleanup 함수 반환
});
return <div ref={ref}>Observed Element</div>;
}
useDeferredValue initial Value
useDeferredValue 훅은 값을 받아 지연시켜주는 역할을 했었는데 이제는 두번째 인자로 initialValue도 설정할 수 있도록 개선되었다.
intialValue를 넣어주면 해당 값을 초기 렌더링 값으로 반환해준다.
const value = useDeferredValue(deferredValue, '');
Support for Document Metadata
이는 다만 현재의 SSR에서 구현하는 완벽한 SEO 최적화를 구현할 수는 없다.
이유는 SSR에서는 요청에 따라 API Fetching을 수행하고 이로부터 받은 Response로 알맞는 Metadata를 만들어 제공해주는 것인데, 정적으로 만들어두는 CSR 의 경우 들어오는 요청에 따라 API Fetching을 수행하고 받아온 Response로는 Metadata를 만들어줄 수 없기 때문이다.
단지 기존의 React-Helmet과 같은 라이브러리가 수행하는 작업을 해준다고 보면 된다.
정리
Release Note에 보면 다른 내용들도 있지만 개인적으로 정리하면서 중요하다고 느낀 부분들은 이정도이다.
전반적으로 React 진영에서는 프론트엔드에서 자주 사용하는 패턴에 있어 너무 방관하지도 너무 관여하지도 않는 선에서 최대한 기능을 개선하려고 한 것이 느껴지며 특히 use hook을 만들기까지의 과정에 있어 얼마나 많은 고민이 들어갔는지 알게되었다. 다른 라이브러리와 다르게 유저의 니즈를 파악하고 비교적 빠르게 개선해나가는 이런 모습이 React를 춘추전국시대에서 앞서나갈 수 있도록 한게 아닐까 생각이 들기도 했다. 아직 짧은 시일 내에 React 19 환경에서 사내 프로젝트를 진행하지는 않을 것 같지만 use를 활용한 방식이 기존의 구조와 어떤 차이를 느끼게 할지 궁금하다.