Skip to Content
WebReactReact 실무09. Zustand로 클라이언트 전역 상태 분리

이번 편의 결과물: 필터·정렬·사이드바 열림 상태가 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에는 필터·정렬 같은 화면 전용 상태를 위한 전역 도구가 따로 없었고, BookListPageuseState로 검색어 하나만 들고 있었습니다(react_2 13편). 여기에 상태 필터·정렬 기준·사이드바 열림 여부까지 더하려니, 컴포넌트마다 useState를 반복하거나 props로 값을 계속 내려보내야 하는 부담이 생깁니다.

Zustand는 Provider 없이 훅 하나로 전역 상태를 만드는 라이브러리입니다.

Context APIZustand
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 zustand

2. 스토어 작성

// 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에서 BookListPageuseState로 들고 있던 검색어를 스토어의 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로 꺼냈습니다. sortByisSidebarOpen이 바뀌어도 이 컴포넌트는 리렌더되지 않습니다.

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 dev

6. 확인

  • “필터 열기”를 누르면 필터 버튼 목록이 나타나고, 항목을 클릭하면 목록이 걸러집니다.
  • 브라우저를 새로고침해도 방금 고른 필터와 사이드바 열림 상태가 그대로 유지됩니다.
  • 개발자 도구 애플리케이션 탭에서 localStoragebookshelf-ui 키를 열어 statusFilter, sortBy, isSidebarOpen 값이 JSON으로 저장된 것을 확인합니다.
  • React DevTools의 Components 탭에서 BookListPageuseState 대신 useUiStore 구독만 남아 있는지 확인합니다.

직접 해보기

  1. Header에 “필터 초기화” 버튼을 추가해 resetFilters를 호출해 보세요.
  2. 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

확인 문제

문제 14지선다
Zustand 스토어에서 리렌더 범위를 좁히는 방법은?
문제 24지선다
이 편에서 필터·정렬·사이드바 상태를 컴포넌트 로컬 state 대신 Zustand로 옮긴 이유로 가장 알맞은 것은?
문제 34지선다
persist 미들웨어의 name 옵션이 하는 역할은?
문제 44지선다
책 목록(서버에서 받아온 데이터)을 Zustand 스토어에 넣지 않고 TanStack Query에 남겨두는 이유는?

참고 자료

Last updated on