고급 훅 — useReducer, useContext, useId, useTransition, useDeferredValue
useReducer
useState의 대안으로, 복잡한 상태 로직을 action/dispatch 패턴으로 관리합니다. Redux에서 영감을 받은 패턴입니다.
import { useReducer } from 'react';
// 액션 타입 상수 정의
const TODO_ACTIONS = {
ADD: 'ADD',
TOGGLE: 'TOGGLE',
DELETE: 'DELETE',
CLEAR_COMPLETED: 'CLEAR_COMPLETED',
};
// 순수 함수 리듀서: (state, action) => newState
function todoReducer(state, action) {
switch (action.type) {
case TODO_ACTIONS.ADD:
return [
...state,
{ id: Date.now(), text: action.payload, done: false },
];
case TODO_ACTIONS.TOGGLE:
return state.map(todo =>
todo.id === action.payload
? { ...todo, done: !todo.done }
: todo
);
case TODO_ACTIONS.DELETE:
return state.filter(todo => todo.id !== action.payload);
case TODO_ACTIONS.CLEAR_COMPLETED:
return state.filter(todo => !todo.done);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, []);
const [input, setInput] = useState('');
function handleAdd() {
if (!input.trim()) return;
dispatch({ type: TODO_ACTIONS.ADD, payload: input });
setInput('');
}
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={handleAdd}>추가</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
onClick={() => dispatch({ type: TODO_ACTIONS.TOGGLE, payload: todo.id })}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: TODO_ACTIONS.DELETE, payload: todo.id })}>
삭제
</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: TODO_ACTIONS.CLEAR_COMPLETED })}>
완료 항목 삭제
</button>
</div>
);
}
useState vs useReducer
| 상황 | 권장 |
|---|---|
| 단순한 boolean/number/string | useState |
| 여러 필드를 가진 객체 | useReducer |
| 다음 상태가 이전 상태에 의존 | useReducer |
| 업데이트 로직을 외부로 분리하고 싶을 때 | useReducer |
| 복잡한 비즈니스 로직 테스트 | useReducer (순수 함수라 테스트 쉬움) |
useContext
컴포넌트 트리 전체에 데이터를 props 없이 전달합니다.
import { createContext, useContext, useState } from 'react';
// 1. Context 생성
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
// 2. Provider 컴포넌트
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
function toggleTheme() {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3. 커스텀 훅으로 래핑 (권장)
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme은 ThemeProvider 내부에서 사용해야 합니다.');
}
return context;
}
// 4. 소비
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={`header-${theme}`}>
<h1>앱 제목</h1>
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙 다크 모드' : '☀️ 라이트 모드'}
</button>
</header>
);
}
// 앱 루트에 Provider 추가
function App() {
return (
<ThemeProvider>
<Header />
<main>콘텐츠</main>
</ThemeProvider>
);
}
Context 최적화
Context 값이 바뀌면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다.
// ❌ 자주 바뀌는 값과 드물게 바뀌는 값이 같은 Context에 있으면 과도한 리렌더링
const AppContext = createContext({ user: null, theme: 'light', notifications: [] });
// ✅ 관심사별로 Context 분리
const UserContext = createContext(null); // 로그인 시 한 번만 변경
const ThemeContext = createContext('light'); // 테마 전환 시만 변경
const NotificationContext = createContext([]); // 자주 변경
useId
클라이언트와 서버 간 일관된 고유 ID를 생성합니다. 접근성(a11y)을 위한 label-input 연결에 유용합니다.
import { useId } from 'react';
function FormField({ label, type = 'text', ...rest }) {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type={type} {...rest} />
</div>
);
}
// 여러 인스턴스를 사용해도 ID 충돌 없음
function App() {
return (
<form>
<FormField label="이름" type="text" />
<FormField label="이메일" type="email" />
<FormField label="비밀번호" type="password" />
</form>
);
}
useTransition
긴급하지 않은(non-urgent) 상태 업데이트를 표시하여 UI 반응성을 유지합니다.
import { useState, useTransition } from 'react';
function SearchPage({ allItems }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(allItems);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
setQuery(value); // 긴급 업데이트: 입력값 즉시 반영
startTransition(() => {
// 비긴급 업데이트: React가 여유 있을 때 처리
const result = allItems.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFiltered(result);
});
}
return (
<div>
<input value={query} onChange={handleSearch} placeholder="검색..." />
{isPending && <span>검색 중...</span>}
<ul style={{ opacity: isPending ? 0.7 : 1 }}>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
useDeferredValue
값의 업데이트를 지연시켜 비싼 렌더링이 UI 블로킹을 유발하지 않게 합니다.
import { useState, useDeferredValue, memo } from 'react';
// 느린 컴포넌트 (수천 개 항목 렌더링)
const SlowList = memo(function SlowList({ query }) {
// 인위적으로 느린 렌더링 시뮬레이션
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `항목 ${i}`,
match: `항목 ${i}`.includes(query),
}));
return (
<ul>
{items.filter(i => i.match).map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
});
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // 지연된 값
const isStale = query !== deferredQuery; // 업데이트 대기 중 여부
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<SlowList query={deferredQuery} />
</div>
</div>
);
}
useTransition vs useDeferredValue
| 항목 | useTransition | useDeferredValue |
|---|---|---|
| 사용 위치 | 상태 업데이트 코드를 감쌈 | 값을 받아서 지연 버전 반환 |
| 적합한 경우 | 업데이트 함수에 접근 가능할 때 | 외부에서 prop으로 받은 값일 때 |
| pending 상태 | isPending 제공 | 직접 비교 필요 |
useImperativeHandle + forwardRef
부모 컴포넌트가 자식의 특정 메서드를 직접 호출할 수 있게 합니다.
import { forwardRef, useImperativeHandle, useRef } from 'react';
// 자식 컴포넌트: ref를 통해 노출할 메서드 정의
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null);
// 부모에게 노출할 메서드 정의
useImperativeHandle(ref, () => ({
play() {
videoRef.current.play();
},
pause() {
videoRef.current.pause();
},
seek(time) {
videoRef.current.currentTime = time;
},
get currentTime() {
return videoRef.current.currentTime;
},
}), []);
return <video ref={videoRef} src={src} />;
});
// 부모 컴포넌트
function VideoControls() {
const playerRef = useRef(null);
return (
<div>
<VideoPlayer ref={playerRef} src="/video.mp4" />
<button onClick={() => playerRef.current.play()}>재생</button>
<button onClick={() => playerRef.current.pause()}>정지</button>
<button onClick={() => playerRef.current.seek(0)}>처음으로</button>
</div>
);
}
실전 예제: 쇼핑 카트 상태 관리 (useReducer + useContext)
import { createContext, useContext, useReducer } from 'react';
// 카트 리듀서
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: action.payload.quantity }
: i
).filter(i => i.quantity > 0),
};
case 'CLEAR':
return { ...state, items: [] };
default:
return state;
}
}
const CartContext = createContext(null);
function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, { items: [] });
const total = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
return (
<CartContext.Provider value={{ cart, dispatch, total, itemCount }}>
{children}
</CartContext.Provider>
);
}
function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error('useCart는 CartProvider 안에서 사용하세요.');
return context;
}
// 사용 예시
function ProductCard({ product }) {
const { dispatch } = useCart();
return (
<div>
<h3>{product.name}</h3>
<p>{product.price.toLocaleString()}원</p>
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
장바구니에 추가
</button>
</div>
);
}
function CartSummary() {
const { cart, total, itemCount, dispatch } = useCart();
return (
<div>
<h2>장바구니 ({itemCount}개)</h2>
{cart.items.map(item => (
<div key={item.id}>
<span>{item.name} x {item.quantity}</span>
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
삭제
</button>
</div>
))}
<strong>합계: {total.toLocaleString()}원</strong>
</div>
);
}
고수 팁
1. Context + useReducer = 간단한 Redux
대부분의 중소 규모 앱은 Zustand나 Redux 없이 Context + useReducer로 충분합니다.
2. useTransition의 오해
startTransition 안의 코드는 여전히 동기적으로 실행됩니다. React가 해당 업데이트의 우선순위를 낮출 뿐이며, 비동기 API 호출을 기다려주지 않습니다.
3. Context 값 안정화
// ❌ 매 렌더링마다 새 객체 생성 → 모든 소비자 리렌더링
<MyContext.Provider value={{ user, logout }}>
// ✅ useMemo로 안정화
const value = useMemo(() => ({ user, logout }), [user, logout]);
<MyContext.Provider value={value}>