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

2026.05.05·수정 2026.09.14·약 37분·작성: 해비·블로그 소개

학습 목표·선수 지식: 값 읽기·변경·복원을 분리하고 중첩 role 변경이 name과 원본 객체를 보존하는지 확인합니다. 선수 지식: 기본 store 생성과 React 이벤트.

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

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

검증 기준: 이 실습에 설치한 Zustand v5.0.15와 현재 공식 문서를 2026-09-12에 확인했습니다. 예제는 React 18 이상을 요구하는 Zustand v5 TypeScript API를 기준으로 합니다.

실습 경로: 현재 ZIP전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.

대상: 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

기본 카운터 전체 코드는 src/stores/useCounterStore.ts입니다. src/counterStore.ts는 같은 인스턴스를 다시 export합니다. useCounterStore.ts 전체 코드

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을 각각 선택

패널 전체 코드는 src/components/CounterPanel.tsx입니다. 패널·배지가 같은 count를 표시합니다. CounterPanel.tsx 전체 코드

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

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

Hook 호출은 컴포넌트 또는 커스텀 Hook 함수 본문 안에 넣는 부분 코드입니다. import는 파일의 최상위에 두고, Hook을 모듈 최상위에서 호출하지 마세요.

개념 부분 코드: src/examples/post-2928-1.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

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를 따릅니다.

중첩 객체와 얕은 병합

전체 프로젝트의 역할 변경 버튼은 name을 보존하면서 새 user 객체를 만듭니다. 변경 전 user를 변수에 보관한 뒤 role을 바꾸어도 이전 객체의 role은 member로 남아야 합니다. set은 최상위만 병합하므로 이 불변성 확인이 중첩 객체 실습의 핵심입니다.

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

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

중첩 객체 전체 코드는 src/stores/useUserStore.ts입니다. role만 바꾸되 name과 이전 객체를 보존합니다. useUserStore.ts 전체 코드

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

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

전체 구독·getState·persist 예외

selector 비교가 제한하는 것은 store 구독으로 시작한 렌더입니다. 부모 렌더나 지역 state·context가 원인인 렌더는 별도로 가능합니다. 또한 Zustand 5의 기본 hook에서 매번 새 객체를 반환하면 단순한 렌더 증가를 넘어 안정된 snapshot 요구를 위반할 수 있으므로 useShallow나 분리 선택을 사용합니다.

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

Hook 호출은 컴포넌트 또는 커스텀 Hook 함수 본문 안에 넣는 부분 코드입니다. import는 파일의 최상위에 두고, Hook을 모듈 최상위에서 호출하지 마세요.

개념 부분 코드: src/examples/post-2928-2.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

// 가능하지만 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에 두고, 컴포넌트는 사용하는 두 항목만 선택합니다.

관리자 UI 전체 코드는 src/components/AdminToolbar.tsx입니다. 선택한 탭과 필터 열림을 따로 읽고 변경합니다. AdminToolbar.tsx 전체 코드

관리자 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와 분리해 빠르게 확인

개념 부분 코드: src/examples/post-2928-3.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

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-09-12. API 설명은 Zustand 공식 문서 및 pmndrs 공식 저장소만 근거로 사용했습니다.

공식 문서·릴리스

내부 학습 경로

실습 파일 안내

이전 ZIP은 과거 시작·완성본을 보관한 링크입니다. 이번 보완에서 제공하는 새 ZIP은 현재 본문과 일치하는 완성형 프로젝트이며 아래 전체 코드와 함께 사용합니다.

현재 본문에 맞는 실행 프로젝트는 아래 새 ZIP과 전체 코드를 사용합니다.

설치부터 확인까지: 전체 실행 프로젝트

Node.js 24와 npm을 준비하고 ZIP을 푼 프로젝트 폴더에서 아래 순서로 실행합니다. 여러 파일을 함께 실행하는 완성형 실습입니다. 뷰어의 파일 경로는 ZIP 프로젝트 루트 기준이며, 짧은 개념 예제와 별개입니다.

npm install
npm run build
npm test
npm run dev

Vite가 출력한 로컬 주소를 여세요. 서버·인증·외부 API 없이 실행하는 브라우저 SPA입니다.

