Zustand state 사용법: 값 읽기와 변경 흐름 익히기

2026.05.05·수정 2026.07.20·약 19분

Zustand v5: selector로 읽고 action과 set으로 변경하는 기준

이 글은 React에서 여러 컴포넌트가 공유하는 클라이언트 상태를 Zustand store로 옮긴 뒤 값이 안 바뀌거나 불필요한 리렌더가 생기는 독자를 위한 글입니다. 현재 create API로 store를 만들고, 컴포넌트는 selector로 필요한 state와 action만 구독하며, 변경은 set을 통해 새 값으로 수행합니다. 얕은 병합, 중첩 객체, 전체 store 구독, useShallow, 비반응형 getState(), persist hydration까지 예외를 분리해 진단할 수 있습니다.

검증 기준: Zustand 공식 저장소의 최신 릴리스 v5.0.14와 현재 공식 문서를 2026-07-19에 확인했습니다. 예제는 React 18 이상을 요구하는 Zustand v5 TypeScript API를 기준으로 합니다.

대상: Zustand에 둘 상태의 범위

Zustand는 React 컴포넌트 밖에 store를 만들고 여러 컴포넌트가 필요한 부분을 구독하게 합니다. 탭·필터·모달 상태처럼 멀리 떨어진 컴포넌트가 함께 읽고 바꾸는 클라이언트 상태가 이해하기 좋은 예입니다. 다만 모든 useState를 전역 store로 옮기는 것이 목표는 아닙니다.

지역 상태와 서버 상태를 먼저 분리합니다

상태 우선 선택 이유
한 입력 컴포넌트만 쓰는 값 지역 useState 소유 범위를 넓힐 필요가 없습니다.
여러 화면 조각이 공유하는 필터·탭 Zustand 검토 props 전달 없이 같은 변경 규칙을 공유할 수 있습니다.
서버에서 가져온 목록의 캐시·재검증 서버 상태 도구 검토 클라이언트 store와 요청 캐시는 수명주기가 다릅니다.

Zustand가 UI 상태만 저장할 수 있다는 뜻은 아닙니다. store에는 다양한 값을 둘 수 있지만, 데이터 소유권과 갱신 주기가 다른 값을 한곳에 섞지 않는 것이 진단과 테스트에 유리합니다.

재현 기반 store 만들기

현재 TypeScript API에서 create<T>()(stateCreatorFn)은 React Hook과 setState, getState, getInitialState, subscribe 유틸리티가 붙은 store를 만듭니다. state와 관련 action을 한 객체에 두면 값과 변경 규칙을 함께 찾을 수 있습니다.

Zustand v5 기본 store

import { create } from 'zustand';

type CounterStore = {
  count: number;
  increase: () => void;
  decrease: () => void;
  reset: () => void;
};

