Skip to Content
WebReactReact 기초13. Context로 테마·필터 공유하기

이번 편의 결과물: 다크/라이트 테마 토글과 상태 필터(전체/읽고 싶음/읽는 중/다 읽음)가 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편까지 Appbooks 상태와 화면 제목(<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;

BookListuseFilter()를 직접 호출합니다. AppstatusFilter를 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 dev

8. 확인

  • “다크 모드” 버튼을 누르면 텍스트가 “라이트 모드”로 바뀝니다. 배경색 규칙이 아직 없다면 텍스트 전환만으로 우선 확인합니다.
  • 필터 버튼(“전체”, “읽고 싶음”, “읽는 중”, “다 읽음”)을 클릭하면 BookList에 해당 상태의 책만 남습니다.
  • DevTools Components 탭에서 ThemeProvider, FilterProvider 노드를 펼치면 value 객체를 볼 수 있습니다.

직접 해보기

  1. index.css[data-theme="dark"] 배경·글자색 규칙을 추가하고, App 최상위 divdata-theme={theme}을 붙여 실제로 색이 바뀌게 만들어 보세요.
  2. 필터 버튼 옆에 각 상태별 책 권수를 괄호로 표시해 보세요(읽는 중 (2)).

정답 보기

App에서 useTheme()으로 theme을 꺼내 최상위 divdata-theme={theme} 속성을 추가합니다. 권수 표시는 Headerbooks를 props로 받아, FILTERS.map 안에서 각 상태에 해당하는 책 개수를 filter().length로 계산해 라벨 옆에 붙이면 됩니다.

자주 하는 실수

증상원인고치는 법
useTheme 에러 발생컴포넌트가 ThemeProvider 바깥에서 렌더링됨main.jsx에서 AppProvider 안에 있는지 확인
테마를 바꿔도 화면 색이 안 바뀜CSS 규칙 자체가 없음index.css[data-theme="dark"] 규칙 추가(직접 해보기 1번)
필터를 바꿨는데 무관한 컴포넌트까지 리렌더됨테마와 필터를 하나의 Context에 함께 넣음관련 없는 값은 분리(본문처럼 둘로 유지)
value에 매 렌더 새 객체 전달로 자식이 불필요하게 리렌더지금 단계에서는 무시 가능한 수준최적화(useMemo)는 react_2에서 다룬다

확인 문제

문제 14지선다
props 드릴링(props drilling)에 대한 설명으로 가장 알맞은 것은?
문제 24지선다
Context 사용의 올바른 순서는?
문제 34지선다
useContext(ThemeContext)가 돌려주는 값은?
문제 44지선다
이 편에서 ThemeContext와 FilterContext를 하나로 합치지 않고 분리한 이유로 가장 알맞은 것은?
문제 54지선다
BookList 컴포넌트가 App으로부터 statusFilter를 props로 받지 않고 useFilter()를 직접 호출하는 이유는?

참고 자료

Last updated on