React 패턴
견고하고 접근 가능하며 성능이 우수한 컴포넌트 트리를 구축하기 위한 관용적인 React 18/19 패턴입니다.
활성화 시점
- React 함수 컴포넌트, 커스텀 훅, 또는 컴포넌트 트리를 작성하거나 수정할 때
- JSX/TSX 파일을 검토할 때
- 상태 구조나 컴포넌트 구성을 설계할 때
- 클래스 컴포넌트 또는
forwardRef/useEffect가 많은 레거시 코드를 마이그레이션할 때 - 로컬 상태, 끌어올린 상태, 컨텍스트, 외부 스토어 중에서 선택해야 할 때
- 서버 컴포넌트 / 클라이언트 컴포넌트(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 안에 위치해야 하며, 절대 렌더 본문 안에 두지 않습니다.
3. 상속보다는 합성을 사용합니다
React는 컴포넌트에 대한 상속 모델이 없습니다. children, render props, 또는 컴포넌트 props를 사용하여 합성합니다.
훅 규율
전체 규칙은 rules/react/hooks.md를 참조하세요. 주요 내용:
- 최상위 레벨에서만 호출, 조건부로 호출 금지
- 모든 구독, 인터벌, 리스너는 정리(cleanup) 필수
- 새 상태가 이전 상태에 의존하는 경우 함수형 업데이터 사용 (
setX(prev => prev + 1)) - 기본 원칙: 메모이제이션하지 않음 — 프로파일러나 의존성 체인이 중요함을 증명할 때만
useMemo/useCallback추가 - 동일한 훅 시퀀스가 2개 이상의 컴포넌트에 나타날 때만 커스텀 훅 추출
상태 위치 결정 트리
하나의 컴포넌트에서만 사용?
-> 그 컴포넌트 안에 useState 사용
부모 + 몇몇 하위 컴포넌트에서 사용?
-> 가장 가까운 공통 조상으로 끌어올리기
먼 분기에서 사용되며 읽기 빈도가 낮음 (테마, 인증, 로케일)?
-> React Context
트리 전체에서 공유되며 업데이트 빈도가 높음?
-> 외부 스토어 (Zustand, Jotai, Redux Toolkit)
서버에서 파생됨?
-> 서버 상태 라이브러리 (TanStack Query, SWR, RSC fetch)
대부분의 페이지는 컨텍스트나 전역 스토어가 필요하지 않습니다. 끌어올리기가 반복되어 고통스러워질 때까지는 추상화를 자제하세요.
서버 / 클라이언트 컴포넌트 (RSC)
// 서버 컴포넌트 - 기본값, async, 자체적으로 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"로 선택
"use client";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "Adding..." : "Add to cart"}
</button>
);
}
경계:
- 서버 -> 클라이언트: 직렬화 가능한 props 또는
children전달 - 클라이언트 -> 서버:
<form action={...}>를 통해 서버 액션 호출 또는 이벤트 핸들러에서 명령형으로 호출 - 클라이언트 컴포넌트 파일에서 서버 컴포넌트를
import하지 말고,children을 통해 합성하세요.
Suspense + 오류 경계
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
- Suspense 경계는 라우트 루트가 아닌 데이터에 가깝게 배치하여 콘텐츠를 점진적으로 공개합니다.
- 오류 경계는 여전히 클래스 API입니다. 훅 친화적인 래퍼를 위해
react-error-boundary를 사용하세요. - 경계는 자식의 렌더, 라이프사이클, 생성자에서 발생한 오류만 캐치합니다. 이벤트 핸들러나 비동기 코드의 오류는 캐치하지 않습니다.
폼
React 19 form actions (새 코드에 권장)
"use client";
import { useActionState } from "react";
const initial = { error: null as string | null };
async function updateUserAction(_prev: typeof initial, formData: FormData) {
"use server";
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "Invalid input" };
await db.user.update({ where: { id: parsed.data.id }, data: parsed.data });
return { error: null };
}
export function UserForm() {
const [state, formAction, pending] = useActionState(updateUserAction, initial);
return (
<form action={formAction}>
<input name="name" required />
<button type="submit" disabled={pending}>Save</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 |
| 일회성 fire-and-forget | 이벤트 핸들러에서 fetch() |
애플리케이션 데이터에 useEffect + fetch를 사용하지 마세요. 경쟁 조건, 캐시 부재, 재시도 없음, Suspense 통합 없음 등의 문제가 있습니다.
합성 레시피
Slot via children
<Layout>
<Header />
<Main>{content}</Main>
</Layout>
명명된 슬롯
<Page header={<Nav />} sidebar={<Filters />}>
<Results />
</Page>
복합 컴포넌트 (Context를 통한 공유 상태)
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>
Render prop / function-as-child
부모가 렌더링된 출력에 매개변수를 전달해야 할 때 유용합니다:
<DataLoader id={id}>
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
</DataLoader>
현대적인 대안: 동일한 형태를 반환하는 훅(useData(id)) — 일반적으로 더 깔끔합니다.
성능
React.memo가 실제로 도움이 되는 경우
다음 조건을 모두 만족할 때만 컴포넌트를 React.memo로 감싸세요:
- 자주 리렌더링됨
- props가 렌더링 사이에 일반적으로 동일함
- 렌더링 비용이 측정 가능할 정도로 큼
React.memo는 모든 렌더링마다 동등성 검사를 추가합니다. props가 대부분의 렌더링에서 다르다면, 이 검사는 순수한 오버헤드입니다.
렌더링 폭포 방지
- 가능하면 상태를 위로 끌어올리지 말고 아래로 내리세요.
- 컨텍스트 분할: 관심사별로 하나의 컨텍스트를 만들어
themeContext의 변경이 auth 소비자를 리렌더링하지 않도록 합니다. - 외부 상태 라이브러리에는
useSyncExternalStore를 사용하세요. 안전한 동시성 렌더링에 필수입니다.
리스트
- 안정적인
keyprops 제공 (데이터베이스 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
"use client";
import { useOptimistic } from "react";
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">Send</button>
</form>
</>
);
}
렌더 폭포를 방지하기 위한 컨텍스트 분할
// 두 개의 컨텍스트: 하나는 거의 변경되지 않고, 다른 하나는 자주 변경됨
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// ThemeContext만 소비하는 컴포넌트는 알림이 변경되어도 리렌더링되지 않음