export const useCounterStore = create<CounterStore>()((set) => ({
  count: 0,
  increase: () =>
    set((state) => ({ count: state.count + 1 })),
  decrease: () =>
    set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

increasedecrease는 이전 state가 필요하므로 함수형 set을 사용합니다. reset은 이전 값과 무관한 고정값이므로 객체를 전달합니다. 기본 set이 최상위 state를 얕게 병합하므로 count만 전달해도 action 필드는 유지됩니다.

Zustand store에서 컴포넌트가 필요한 state와 action만 선택해 사용하는 구조 설명
컴포넌트는 store 전체 구현보다 자신에게 필요한 state와 action의 selector에 의존합니다.

selector로 값 읽기

useCounterStore((state) => state.count)count를 선택하고 그 결과를 구독합니다. selector 결과가 이전 결과와 Object.is로 다를 때 해당 컴포넌트가 다시 렌더됩니다. 어떤 값 때문에 화면이 바뀌는지가 코드에 드러나므로 store가 커져도 의존 범위를 추적하기 쉽습니다.

값과 action을 각각 선택

import { useCounterStore } from './counterStore';

export function CounterPanel() {
  const count = useCounterStore((state) => state.count);
  const increase = useCounterStore((state) => state.increase);
  const reset = useCounterStore((state) => state.reset);

  return (
    <section>
      <p>현재 값: {count}</p>
      <button type="button" onClick={increase}>+1</button>
      <button type="button" onClick={reset}>초기화</button>
    </section>
  );
}

여러 값을 새 객체로 반환하면 useShallow를 검토

selector가 매번 새 객체나 배열을 계산해 반환하면 내용이 같아도 새 참조입니다. 여러 선택 결과를 한 객체로 묶고 최상위 값이 같을 때 리렌더를 줄이려면 현재 v5의 useShallow를 사용할 수 있습니다.

import { useShallow } from 'zustand/react/shallow';

const { count, increase } = useCounterStore(
  useShallow((state) => ({
    count: state.count,
    increase: state.increase,
  })),
);

useShallow는 모든 selector에 의무가 아닙니다. 원시 값 하나나 안정된 action 함수 하나를 선택한다면 기본 비교로 충분합니다. 계산 selector의 결과가 새 객체·배열이지만 이전 결과와 얕게 같은 경우에 적용합니다. 공식 기준은 Prevent rerenders with useShallow에서 확인할 수 있습니다.

action과 set으로 변경하기

Zustand는 set 또는 store의 setState를 통해 변경해야 병합과 구독자 알림이 수행됩니다. 컴포넌트가 store 객체의 필드를 직접 대입하면 변경 규칙을 우회하고 구독자에게 올바른 알림을 주지 못합니다.

action을 store에 함께 두는 것은 권장 패턴입니다

공식 Flux-inspired practice는 관련 action을 store에 함께 배치하는 방식을 권장합니다. 여러 버튼이 같은 openFilter를 호출하면 조건 변경도 한곳에서 할 수 있습니다. 다만 Zustand는 비규범적인 라이브러리이므로 외부 함수가 useStore.setState를 호출하는 패턴도 지원합니다. “action은 반드시 store 안에만 있어야 한다”로 일반화하지 않습니다.

이전 값이 필요한지로 set 형태를 선택

  • set({ isOpen: true }): 이전 값과 무관한 지정·초기화
  • set((state) => ({ count: state.count + 1 })): 증가·토글·배열 추가처럼 이전 값에 의존
  • useStore.setState(partial): store 밖 action이나 테스트에서 명시적으로 변경할 때 사용 가능

현재 API와 updater 예시는 Zustand create API, action 배치 기준은 Flux inspired practice를 따릅니다.

중첩 객체와 얕은 병합

Zustand의 기본 set은 최상위 한 단계만 얕게 병합합니다. set({ count: 1 })처럼 최상위 필드 하나를 바꿀 때 전체 state를 펼칠 필요는 없습니다. 하지만 user.role처럼 중첩된 값은 내부 객체를 직접 병합해 주지 않으므로 새 user 객체를 만들어야 합니다.

중첩 객체의 변경 경로를 복사

import { create } from 'zustand';

type UserStore = {
  user: {
    name: string;
    role: 'admin' | 'member';
  };
  changeRole: (role: 'admin' | 'member') => void;
};

export const useUserStore = create<UserStore>()((set) => ({
  user: { name: 'Haebi', role: 'member' },
  changeRole: (role) =>
    set((state) => ({
      user: {
        ...state.user,
        role,
      },
    })),
}));

replace 플래그는 병합이 아니라 전체 교체입니다

set(nextState, true) 또는 setState(nextState, true)는 기본 얕은 병합을 끄고 store 전체를 교체합니다. state와 action이 한 객체에 있으면 불완전한 교체가 action까지 제거할 수 있습니다. 단순 reset은 기존 action을 보존하는 부분 업데이트로 작성하고, 전체 교체가 정말 필요할 때만 완전한 state 형태와 v5 타입을 확인합니다. 자세한 병합 기준은 Immutable state and mergingUpdating state를 참고합니다.

전체 구독·getState·persist 예외

store 전체 구독은 가능하지만 구독 범위가 넓습니다

// 가능하지만 store의 어떤 변경에도 다시 렌더될 수 있습니다.
const wholeStore = useCounterStore();

// 렌더가 실제로 사용하는 값만 구독합니다.
const count = useCounterStore((state) => state.count);

작은 store를 실제로 모두 사용하는 컴포넌트라면 전체 구독이 틀린 코드는 아닙니다. 문제는 store가 커진 뒤 컴포넌트가 사용하지 않는 값의 변경에도 반응하고, 의존 관계가 코드에서 보이지 않는 경우입니다. 성능 문제가 확인되지 않았는데 모든 selector에 복잡한 최적화를 미리 추가할 필요도 없습니다.

getState는 현재 값을 읽지만 렌더를 구독하지 않습니다

useCounterStore.getState()는 이벤트, 비-React 코드, 테스트에서 현재 store 값을 즉시 읽을 때 유용합니다. 하지만 컴포넌트 렌더에서 이 값만 읽으면 이후 변경을 구독하지 않습니다. 화면에 표시할 값은 Hook selector로 읽고, 단발성 검사에는 getState()를 사용합니다.

persist를 붙였다면 hydration을 별도 원인으로 봅니다

새로고침 후 초기 값이 예상과 다르면 selector와 action만 확인해서는 부족할 수 있습니다. persist middleware의 storage, hydration 시점, 저장된 버전과 merge·migration 구성을 별도로 확인합니다. persist는 기본 읽기·변경 흐름을 대체하지 않고 초기 복원 단계를 추가합니다. 공식 옵션은 Persisting store data에 정리되어 있습니다.

UI 상태 예제: 관리자 탭과 필터

다음 store는 서버 목록 자체가 아니라 여러 컴포넌트가 공유하는 관리자 화면의 탭과 필터 열림 상태만 관리합니다. 값과 이름 있는 action을 같은 store에 두고, 컴포넌트는 사용하는 두 항목만 선택합니다.

import { create } from 'zustand';

type AdminTab = 'summary' | 'orders' | 'settings';

type AdminUiStore = {
  selectedTab: AdminTab;
  isFilterOpen: boolean;
  selectTab: (tab: AdminTab) => void;
  toggleFilter: () => void;
};

export const useAdminUiStore = create<AdminUiStore>()((set) => ({
  selectedTab: 'summary',
  isFilterOpen: false,
  selectTab: (tab) => set({ selectedTab: tab }),
  toggleFilter: () =>
    set((state) => ({
      isFilterOpen: !state.isFilterOpen,
    })),
}));

export function AdminToolbar() {
  const selectedTab = useAdminUiStore(
    (state) => state.selectedTab,
  );
  const toggleFilter = useAdminUiStore(
    (state) => state.toggleFilter,
  );

  return (
    <header>
      <p>선택된 탭: {selectedTab}</p>
      <button type="button" onClick={toggleFilter}>
        필터 열기/닫기
      </button>
    </header>
  );
}
관리자 UI 상태를 Zustand selector와 action으로 분리해 관리하는 예시 구조
표시 문제는 selector, 변경 문제는 action과 set, 초기 복원 문제는 persist hydration으로 원인 범위를 나눕니다.

원인 진단과 검증

화면이 바뀌지 않을 때 순서

  1. 버튼·입력 이벤트가 의도한 action을 실제 호출하는지 확인합니다.
  2. action이 직접 대입이 아니라 set 또는 setState를 사용하는지 확인합니다.
  3. getState()로 action 전후 값이 실제 바뀌는지 확인합니다.
  4. 컴포넌트 selector가 바뀐 필드를 선택하는지 확인합니다.
  5. 중첩 객체의 바뀌는 단계마다 새 참조를 만들었는지 확인합니다.
  6. persist 사용 시 hydration과 저장된 이전 구조를 별도 확인합니다.

store 동작을 UI와 분리해 빠르게 확인

const before = useCounterStore.getState().count;

useCounterStore.getState().increase();

const after = useCounterStore.getState().count;
console.assert(after === before + 1);

// 각 테스트 뒤에는 다음 테스트가 공유 state를 물려받지 않게 복원합니다.
useCounterStore.setState({ count: 0 });
검증 기대 결과 실패 원인 후보
increase()getState() count가 정확히 1 증가 action·set 계산
count selector 컴포넌트 count 변경 때 다시 렌더 잘못된 selector·다른 store 인스턴스
관련 없는 필드 변경 count 단일 selector 결과가 같으면 렌더 불필요 전체 store 구독·새 계산 객체
중첩 role 변경 name 보존, user 새 참조 직접 변이·내부 객체 미복사
새로고침 후 값 persist 설계와 일치 hydration·version·merge·migration

Zustand 공식 출처와 내부 학습 경로

확인일: 2026-07-19. 현재 릴리스와 API 설명은 Zustand 공식 문서 및 pmndrs 공식 저장소만 근거로 사용했습니다.

공식 문서·릴리스

내부 학습 경로

결론: 읽기·변경·복원을 서로 다른 경계로 확인하세요

Zustand의 기본 흐름은 단순합니다. 현재 v5의 create로 store를 만들고, 컴포넌트는 실제로 사용하는 값과 action을 selector로 구독하며, action은 set으로 새 state를 만듭니다. 최상위 부분 변경은 기본 얕은 병합을 활용하고, 중첩 객체는 변경 경로를 직접 복사합니다. 전체 구독과 useShallow는 금지·필수 규칙이 아니라 selector 결과와 실제 렌더 범위에 따라 선택합니다.

화면이 갱신되지 않는다면 먼저 getState()로 action 전후 값을 확인하고, 값이 바뀌었다면 selector를, 값이 바뀌지 않았다면 action과 중첩 업데이트를 보세요. persist를 쓴 경우에만 hydration을 세 번째 경계로 추가합니다. 이 순서로 카운터 증가, 관련 없는 필드 변경, 중첩 role 변경, 새로고침 복원을 각각 검증하면 원인을 한꺼번에 추측하지 않고 좁힐 수 있습니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“Zustand state 사용법: 값 읽기와 변경 흐름 익히기”에 대한 1개의 생각

댓글 남기기