이번 편의 결과물: 필터·정렬·사이드바 열림 상태가 Zustand 스토어로 옮겨지고 새로고침해도 유지됩니다. · 다루는 개념: Zustand 스토어 설계, selector로 구독 좁히기, persist 미들웨어, 서버 상태와 클라이언트 상태의 책임 분리
이 편에서 만드는 파일
bookshelf/src/
├── store/
│ └── useUiStore.js + 신규
├── pages/
│ └── BookListPage.jsx ~ 수정 (필터·사이드바 로컬 state → useUiStore)
└── components/
└── Header.jsx ~ 수정 (필터·사이드바 버튼을 store에 연결)개념 정리
01편에서 서버 상태(책 목록)와 클라이언트 상태(필터·테마 같은 화면 전용 값)를 구분했습니다. 04~06편에서 서버 상태는 TanStack Query가 맡았습니다. react_2가 남긴 bookshelf에는 필터·정렬 같은 화면 전용 상태를 위한 전역 도구가 따로 없었고, BookListPage가 useState로 검색어 하나만 들고 있었습니다(react_2 13편). 여기에 상태 필터·정렬 기준·사이드바 열림 여부까지 더하려니, 컴포넌트마다 useState를 반복하거나 props로 값을 계속 내려보내야 하는 부담이 생깁니다.
Zustand는 Provider 없이 훅 하나로 전역 상태를 만드는 라이브러리입니다.
| Context API | Zustand |
|---|---|
Provider로 트리를 감싸야 구독 가능 | create()로 만든 훅을 아무 컴포넌트에서나 바로 호출 |
value 객체가 바뀌면 구독자 전원 리렌더 | selector로 구독 범위를 필드 단위로 좁힘 |
| 상태·갱신 함수를 컴포넌트 트리 밖에 영속하기 어려움 | persist 미들웨어로 손쉽게 저장소 연동 |
스토어는 create 콜백 안에 상태와 그 상태를 바꾸는 함수를 함께 정의합니다.
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));컴포넌트에서는 useStore((state) => state.count)처럼 필요한 조각만 selector로 꺼내야 합니다. useStore()처럼 전체 상태를 그대로 구독하면 스토어의 어떤 필드가 바뀌어도 그 컴포넌트가 리렌더됩니다. persist 미들웨어는 스토어 상태를 지정한 저장소(기본은 localStorage)에 자동으로 동기화합니다.
| 상태 | 어디로 | 이유 |
|---|---|---|
| 책 목록, 책 상세 | TanStack Query | 서버가 진실의 원천, 캐시·재검증 필요 |
| 로그인 여부, 토큰 | 07편에서 만든 인증 흐름 | 인증은 별도 관심사 |
| 필터·정렬·사이드바 열림 | Zustand | 화면 전용, 서버와 무관, 새로고침 후에도 유지하고 싶음 |
실습
1. Zustand 설치
npm install zustand2. 스토어 작성
// src/store/useUiStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const initialFilters = {
statusFilter: 'all',
sortBy: 'title',
sortOrder: 'asc',
};
export const useUiStore = create(
persist(
(set) => ({
...initialFilters,
isSidebarOpen: false,
setStatusFilter: (statusFilter) => set({ statusFilter }),
setSort: (sortBy) =>
set((state) => ({
sortBy,
sortOrder: state.sortBy === sortBy && state.sortOrder === 'asc' ? 'desc' : 'asc',
})),
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
resetFilters: () => set(initialFilters),
}),
{ name: 'bookshelf-ui' },
),
);persist의 첫 인자는 평소와 같은 스토어 정의 함수이고, 두 번째 인자 { name: 'bookshelf-ui' }는 localStorage에 저장할 때 쓸 키 이름입니다. setSort는 컬럼 키 하나만 받습니다. 같은 컬럼을 다시 누르면 방향을 뒤집고, 다른 컬럼을 누르면 오름차순으로 새로 시작합니다. resetFilters는 14편의 테스트가 각 테스트 사이에 스토어를 초기화할 때도 그대로 재사용합니다.
3. BookListPage를 useUiStore에 연결
react_2에서 BookListPage가 useState로 들고 있던 검색어를 스토어의 statusFilter로 옮깁니다.
// src/pages/BookListPage.jsx (필터 관련 부분만 발췌 — 나머지는 기존 코드 유지)
import { useUiStore } from '../store/useUiStore';
// ...기존 import 유지(useQuery, api 등)
const FILTERS = [
{ value: 'all', label: '전체' },
{ value: 'wish', label: '읽고 싶음' },
{ value: 'reading', label: '읽는 중' },
{ value: 'done', label: '다 읽음' },
];
function BookListPage() {
const statusFilter = useUiStore((state) => state.statusFilter);
// ...기존 useQuery(['books', statusFilter], ...) 유지
// ...기존 로딩·에러 분기, BookList 렌더링 유지
}
export default BookListPage;statusFilter만 selector로 꺼냈습니다. sortBy나 isSidebarOpen이 바뀌어도 이 컴포넌트는 리렌더되지 않습니다.
4. Header에서 필터·사이드바 버튼 연결
// src/components/Header.jsx
import { useTheme } from '../context/ThemeContext';
import { useUiStore } from '../store/useUiStore';
const FILTERS = [
{ value: 'all', label: '전체' },
{ value: 'wish', label: '읽고 싶음' },
{ value: 'reading', label: '읽는 중' },
{ value: 'done', label: '다 읽음' },
];
function Header() {
const { theme, toggleTheme } = useTheme();
const statusFilter = useUiStore((state) => state.statusFilter);
const setStatusFilter = useUiStore((state) => state.setStatusFilter);
const isSidebarOpen = useUiStore((state) => state.isSidebarOpen);
const toggleSidebar = useUiStore((state) => state.toggleSidebar);
return (
<header className="header">
<h1>bookshelf</h1>
<button type="button" onClick={toggleSidebar} aria-expanded={isSidebarOpen}>
{isSidebarOpen ? '필터 닫기' : '필터 열기'}
</button>
<button type="button" onClick={toggleTheme}>
{theme === 'light' ? '다크 모드' : '라이트 모드'}
</button>
{isSidebarOpen && (
<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;각 훅 호출이 한 필드씩만 구독합니다. toggleSidebar처럼 함수만 꺼내는 selector는 함수 참조가 바뀌지 않아 리렌더를 더 일으키지 않습니다.
5. 실행
npm run dev6. 확인
- “필터 열기”를 누르면 필터 버튼 목록이 나타나고, 항목을 클릭하면 목록이 걸러집니다.
- 브라우저를 새로고침해도 방금 고른 필터와 사이드바 열림 상태가 그대로 유지됩니다.
- 개발자 도구 애플리케이션 탭에서
localStorage의bookshelf-ui키를 열어statusFilter,sortBy,isSidebarOpen값이 JSON으로 저장된 것을 확인합니다. - React DevTools의 Components 탭에서
BookListPage에useState대신useUiStore구독만 남아 있는지 확인합니다.
직접 해보기
Header에 “필터 초기화” 버튼을 추가해resetFilters를 호출해 보세요.persist의 두 번째 인자에partialize: (state) => ({ statusFilter: state.statusFilter })를 추가하면statusFilter만 저장되고 나머지는 저장되지 않습니다. 직접 넣어 동작을 비교해 보세요.
정답 보기
// src/components/Header.jsx (버튼 추가)
<button type="button" onClick={() => useUiStore.getState().resetFilters()}>
필터 초기화
</button>partialize를 추가하면 localStorage에는 statusFilter만 남고, sortBy·isSidebarOpen은 새로고침 시 정의한 초깃값('title', false)으로 돌아갑니다. 민감하지 않은 값이라도 꼭 저장할 필요가 없는 필드는 partialize로 걸러내는 습관이 좋습니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 필터 하나만 바꿨는데 사이드바·테마 관련 컴포넌트까지 리렌더됨 | useUiStore()로 스토어 전체를 구독 | useUiStore((state) => state.statusFilter)처럼 selector로 좁힌다 |
| 새로고침 후 필터가 초깃값으로 돌아감 | persist 미들웨어를 안 씌움 | create(persist(...)) 형태인지 확인 |
set을 호출했는데 다른 필드가 사라짐 | set({ statusFilter })처럼 객체 리터럴로만 교체(Zustand는 얕은 병합이라 괜찮지만, 함수형 set((state) => ...) 안에서 state를 안 펼치고 새 객체만 반환) | set((state) => ({ ...state, statusFilter })) 또는 Zustand의 얕은 병합 특성을 이해하고 필요한 키만 반환 |
| 서버 상태(책 목록)까지 Zustand에 넣고 싶어짐 | 캐시 무효화·재검증 로직을 직접 다시 구현해야 함 | 서버에서 온 데이터는 TanStack Query, 화면 전용 상태만 Zustand |