본문으로 건너뛰기

고급 훅 — 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/stringuseState
여러 필드를 가진 객체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​

항목useTransitionuseDeferredValue
사용 위치상태 업데이트 코드를 감쌈값을 받아서 지연 버전 반환
적합한 경우업데이트 함수에 접근 가능할 때외부에서 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}>