현재 실습 ZIP

현재 본문과 같은 실습 파일 내려받기

이전 버전 참고 파일

이전 학습·복구용 자료입니다. 지금 본문을 따라갈 때는 위 현재 실습 파일을 사용하세요.

README.md

# Zustand counter 실습
Node.js 24 및 npm. 이 폴더에서 `npm install`, `npm run build`, `npm test`, `npm run dev` 순서로 실행합니다. Vite가 표시한 로컬 주소를 여세요.
Zustand 5.0.15 / React 19.3.0 / TypeScript 7.0.2 기준입니다.
독립 브라우저 SPA이며 서버·인증·외부 API는 포함하지 않습니다. 자동 테스트는 Vitest와 jsdom 또는 메모리 저장소를 사용합니다. 브라우저 실제 새로고침·기기 성능 검사는 별도로 수행해야 합니다.

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Zustand 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

package.json

{
  "name": "zustand-counter",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "zustand": "5.0.15"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1"
  }
}

src/App.test.tsx

// @vitest-environment jsdom
import { expect, test } from 'vitest';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { useCounterStore } from './stores/useCounterStore';

(
  globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
test('두 컴포넌트가 증가와 초기화를 함께 보여 준다', async () => {
  useCounterStore.setState({ count: 0 });
  const host = document.createElement('div');
  document.body.append(host);
  const root = createRoot(host);
  function button(label: string) {
    const found = [...host.querySelectorAll('button')].find(
      (item) => item.textContent?.trim() === label,
    );
    if (!found) throw new Error(`버튼 없음: ${label}`);
    return found;
  }
  try {
    await act(async () => root.render(<App />));
    await act(async () => button('+1').click());
    expect(host.textContent).toContain('현재 값: 1');
    expect(host.textContent).toContain('카운트 1');
    await act(async () => button('초기화').click());
    expect(host.textContent).toContain('현재 값: 0');
    expect(host.textContent).toContain('카운트 0');
  } finally {
    await act(async () => root.unmount());
    host.remove();
    useCounterStore.setState({ count: 0 });
  }
});

src/App.tsx

import { CounterPanel } from './components/CounterPanel';
import { CounterBadge } from './components/CounterBadge';
import { AdminToolbar, useAdminUiStore } from './components/AdminToolbar';
import { useUserStore } from './stores/useUserStore';
export default function App() {
  const user = useUserStore((s) => s.user);
  const changeRole = useUserStore((s) => s.changeRole);
  const open = useAdminUiStore((s) => s.isFilterOpen);
  const selectTab = useAdminUiStore((s) => s.selectTab);
  return (
    <main>
      <h1>Zustand 공유 카운터</h1>
      <CounterPanel />
      <CounterBadge />
      <section>
        <h2>객체 상태</h2>
        <p>
          {user.name}: {user.role}
        </p>
        <button onClick={() => changeRole(user.role === 'member' ? 'admin' : 'member')}>
          역할 변경
        </button>
      </section>
      <AdminToolbar />
      <p role="status">{open ? '필터 열림' : '필터 닫힘'}</p>
      <button onClick={() => selectTab('orders')}>주문 탭</button>
    </main>
  );
}

src/components/AdminToolbar.tsx

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>
  );
}

src/components/CounterBadge.tsx

import { useCounterStore } from '../stores/useCounterStore';
export function CounterBadge() {
  const count = useCounterStore((state) => state.count);
  return <span>카운트 {count}</span>;
}

src/components/CounterPanel.tsx

import { useCounterStore } from '../stores/useCounterStore';
export function CounterPanel() {
  const count = useCounterStore((state) => state.count);
  const increase = useCounterStore((state) => state.increase);
  const decrease = useCounterStore((state) => state.decrease);
  const reset = useCounterStore((state) => state.reset);
  return (
    <section>
      {' '}
      <p>현재 값: {count}</p>{' '}
      <button type="button" onClick={decrease}>
        {' '}
        -1{' '}
      </button>{' '}
      <button type="button" onClick={increase}>
        {' '}
        +1{' '}
      </button>{' '}
      <button type="button" onClick={reset}>
        {' '}
        초기화{' '}
      </button>{' '}
    </section>
  );
}

