React 패턴
견고하고 접근 가능하며 성능 좋은 컴포넌트 트리를 구축하기 위한 관용적인 React 18/19 패턴입니다.
활성화 시점
- React 함수 컴포넌트, 커스텀 훅 또는 컴포넌트 트리를 작성하거나 수정할 때
- JSX/TSX 파일을 리뷰할 때
- 상태 구조나 컴포넌트 구성을 설계할 때
- 클래스 컴포넌트 또는
forwardRef/useEffect가 많은 코드를 마이그레이션할 때 - 지역 상태, 상태 끌어올리기, Context, 외부 스토어 중에서 선택할 때
- Server Components / Client Components (Next.js App Router, RSC)로 작업할 때
- React 19 액션 또는 제어 입력으로 폼을 구현할 때
- TanStack Query / SWR / RSC로 데이터 페칭을 연결할 때
핵심 원칙
1. 렌더링은 Props와 State의 순수 함수
// 좋음: 렌더링 중에 파생
function Cart({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <span>{formatMoney(total)}</span>;
}
// 나쁨: 파생 상태를 별도로 저장
function Cart({ items }: { items: CartItem[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
return <span>{formatMoney(total)}</span>;
}
useEffect에서 파생 상태를 계산하면 추가 렌더링 사이클이 생기고, 동기화가 깨질 수 있으며, 데이터 흐름이 불명확해집니다.
2. 사이드 이펙트는 렌더링 밖에서
이펙트, 변이, 네트워크 호출, 구독은 이벤트 핸들러나 useEffect 안에 있어야 하며, 절대 렌더 본문(body) 안에 있으면 안 됩니다.
3. 상속보다 합성
React에는 컴포넌트 상속 모델이 없습니다. children, render prop, 또는 컴포넌트 prop을 사용하여 합성하세요.
훅 규율
전체 규칙은 rules/react/hooks.md를 참조하세요. 주요 사항:
- 최상위 레벨에서만 호출, 절대 조건부로 호출하지 않음
- 모든 구독, 인터벌, 리스너는 정리(cleanup) 처리
- 새 상태가 이전 상태에 의존할 때는 함수형 업데이터 사용 (
setX(prev => prev + 1)) - 기본 원칙: 메모이제이션 하지 말 것 —
useMemo/useCallback은 프로파일러나 의존성 체인이 그 중요성을 증명할 때만 추가 - 동일한 훅 시퀀스가 2개 이상의 컴포넌트에 나타날 때만 커스텀 훅 추출
상태 위치 결정 트리
한 컴포넌트에서만 사용?
-> 그 컴포넌트 안에 useState
부모와 몇몇 하위 컴포넌트에서 사용?
-> 가장 가까운 공통 조상으로 끌어올림
멀리 떨어진 브랜치에서 사용되면서 읽기 빈도가 낮음(테마, 인증, 로케일)?
-> React Context
트리 전체에서 공유되며 업데이트 빈도가 높음?
-> 외부 스토어 (Zustand, Jotai, Redux Toolkit)
서버에서 파생됨?
-> 서버 상태 라이브러리 (TanStack Query, SWR, RSC fetch)
대부분의 페이지는 Context나 전역 스토어가 필요하지 않습니다. 중복된 상태 끌어올리기가 고통스러워지기 전까지는 추상화를 자제하세요.
서버 / 클라이언트 컴포넌트 (RSC)
// 서버 컴포넌트 - 기본값, 비동기, 자체적으로 JS를 전송하지 않음
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } });
if (!product) notFound();
return <ProductView product={product} />;
}
// 클라이언트 컴포넌트 - "use client"로 선택
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "추가 중..." : "장바구니에 담기"}
</button>
);
}
경계:
- 서버 -> 클라이언트: 직렬화 가능한 props 또는
children전달 - 클라이언트 -> 서버:
<form action={...}>을 통해 서버 액션 호출하거나 이벤트 핸들러에서 명령형으로 호출 - 클라이언트 컴포넌트 파일에서 서버 컴포넌트를
import하지 말고children을 통해 합성
Suspense + 에러 경계
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
- Suspense 경계는 라우트 루트가 아닌 데이터 가까이 배치하여 점진적으로 콘텐츠를 표시
- Error Boundary는 여전히 클래스 API이며, 훅 친화적인 래퍼를 위해
react-error-boundary사용 - 경계는 자식의 렌더링, 생명주기, 생성자에서 발생한 에러를 잡지만, 이벤트 핸들러나 비동기 코드 내 에러는 잡지 않음
폼
React 19 폼 액션 (새 코드에 권장)
export function UserForm() {
const [state, formAction, pending] = useActionState(updateUserAction, initial);
return (
<form action={formAction}>
<input name="name" required />
<button type="submit" disabled={pending}>저장</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
제어 입력(Controlled inputs)
값이 다른 UI를 제어하거나, 키 입력마다 포맷을 적용하거나, 실시간 유효성 검사를 구현할 때 제어 방식을 사용하세요.
복잡한 폼
다단계 폼, 동적 필드 배열, 교차 필드 유효성 검사에는 라이브러리(React Hook Form, TanStack Form)를 사용하세요. 단순함을 넘어서는 폼의 상태 관리를 직접 구현하는 것은 유지보수 함정입니다.
데이터 페칭 결정 매트릭스
| 필요 사항 | 도구 |
|---|---|
| Next.js App Router에서 요청별 데이터 | RSC await fetch() |
| 클라이언트 측 캐시 + 변이 + 무효화 | TanStack Query |
| 가벼운 클라이언트 캐시 + 재검증 | SWR |
| 실시간 구독 | Server-Sent Events, WebSockets 또는 라이브러리의 구독 API |
| 일회성 파이어 앤 포겟 | 이벤트 핸들러 내 fetch() |
애플리케이션 데이터에는 useEffect + fetch를 피하세요 — 경쟁 조건, 캐시 부재, 재시도 없음, Suspense 통합 없음.
합성 레시피
children을 통한 슬롯
<Layout>
<Header />
<Main>{content}</Main>
</Layout>
명명된 슬롯
<Page header={<Nav />} sidebar={<Filters />}>
<Results />
</Page>
컴파운드 컴포넌트 (Context를 통한 공유 상태)
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">프로필</Tabs.Trigger>
<Tabs.Trigger value="settings">설정</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>
Render prop / 함수-자식 패턴
부모가 렌더링된 출력에 매개변수를 전달해야 할 때 유용합니다:
<DataLoader id={id}>
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
</DataLoader>
현대적인 대안: 같은 형태를 반환하는 훅 (useData(id)) — 보통 더 깔끔합니다.
성능
React.memo가 실제로 도움이 되는 경우
다음 조건을 모두 만족할 때만 React.memo로 컴포넌트를 감싸세요:
- 자주 리렌더링됨
- props가 대부분의 렌더링 사이에 동일함
- 렌더링 비용이 측정 가능할 정도로 큼
React.memo는 모든 렌더링에서 동등성 검사를 추가합니다. props가 대부분의 렌더링에서 다르다면, 검사는 순수 오버헤드입니다.
렌더링 캐스케이드 방지
- 가능하면 상태를 위로 끌어올리기보다 아래로 내리기
- 컨텍스트 분할: 관심사별로 하나의 컨텍스트.
themeContext의 변경이 auth 사용자를 리렌더링하지 않도록 - 외부 상태 라이브러리에는
useSyncExternalStore사용 — 안전한 동시성 렌더링을 위해 필수
목록
- 안정적인
keyprop 제공 (데이터베이스 ID, 배열 인덱스는 사용하지 말 것) - 화면에 보이는 항목 수가 비간단한 행을 가진 ~50개를 초과하면
@tanstack/react-virtual또는react-window로 긴 목록 가상화
접근성 우선 구성
role속성을 사용하기 전에 항상 의미론적 HTML (<button>,<a>,<nav>,<main>)을 먼저 렌더링- 모든 상호작용 요소는 키보드로 접근 가능해야 함
- 폼 입력에는 레이블 필요 —
<label htmlFor>또는 시각적으로 아이콘으로 레이블링된 경우aria-label - 라우트 변경 및 모달 열기/닫기 시 포커스 관리
- 컴포넌트 테스트에서
axe실행 ( skills/react-testing 참조) - 관련 링크: skills/accessibility/SKILL.md 에서 WCAG 기준과 패턴 라이브러리 다룸
라우팅
이 스킬은 라우터에 독립적입니다. 위의 패턴은 React Router, TanStack Router, Next.js App Router, Remix Router와 함께 작동합니다. 라우터별 패턴(로더, 액션, 중첩 레이아웃)은 해당 라우터의 문서를 따르세요 — 이는 React 코어 위에 계층화된 프레임워크 관심사입니다.
범위 밖 (참고 섹션)
- Next.js 세부 사항: App Router 데이터 로딩, Route Handlers, Middleware, Parallel Routes — 별도의 관심사이며 Next.js 문서 참조
- React Native: 플랫폼별 패턴은 별도의
react-native-patterns스킬(아직 없음)을 보증할 만큼 다름 - Remix: Loader/action 규칙은 RSC와 겹치지만 Remix 문서를 따름
관련 자료
- 규칙: rules/react/ — 코딩 스타일, 훅, 패턴, 보안, 테스팅
- 스킬: react-performance (Vercel 기반 성능 규칙), frontend-patterns (프레임워크 간 UI 관심사), accessibility, angular-developer (프레임워크 비교)
- 에이전트:
react-reviewer(코드 리뷰),react-build-resolver(빌드/번들러 오류 처리) - 명령어:
/react-review,/react-build,/react-test
예제
디바운스된 검색을 위한 커스텀 훅
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const debounced = useDebounce(query, 300);
const { data } = useQuery({
queryKey: ["search", debounced],
queryFn: () => searchApi(debounced),
enabled: debounced.length > 0,
});
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results items={data ?? []} />
</>
);
}
React 19 useOptimistic을 사용한 낙관적 UI
export function MessageList({ messages }: { messages: Message[] }) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, newMessage],
);
async function send(formData: FormData) {
const text = String(formData.get("text"));
addOptimistic({ id: "pending", text, sender: "me" });
await saveMessage(text);
}
return (
<>
<ul>{optimistic.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<form action={send}>
<input name="text" />
<button type="submit">보내기</button>
</form>
</>
);
}
렌더링 캐스케이드를 방지하기 위한 컨텍스트 분할
// 두 개의 컨텍스트: 하나는 거의 변하지 않고, 다른 하나는 자주 변함
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// ThemeContext만 사용하는 컴포넌트는 알림이 변경되어도 리렌더링되지 않음