- Published on
React 19에서 useOptimistic+useActionState로 렌더링 최소화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 액션과 스트리밍 UI가 보편화되면서, 프론트엔드 성능 최적화의 초점도 바뀌고 있습니다. 예전에는 useState 로컬 상태를 잘게 쪼개고 memo로 감싸는 식으로 렌더링을 줄였다면, React 19 환경에서는 서버 액션을 트리거하는 UI에서 “사용자 반응성”과 “정합성”을 동시에 만족시키는 게 더 중요해졌습니다.
이때 핵심 도구가 useActionState와 useOptimistic입니다.
useActionState: 서버 액션 실행 상태를 React 상태처럼 다루며, 제출 중 상태와 서버 결과를 한 흐름으로 관리useOptimistic: 서버 응답을 기다리기 전에 UI를 먼저 업데이트하고, 실패 시 자연스럽게 롤백
두 훅을 같이 쓰면, “폼 제출 한 번에 컴포넌트 전체가 여러 번 렌더링되는” 상황을 줄이고, 필요한 부분만 최소한으로 다시 그리면서도 UX는 즉각적으로 만들 수 있습니다.
관련해서 렌더링 병목을 찾는 방법은 아래 글이 같이 보면 좋습니다.
왜 서버 액션 UI는 렌더링이 쉽게 늘어날까
전형적인 패턴을 떠올려보면 이런 흐름이 많습니다.
- 사용자가 버튼 클릭
setLoading(true)- API 호출
- 성공하면
setItems(...) - 실패하면
setError(...) - 마지막에
setLoading(false)
이 과정에서 상태가 여러 번 바뀌며, 상위 컴포넌트에 상태가 몰려 있으면 하위 트리까지 연쇄 렌더링이 발생합니다. 특히 리스트 UI에서는 “아이템 하나 추가/삭제”가 “리스트 전체 리렌더”로 번지기 쉽습니다.
React 19의 서버 액션 기반에서는 더 복잡해집니다.
- 서버 액션의 결과가 다시 서버에서 내려오며 UI를 재구성
- 스트리밍/서스펜스 경계가 섞이면, 작은 상태 변화도 체감 성능에 영향을 줌
- 실패 시 롤백 처리가 분산되면, 상태를 더 많이 쪼개야 해서 오히려 렌더링이 늘어남
여기서 useActionState는 “액션 실행 상태”를, useOptimistic은 “UI의 즉시성”을 담당하면서 상태 변경 횟수와 범위를 줄이는 방향으로 설계를 유도합니다.
useActionState 핵심: 액션의 입력과 출력, pending을 한 덩어리로
useActionState는 대략 다음을 제공합니다.
- 현재 상태 값(서버가 돌려준 결과 포함)
- 액션 함수(폼
action에 연결 가능) pending여부
즉, loading, error, result를 따로 useState로 관리하지 않고, 액션 자체를 상태 머신처럼 다룰 수 있습니다.
예시: 댓글 작성 액션 상태 관리
아래는 “댓글 작성”을 서버 액션으로 처리한다고 가정한 코드입니다.
"use client";
import { useActionState } from "react";
type ActionState =
| { ok: true; message: string; newId: string }
| { ok: false; message: string };
async function createCommentAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const content = String(formData.get("content") || "");
if (!content.trim()) {
return { ok: false, message: "내용을 입력하세요." };
}
// 실제로는 서버에서 DB insert 후 id 리턴
await new Promise((r) => setTimeout(r, 400));
return { ok: true, message: "등록 완료", newId: crypto.randomUUID() };
}
export function CommentForm() {
const [state, formAction, pending] = useActionState(createCommentAction, null);
return (
<form action={formAction}>
<textarea name="content" placeholder="댓글" />
<button type="submit" disabled={pending}>
{pending ? "등록 중..." : "등록"}
</button>
{state && (
<p aria-live="polite" style={{ color: state.ok ? "green" : "crimson" }}>
{state.message}
</p>
)}
</form>
);
}
이 패턴의 장점은 단순히 코드가 짧아지는 게 아닙니다.
pending토글을 위한 별도setState가 사라짐- 서버 결과를 “상태 업데이트”로 다시 옮기는 중간 단계가 줄어듦
- 폼 제출 흐름이 React가 최적화하기 쉬운 형태로 고정됨
하지만 이것만으로는 “즉시 반응하는 리스트 업데이트”가 부족합니다. 사용자는 댓글을 등록 버튼 누르자마자 리스트에서 보길 원하니까요. 그 역할이 useOptimistic입니다.
useOptimistic 핵심: UI를 먼저 바꾸고, 실패하면 되돌린다
useOptimistic은 “서버 확정 상태”와 “낙관적 상태”를 분리합니다.
- 서버에서 내려온 확정 리스트는 그대로 두고
- 사용자 상호작용 직후에는 낙관적 리스트를 만들어 보여줍니다
중요한 포인트는, 낙관적 업데이트를 위해 상위 상태를 무리하게 바꾸지 않아도 된다는 점입니다. 즉, 리스트 전체를 전역 상태로 끌어올리지 않고도 체감 반응성을 만들 수 있습니다.
예시: 낙관적 댓글 추가 + 실패 시 롤백
아래는 useActionState로 서버 액션을 실행하고, 동시에 useOptimistic으로 리스트를 즉시 업데이트하는 패턴입니다.
"use client";
import { useActionState, useOptimistic } from "react";
type Comment = {
id: string;
content: string;
status?: "optimistic" | "confirmed";
};
type ActionState =
| { ok: true; confirmedId: string }
| { ok: false; error: string }
| null;
async function createCommentAction(
prev: ActionState,
formData: FormData
): Promise<ActionState> {
const content = String(formData.get("content") || "");
if (!content.trim()) return { ok: false, error: "빈 댓글입니다." };
// 서버 처리 시뮬레이션
await new Promise((r) => setTimeout(r, 500));
// 실패를 테스트하고 싶으면 아래를 주석 해제
// return { ok: false, error: "서버 오류" };
return { ok: true, confirmedId: crypto.randomUUID() };
}
export function CommentBox({ initial }: { initial: Comment[] }) {
const [optimisticComments, addOptimistic] = useOptimistic(
initial,
(current: Comment[], newContent: string) => {
const tempId = `temp-${crypto.randomUUID()}`;
return [
{ id: tempId, content: newContent, status: "optimistic" },
...current,
];
}
);
const [state, formAction, pending] = useActionState(
async (prev, formData) => {
const content = String(formData.get("content") || "");
// 1) UI를 먼저 업데이트
addOptimistic(content);
// 2) 서버 액션 실행
const res = await createCommentAction(prev, formData);
return res;
},
null
);
return (
<section>
<form action={formAction}>
<input name="content" placeholder="댓글 입력" />
<button type="submit" disabled={pending}>
{pending ? "등록 중" : "등록"}
</button>
</form>
{state && !state.ok && (
<p role="alert" style={{ color: "crimson" }}>
{state.error}
</p>
)}
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.status === "optimistic" ? 0.6 : 1 }}>
{c.content}
{c.status === "optimistic" ? " (전송 중)" : ""}
</li>
))}
</ul>
</section>
);
}
여기서 “실패하면 롤백”은 어떻게 되냐는 질문이 자연스럽습니다.
useOptimistic은 “기본 상태(initial)”가 바뀌면 낙관적 상태를 재계산하는 방식으로 동작합니다.- 실전에서는 서버 액션 성공 시 서버에서 확정 리스트를 다시 패치하거나, 상위에서 내려주는
initial을 갱신하게 됩니다. - 실패하면 확정 상태가 변하지 않으므로, 낙관적 항목이 남는 문제가 생길 수 있어 “실패 시 낙관적 항목 제거” 전략이 필요합니다.
즉, 실전 패턴은 보통 아래 둘 중 하나로 갑니다.
- 낙관적 항목에
tempId를 붙이고, 실패하면 해당 항목을 제거하는 reducer를 추가 - 성공 시 revalidate나 refetch로 확정 상태를 갱신해서 낙관적 항목이 자연스럽게 사라지게 설계
Next.js App Router 환경이라면 2번이 흔하고, 이때는 Hydration 및 서버/클라 불일치도 같이 신경 써야 합니다. 관련 이슈는 아래 글을 참고하세요.
렌더링을 “최소화”한다는 말의 의미를 다시 정의하기
React에서 렌더링을 0으로 만들 수는 없습니다. 대신 목표를 명확히 해야 합니다.
- 상태 변경 횟수를 줄인다
- 상태 변경의 영향 범위를 줄인다
- 사용자 입력과 스크롤 같은 고우선순위 작업을 방해하지 않는다
useActionState와 useOptimistic 조합이 좋은 이유는, 이 목표를 설계 레벨에서 강제하기 때문입니다.
- 액션 실행 상태는
useActionState로 한 덩어리 - 리스트 즉시성은
useOptimistic로 국소 처리 - 성공/실패 처리는 액션 결과를 기준으로 분기
이렇게 하면 흔히 발생하는 “여기저기 흩어진 setState”가 줄고, 결과적으로 리렌더 그래프가 단순해집니다.
실전 팁: 렌더링 범위를 줄이는 컴포넌트 분리 기준
두 훅을 쓴다고 자동으로 빠른 건 아닙니다. 아래 기준으로 컴포넌트를 나누면 효과가 커집니다.
1) 폼과 리스트를 분리하되, 낙관적 상태는 리스트 가까이에 둔다
- 폼 입력값은 폼 컴포넌트 내부에서 끝내기
- 낙관적 리스트는 리스트 컴포넌트에서 관리
상위에서 모든 것을 관리하면, 액션 pending 변화가 전체 페이지 리렌더로 번집니다.
2) 리스트 아이템은 memo보다 “데이터 구조 안정성”이 먼저다
낙관적 업데이트에서 흔한 실수는 매번 새 배열을 만들면서 모든 아이템의 props 참조가 바뀌는 것입니다.
- 변경된 항목만 새 객체로 만들고
- 나머지는 기존 참조를 유지
이게 지켜지면 memo 없이도 체감 렌더링이 줄어듭니다.
3) 실패 롤백을 위해 “낙관적 항목 식별자”를 설계에 포함
서버에서 확정 ID가 오기 전까지는 tempId가 필요합니다.
temp-...같은 prefixstatus: optimistic플래그
이게 없으면 실패 시 제거도 어렵고, 성공 시 확정 데이터와 매칭도 꼬입니다.
고급 패턴: 낙관적 업데이트를 reducer 형태로 확장하기
댓글 “추가”만 있으면 단순하지만, 좋아요 토글, 삭제, 수정까지 들어오면 낙관적 업데이트가 복잡해집니다. 이때는 useOptimistic의 업데이트 함수를 reducer처럼 설계하는 게 좋습니다.
type OptimisticEvent =
| { type: "add"; tempId: string; content: string }
| { type: "remove"; id: string }
| { type: "confirm"; tempId: string; confirmedId: string };
function optimisticReducer(current: Comment[], ev: OptimisticEvent): Comment[] {
switch (ev.type) {
case "add":
return [
{ id: ev.tempId, content: ev.content, status: "optimistic" },
...current,
];
case "remove":
return current.filter((c) => c.id !== ev.id);
case "confirm":
return current.map((c) =>
c.id === ev.tempId
? { ...c, id: ev.confirmedId, status: "confirmed" }
: c
);
default:
return current;
}
}
이 구조의 장점은 “낙관적 상태 변경”이 이벤트 로그처럼 명확해져서, 디버깅과 테스트가 쉬워진다는 점입니다. 또한 렌더링 관점에서도 “어떤 변경이 어떤 범위를 바꾸는지”가 코드로 드러납니다.
체크리스트: useOptimistic+useActionState로 성능을 얻는 조건
아래 조건이 만족되면, 이 조합은 렌더링 최소화에 꽤 직접적으로 기여합니다.
- pending, error, result를 각각
useState로 흩뿌리지 않고useActionState로 단일화 - 낙관적 업데이트는 리스트 가까이에서 처리해 상위 리렌더 전파를 차단
- 낙관적 항목은
tempId와status를 가져서 confirm 또는 rollback이 가능 - 성공 시 확정 데이터 소스가 갱신되도록 설계(리페치 또는 revalidate)
그리고 최적화는 항상 측정과 함께 가야 합니다. 렌더링 병목을 찾는 구체적 방법은 아래 글을 같이 참고하면, “체감이 아닌 근거 기반”으로 개선할 수 있습니다.
마무리
React 19에서 useActionState와 useOptimistic을 함께 쓰는 최적화는, 단순히 “훅 하나 더 써서 빠르게”가 아니라 서버 액션 중심 UI의 상태 흐름을 단순화해 렌더링을 줄이는 설계에 가깝습니다.
useActionState로 액션 실행 상태를 한 번에 묶고useOptimistic으로 사용자 반응성을 즉시 확보하며- 성공/실패(롤백/확정) 경로를 데이터 모델에 포함시키면
불필요한 리렌더와 복잡한 상태 분기를 동시에 줄일 수 있습니다.
다음 단계로는, 실제 앱에서 “어떤 컴포넌트가 pending 변화에 같이 흔들리는지”를 프로파일링하고, 낙관적 업데이트 이벤트를 reducer 형태로 정리해보는 것을 권합니다.