본문으로 건너뛰기
Advertisement

고급 훅 — 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}>
Advertisement