이번 편의 결과물: 다크/라이트 테마 토글과 상태 필터(전체/읽고 싶음/읽는 중/다 읽음)가 Context로 앱 전역에서 동작합니다. · 다루는 개념: props drilling, createContext, useContext, Provider
이 편에서 만드는 파일
bookshelf/src/
├── context/
│ ├── ThemeContext.jsx + 신규
│ └── FilterContext.jsx + 신규
├── components/
│ ├── Header.jsx + 신규 (h1·테마 토글·필터 버튼)
│ └── BookList.jsx ~ 수정 (statusFilter 적용)
├── main.jsx ~ 수정 (Provider로 감싸기)
└── App.jsx ~ 수정 (h1을 Header로 교체)개념 정리
12편까지 App이 books 상태와 화면 제목(<h1>bookshelf</h1>)을 함께 들고 있었습니다. 이제 테마와 상태 필터를 추가하면, Header(테마 버튼·필터 버튼)와 BookList(필터링)처럼 트리 여러 곳에서 같은 값이 동시에 필요해집니다. 이걸 전부 App → 자식으로 props 릴레이하면, 중간 컴포넌트가 자신과 무관한 props를 떠안는 props 드릴링이 생깁니다.
Context는 트리 아래 전체에 값을 방송하는 통로이며, 사용법은 세 단계입니다.
| 단계 | 함수 | 역할 |
|---|---|---|
| 1. 만들기 | createContext(기본값) | Context 객체 생성. 컴포넌트 밖에서 한 번만 |
| 2. 공급하기 | <Context.Provider value={...}> | 감싼 트리 전체에 값 방송 |
| 3. 구독하기 | useContext(Context) | 가장 가까운 위쪽 Provider의 값을 받음 |
value가 바뀌면 구독하는 컴포넌트 전원이 리렌더됩니다. useState와 묶으면 “바뀌는 전역 값”이 됩니다. Context는 만능이 아니며 판단 기준은 이렇습니다.
| 상황 | 권장 수단 |
|---|---|
| 한두 층 아래 전달 | props |
| 트리 전반에서 쓰는 테마·필터 | Context |
| 자주 바뀌는 대규모 데이터 | 후속 과목의 상태 관리 도구 영역 |
books 배열 자체는 아직 Context로 옮기지 않습니다. App이 소유하고 props로 내려주는 구조를 그대로 유지하고(14편에서 useReducer로 전환), 테마·필터처럼 값은 단순하지만 여러 곳에서 필요한 상태만 Context로 뺍니다.
실습
1. ThemeContext 만들기
// src/context/ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
function toggleTheme() {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
}
const value = { theme, toggleTheme };
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme은 ThemeProvider 내부에서만 사용합니다.');
}
return context;
}useTheme처럼 useContext 호출을 감싼 함수를 만들어 두면 useTheme() 한 줄로 끝나고, Provider 밖에서 잘못 호출했을 때 바로 에러로 알 수 있습니다.
2. FilterContext 만들기
// src/context/FilterContext.jsx
import { createContext, useContext, useState } from 'react';
const FilterContext = createContext(null);
export function FilterProvider({ children }) {
const [statusFilter, setStatusFilter] = useState('all');
const value = { statusFilter, setStatusFilter };
return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
}
export function useFilter() {
const context = useContext(FilterContext);
if (context === null) {
throw new Error('useFilter는 FilterProvider 내부에서만 사용합니다.');
}
return context;
}statusFilter는 'all', 'wish', 'reading', 'done' 중 하나입니다. 테마와 필터는 서로 관련 없는 값이라 Context를 분리했습니다. 하나로 합치면 필터만 바뀌어도 테마 구독 컴포넌트까지 리렌더됩니다.
3. main.jsx에서 Provider로 감싸기
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
import { ThemeProvider } from './context/ThemeContext.jsx';
import { FilterProvider } from './context/FilterContext.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<FilterProvider>
<App />
</FilterProvider>
</ThemeProvider>
</StrictMode>,
);App과 그 아래 모든 컴포넌트가 이제 useTheme(), useFilter()를 어디서든 호출할 수 있습니다.
4. Header 컴포넌트 만들기
App에 있던 <h1>bookshelf</h1>를 Header로 옮기고, 테마 토글과 필터 버튼을 추가합니다.
// src/components/Header.jsx
import { useTheme } from '../context/ThemeContext.jsx';
import { useFilter } from '../context/FilterContext.jsx';
const FILTERS = [
{ value: 'all', label: '전체' },
{ value: 'wish', label: '읽고 싶음' },
{ value: 'reading', label: '읽는 중' },
{ value: 'done', label: '다 읽음' },
];
function Header() {
const { theme, toggleTheme } = useTheme();
const { statusFilter, setStatusFilter } = useFilter();
return (
<header className="header">
<h1>bookshelf</h1>
<button type="button" onClick={toggleTheme}>
{theme === 'light' ? '다크 모드' : '라이트 모드'}
</button>
<div className="filter-bar">
{FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
className={statusFilter === filter.value ? 'active' : ''}
onClick={() => setStatusFilter(filter.value)}
>
{filter.label}
</button>
))}
</div>
</header>
);
}
export default Header;5. BookList에 필터 적용하기
BookList는 12편에서 만든 forwardRef 구조(맨 끝 sentinel <li>로 자동 스크롤 대상 표시)를 그대로 유지하면서, useFilter()로 걸러진 목록만 그립니다.
// src/components/BookList.jsx
import { forwardRef } from 'react';
import BookCard from './BookCard.jsx';
import { useFilter } from '../context/FilterContext.jsx';
const BookList = forwardRef(function BookList(
{ books, onDelete, onRate, onToggleStatus },
scrollTargetRef,
) {
const { statusFilter } = useFilter();
const visibleBooks =
statusFilter === 'all' ? books : books.filter((book) => book.status === statusFilter);
if (visibleBooks.length === 0) {
return <p className="empty-state">표시할 책이 없습니다.</p>;
}
return (
<ul className="book-list">
{visibleBooks.map((book) => (
<li key={book.id}>
<BookCard
book={book}
onDelete={onDelete}
onRate={onRate}
onToggleStatus={onToggleStatus}
/>
</li>
))}
<li ref={scrollTargetRef} className="scroll-sentinel" />
</ul>
);
});
export default BookList;BookList는 useFilter()를 직접 호출합니다. App이 statusFilter를 props로 내려줄 필요가 없습니다 — 이것이 props 드릴링을 없앤 결과입니다.
6. App.jsx에서 h1을 Header로 교체하기
// src/App.jsx (관련 부분만 발췌 — books 상태·useEffect·핸들러는 12편 그대로 유지)
import Header from './components/Header.jsx';
// ...기존 import 유지
function App() {
// ...기존 코드 유지
return (
<div className="app">
<Header />
<p>총 {books.length}권</p>
<BookList
ref={scrollTargetRef}
books={books}
onDelete={handleDeleteBook}
onRate={handleRateBook}
onToggleStatus={handleToggleStatus}
/>
<BookForm onAddBook={handleAddBook} />
</div>
);
}
export default App;7. 실행
npm run dev8. 확인
- “다크 모드” 버튼을 누르면 텍스트가 “라이트 모드”로 바뀝니다. 배경색 규칙이 아직 없다면 텍스트 전환만으로 우선 확인합니다.
- 필터 버튼(“전체”, “읽고 싶음”, “읽는 중”, “다 읽음”)을 클릭하면
BookList에 해당 상태의 책만 남습니다. - DevTools Components 탭에서
ThemeProvider,FilterProvider노드를 펼치면value객체를 볼 수 있습니다.
직접 해보기
index.css에[data-theme="dark"]배경·글자색 규칙을 추가하고,App최상위div에data-theme={theme}을 붙여 실제로 색이 바뀌게 만들어 보세요.- 필터 버튼 옆에 각 상태별 책 권수를 괄호로 표시해 보세요(
읽는 중 (2)).
정답 보기
App에서 useTheme()으로 theme을 꺼내 최상위 div에 data-theme={theme} 속성을 추가합니다. 권수 표시는 Header가 books를 props로 받아, FILTERS.map 안에서 각 상태에 해당하는 책 개수를 filter().length로 계산해 라벨 옆에 붙이면 됩니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
useTheme 에러 발생 | 컴포넌트가 ThemeProvider 바깥에서 렌더링됨 | main.jsx에서 App이 Provider 안에 있는지 확인 |
| 테마를 바꿔도 화면 색이 안 바뀜 | CSS 규칙 자체가 없음 | index.css에 [data-theme="dark"] 규칙 추가(직접 해보기 1번) |
| 필터를 바꿨는데 무관한 컴포넌트까지 리렌더됨 | 테마와 필터를 하나의 Context에 함께 넣음 | 관련 없는 값은 분리(본문처럼 둘로 유지) |
value에 매 렌더 새 객체 전달로 자식이 불필요하게 리렌더 | 지금 단계에서는 무시 가능한 수준 | 최적화(useMemo)는 react_2에서 다룬다 |