src/counterStore.ts

// 2928 글의 ./counterStore 경로를 같은 공유 store로 연결합니다.
export { useCounterStore } from './stores/useCounterStore';

src/main.tsx

import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('root 요소가 필요합니다.');
createRoot(root).render(<App />);

src/store.test.ts

import { beforeEach, expect, test } from 'vitest';
import { useCounterStore } from './stores/useCounterStore';
import { useUserStore } from './stores/useUserStore';
import { useAdminUiStore } from './components/AdminToolbar';
beforeEach(() => {
  useCounterStore.setState({ count: 0 });
  useUserStore.setState({ user: { name: 'Haebi', role: 'member' } });
  useAdminUiStore.setState({ selectedTab: 'summary', isFilterOpen: false });
});
test('증가 감소 초기화가 같은 store에 반영된다', () => {
  let changes = 0;
  const stop = useCounterStore.subscribe(() => changes++);
  useCounterStore.getState().increase();
  useCounterStore.getState().increase();
  useCounterStore.getState().decrease();
  expect(useCounterStore.getState().count).toBe(1);
  useCounterStore.getState().reset();
  expect(useCounterStore.getState().count).toBe(0);
  expect(changes).toBe(4);
  stop();
});
test('객체 변경 시 이름 보존, 탭과 필터는 별도 상태', () => {
  const before = useUserStore.getState().user;
  useUserStore.getState().changeRole('admin');
  expect(useUserStore.getState().user).toEqual({ name: 'Haebi', role: 'admin' });
  expect(before.role).toBe('member');
  useAdminUiStore.getState().selectTab('orders');
  useAdminUiStore.getState().toggleFilter();
  expect(useAdminUiStore.getState().selectedTab).toBe('orders');
  expect(useAdminUiStore.getState().isFilterOpen).toBe(true);
});

src/stores/useCounterStore.ts

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 }),
}));

src/stores/useUserStore.ts

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,
      },
    })),
}));

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "noEmit": true
  },
  "include": ["src"]
}

기대 화면과 확인 순서

패널과 배지의 +1/-1/초기화가 같이 바뀝니다. 역할 변경은 Haebi 이름을 유지하고, 주문 탭과 필터 열림은 독립적으로 바뀝니다. 새로고침하면 카운터는 0입니다.

검증 범위: npm 설치, TypeScript 타입 검사와 Vite production 빌드, Vitest 자동 테스트를 실행했습니다. UI 테스트는 jsdom이며 실제 기기의 레이아웃·성능 측정은 포함하지 않습니다.

이전 글의 버전 참고 링크 보존: Zustand v5.0.14 이전 릴리스. 이번 실습 검증 버전은 5.0.15입니다.

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

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

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

공식 근거와 확인 범위

확인일: 2026-09-12. 실행 프로젝트는 Zustand 5.0.15, React 19.3.0, TypeScript 7.0.2로 검사했습니다. 공식 문서의 API 설명과 실제 설치 버전의 동작을 구분해 확인합니다.

연결 학습: 객체·배열 업데이트 확인

목표·선행 지식: 원시값과 객체 참조를 구분하고 이전 상태를 보존하는 것이 목표입니다.

기본 set의 얕은 병합은 최상위 속성에 적용됩니다. 중첩 객체의 일부만 바꿀 때는 그 객체도 복사하거나 immer 미들웨어 안의 draft를 사용합니다. 미들웨어 밖의 기존 객체를 직접 바꾸는 코드와 혼동하지 마세요.

직접 확인할 과제

이전 profile 참조를 변수에 저장하고 visits를 증가시켜 보세요. 업데이트 뒤 새 값은 증가하되 이전 객체의 visits는 그대로 남아야 합니다.

공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드

공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.

이 글이 도움이 되었나요?

조회 중

Zustand 학습 순서

필수 14개 · 전체 15개

읽음 기록 관리

전체 과정 목차 (15개)
  1. 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
  2. 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
  3. 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
  4. 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
  5. 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기 현재 글
  6. 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기
  7. 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
  8. 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
  9. 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기
  10. 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
  11. 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
  12. 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
  13. 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성
  14. 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
  15. 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기