Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습

··설명 약 9분·작성: 해비·블로그 소개

Zustand subscribeWithSelector로 특정 값만 관찰하고 구독을 어떻게 해제하나요?

React 렌더링과 별개로 특정 상태 변화에 반응하려면 Zustand subscribe를 사용할 수 있습니다. subscribeWithSelector로 필요한 값만 관찰하고 cleanup에서 구독을 해제합니다.

적용 조건·범위: cleanup이 없으면 개발 환경의 StrictMode나 컴포넌트 재마운트 상황에서 중복 구독이 생길 수 있습니다.

본문 근거: 공식 TypeScript 안내 · zustand.docs.pmnd.rs/middlewares/subscribe-with-selector · zustand.docs.pmnd.rs/middlewares/devtools

이 기술만 따로 실행하기

이 개념 직접 실행

상태를 바꾸고 화면에 반영되는 결과를 확인합니다.

예제 크게 보기 (새 탭)

환경: Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기에서 준비한 React·TypeScript 및 Zustand 5 연습 앱입니다. 기존 TaskFlow는 필요하지 않습니다. src/App.tsx 전체를 아래 내용으로 교체합니다.

import { useEffect, useState } from 'react';
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
type State = { count: number; increase: () => void };
const useDemo = create<State>()(devtools(subscribeWithSelector((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 }), false, 'demo/increase'),
})), { name: 'BlogFlowSubscribeDemo' }));
function Watcher() {
  const [log, setLog] = useState<string[]>([]);
  useEffect(() => useDemo.subscribe(
    (state) => state.count,
    (next, previous) => setLog((lines) => [...lines, `${previous} → ${next}`]),
  ), []);
  return <p>변경 기록: {log.join(', ') || '없음'}</p>;
}
export default function App() {
  const count = useDemo((state) => state.count);
  const increase = useDemo((state) => state.increase);
  const [watch, setWatch] = useState(true);
  return (
    <main>
      <p>횟수: {count}</p>
      <button onClick={increase}>증가</button>
      <button onClick={() => setWatch((value) => !value)}>구독 화면 전환</button>
      {watch && <Watcher />}
    </main>
  );
}
npm run dev
npm run build

확인 순서: 증가 두 번 → 구독 화면 숨기기 → 증가 한 번 → 구독 화면 다시 보이기 → 증가

기대 결과: 첫 기록은 0 → 1, 1 → 2입니다. 구독이 사라진 동안 3으로 바뀐 이력은 새 Watcher에 소급 표시되지 않고, 다시 구독한 뒤에는 3 → 4가 기록됩니다. Redux DevTools를 이미 사용하는 경우 BlogFlowSubscribeDemo의 demo/increase action도 별도로 확인할 수 있습니다.

자가 확인: cleanup에서 구독 해제를 반환하지 않으면 숨긴 Watcher의 콜백도 남을 수 있습니다. useEffect가 반환하는 값이 무엇인지 확인하세요.

검증: 2026-09-28, TypeScript strict 검사와 Vite 라이브러리 빌드, jsdom 버튼 조작 후 표시값 검사를 통과했습니다. 실제 브라우저 배치와 누적 프로젝트 전체 실행은 확인 범위 밖입니다.

React 컴포넌트에서는 selector hook이 기본

컴포넌트 화면을 갱신하려는 목적이라면 다음 방식이 기본입니다.

const filter = useTaskStore(
  (state) => state.filter,
);

subscribe를 UI 렌더링 대체 수단으로 사용하지 않습니다.

외부 구독이 필요한 경우

예를 들면 다음 상황을 생각할 수 있습니다.

  • 특정 상태 변경 로그 기록
  • 브라우저 API와 동기화
  • React 바깥 코드에서 상태 변화 감지
  • 분석 이벤트 전송
  • 특정 상태 변화만 디버깅

이런 경우 store.subscribe를 사용할 수 있습니다.

subscribeWithSelector

middleware를 적용합니다.

import { subscribeWithSelector } from 'zustand/middleware';


const useStore = create(
  subscribeWithSelector((set) => ({
    filter: 'all',
    setFilter: (filter) => set({ filter }),
  })),
);

특정 값만 구독할 수 있습니다.

const unsubscribe = useStore.subscribe(
  (state) => state.filter,
  (nextFilter, previousFilter) => {
    console.log({
      previousFilter,
      nextFilter,
    });
  },
);

unsubscribe가 중요한 이유

구독은 자동으로 사라진다고 가정하면 안 됩니다.

React effect 안에서 등록했다면 cleanup에서 해제합니다.

useEffect(() => {
  const unsubscribe = useTaskStore.subscribe(
    (state) => state.filter,
    (filter) => {
      console.log('filter changed', filter);
    },
  );


  return unsubscribe;
}, []);

cleanup이 없으면 개발 환경의 StrictMode나 컴포넌트 재마운트 상황에서 중복 구독이 생길 수 있습니다.

devtools middleware

devtools는 상태 변경을 Redux DevTools Extension에서 확인할 수 있게 도와줍니다.

import { devtools } from 'zustand/middleware';


const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      increase: () =>
        set(
          (state) => ({
            count: state.count + 1,
          }),
          false,
          'counter/increase',
        ),
    }),
    {
      name: 'TaskFlowStore',
    },
  ),
);

action 이름을 명시하면 DevTools에서 흐름을 읽기 쉬워집니다.

middleware 조합

persist, subscribeWithSelector, devtools를 함께 쓸 수 있습니다.

각 middleware의 책임과 적용 순서를 함께 확인합니다. devtools는 setState의 action 이름 타입도 확장하므로 공식 TypeScript 안내는 다른 middleware 바깥에서 감싸는 구성을 권장합니다. 아래 예제도 devtools가 subscribeWithSelector 바깥에 있는 구조를 사용합니다.

persist
→ 저장/복원


subscribeWithSelector
→ 선택 외부 구독


devtools
→ 변경 추적

학습 완료 체크리스트

  • React selector 구독과 외부 subscribe를 구분할 수 있습니다.
  • 특정 state만 subscribe할 수 있습니다.
  • cleanup에서 unsubscribe할 수 있습니다.
  • devtools에서 action 이름을 읽기 좋게 만들 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.

TaskFlow filter 변화만 외부 구독하고, 주요 action을 DevTools에서 추적할 수 있게 만듭니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 직접 실행

기존 Store 바깥에 devtools·subscribeWithSelector를 연결하고 TaskFilterWatcher를 App에 붙입니다. 필터만 외부 구독하며 구독 해제 시 이후 기록이 늘지 않아야 합니다.

예제 크게 보기 (새 탭)

중간부터 시작하는 전체 실습 파일

기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.

이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.

시작본에서 직접 바꾸기

시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.

  1. src/App.tsx — 기존 파일의 같은 위치를 갱신합니다.
  2. src/components/TaskFilterWatcher.tsx — 이전 단계에 없던 파일을 만듭니다.
  3. src/store/useTaskStore.ts — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: 기존 Store 바깥에 devtools·subscribeWithSelector를 연결하고 TaskFilterWatcher를 App에 붙입니다. 필터만 외부 구독하며 구독 해제 시 이후 기록이 늘지 않아야 합니다.

완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.

src/App.tsx 전체 파일

import { useEffect } from 'react';
import Header from './components/Header';
import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
import { useTaskStore } from './store/useTaskStore';
import TaskSelection from './components/TaskSelection';
import TaskModalControls from './components/TaskModalControls';
import TaskSummary from './components/TaskSummary';
import TaskPreferences from './components/TaskPreferences';
import TaskFilterWatcher from './components/TaskFilterWatcher';

function RemainingCount() {
  const tasks = useTaskStore((state) => state.tasks);
  const remainingCount = tasks.filter((task) => !task.completed).length;
  useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
  return <p>남은 작업: {remainingCount}</p>;
}

export default function App() {
  return (
    <main className="app">
      <Header />
      <NetworkStatus />
      <RemainingCount />
      <TaskFilter />
      <TaskForm />
      <TaskList />
      <TaskSelection />
      <TaskModalControls />
      <TaskSummary />
      <TaskPreferences />
      <TaskFilterWatcher />
      <StateResetDemo />
    </main>
  );
}

src/components/TaskFilterWatcher.tsx 전체 파일

import { useEffect } from 'react';
import { useTaskStore } from '../store/useTaskStore';


export default function TaskFilterWatcher() {
  useEffect(() => {
    const unsubscribe =
      useTaskStore.subscribe(
        (state) => state.filter,
        (nextFilter, previousFilter) => {
          console.log(
            'filter:',
            previousFilter,
            '→',
            nextFilter,
          );
        },
      );


    return unsubscribe;
  }, []);


  return null;
}

src/store/useTaskStore.ts 전체 파일

import { create } from 'zustand';
import { combine, persist, devtools, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { Task } from '../types/task';
export type TaskFilter = 'all' | 'active' | 'completed';
export type { Task } from '../types/task';
const initialState = {
  tasks: [] as Task[],
  filter: 'all' as TaskFilter,
  selectedTaskId: null as string | null,
  isTaskModalOpen: false,
  preferences: { list: { compact: false, showCompleted: true } },
};
const creator = persist(
  combine(initialState, immer((set) => ({
    addTask: (title: string) => {
      const value = title.trim();
      if (!value) return;
      set((state) => { state.tasks.push({ id: crypto.randomUUID(), title: value, completed: false }); });
    },
    toggleTask: (id: string) => set((state) => {
      const task = state.tasks.find((item) => item.id === id);
      if (task) task.completed = !task.completed;
    }),
    deleteTask: (id: string) => set((state) => {
      state.tasks = state.tasks.filter((task) => task.id !== id);
      if (state.selectedTaskId === id) state.selectedTaskId = null;
    }),
    selectTask: (selectedTaskId: string | null) => set({ selectedTaskId }),
    openTaskModal: () => set({ isTaskModalOpen: true }),
    closeTaskModal: () => set({ isTaskModalOpen: false }),
    setFilter: (filter: TaskFilter) => set({ filter }),
    toggleCompact: () => set((state) => { state.preferences.list.compact = !state.preferences.list.compact; }),
    toggleShowCompleted: () => set((state) => { state.preferences.list.showCompleted = !state.preferences.list.showCompleted; }),
  }))),
  {
    name: 'taskflow-ui',
    version: 0,
    partialize: (state) => ({ filter: state.filter, selectedTaskId: state.selectedTaskId, preferences: state.preferences }),
  },
);
export const useTaskStore = create(devtools(subscribeWithSelector(creator), { name: 'TaskFlowStore' }));

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 터미널의 localhost 주소를 엽니다.
  3. 기존 Store 바깥에 devtools·subscribeWithSelector를 연결하고 TaskFilterWatcher를 App에 붙입니다. 필터만 외부 구독하며 구독 해제 시 이후 기록이 늘지 않아야 합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

기존 체크포인트 검증 기록: TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과한 기록입니다. 별도 실제 브라우저 화면 검수는 그 파일 검증 범위에 포함하지 않았습니다. 이번 문장 보완에서 재실행한 결과는 아닙니다.

아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.

누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.

이전 프로젝트 단계: Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.

src/store/useTaskStore.ts의 devtools 바깥 감싸기와 subscribeWithSelector를 연결하고 TaskFilterWatcher.tsx를 App에 한 번 렌더링합니다. filter 변경만 구독하며 effect의 cleanup에서 unsubscribe를 반환합니다.

이전 체크포인트

zustand-step-11-combine-immer

이번 목표

TaskFlow filter 변화만 외부 구독하고, 주요 action을 DevTools에서 추적할 수 있게 만듭니다.

전체 파일·코드 보기

src/store/useTaskStore.ts 핵심 구조

import { create } from 'zustand';
import {
  devtools,
  persist,
  subscribeWithSelector,
} from 'zustand/middleware';


type TaskFilter =
  | 'all'
  | 'active'
  | 'completed';


type TaskStore = {
  filter: TaskFilter;
  selectedTaskId: string | null;
  setFilter: (filter: TaskFilter) => void;
  selectTask: (id: string | null) => void;
};


export const useTaskStore =
  create<TaskStore>()(
    devtools(
      subscribeWithSelector(
        persist(
          (set) => ({
            filter: 'all',
            selectedTaskId: null,


            setFilter: (filter) =>
              set(
                { filter },
                false,
                'task/setFilter',
              ),


            selectTask: (selectedTaskId) =>
              set(
                { selectedTaskId },
                false,
                'task/selectTask',
              ),
          }),
          {
            name: 'taskflow-ui',
            partialize: (state) => ({
              filter: state.filter,
              selectedTaskId:
                state.selectedTaskId,
            }),
          },
        ),
      ),
      {
        name: 'TaskFlowStore',
      },
    ),
  );

TypeScript에서 middleware 조합에 따라 set의 세 번째 action 이름 인자 타입이 달라질 수 있으므로 실제 사용 버전의 공식 타입 정의와 함께 확인합니다.

src/components/TaskFilterWatcher.tsx

import { useEffect } from 'react';
import { useTaskStore } from '../store/useTaskStore';


export default function TaskFilterWatcher() {
  useEffect(() => {
    const unsubscribe =
      useTaskStore.subscribe(
        (state) => state.filter,
        (nextFilter, previousFilter) => {
          console.log(
            'filter:',
            previousFilter,
            '→',
            nextFilter,
          );
        },
      );


    return unsubscribe;
  }, []);


  return null;
}

이 컴포넌트는 화면을 그리지 않고 학습용 구독 생명주기만 확인합니다.

실무에서 단순 로그를 위해 별도 컴포넌트를 두라는 의미는 아닙니다. 구독이 필요한 실제 외부 연동 위치에서 같은 cleanup 원칙을 적용하면 됩니다.

DevTools 확인

  1. Redux DevTools Extension을 엽니다.
  2. TaskFlowStore를 선택합니다.
  3. filter를 변경합니다.
  4. selectedTaskId를 변경합니다.
  5. action 이름과 state 차이를 확인합니다.

실행 확인

npm run dev
npm run build

개발자 콘솔에서 filter 변화가 한 번씩 기록되고, 컴포넌트 언마운트 후 중복 로그가 남지 않는지 확인합니다.

현재 확장 단계

✓ 1~11. Store·persist·middleware 기초
→ 12. subscribeWithSelector / devtools
○ 13. persist migration
○ 14. 실무 설계 점검
○ 15. 최종 통합

완료 체크포인트

zustand-step-12-subscribe-devtools

다음 학습

다음 글에서는 이미 저장된 persist 데이터 구조가 바뀌었을 때 version과 migrate를 사용해 안전하게 전환합니다.

다음 프로젝트 단계: Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때

공식 문서

글 소개·학습 준비·전체 목차
Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습

Zustand · 12 / 15 · 고급 학습 · subscribeWithSelector·devtools

React 컴포넌트 안에서는 useTaskStore(selector)로 상태를 구독합니다. 하지만 React 렌더링과 별개로 특정 state 변화에 반응해야 할 때는 store의 subscribe 기능을 사용할 수 있습니다.

subscribeWithSelector middleware를 사용하면 store 전체가 아니라 특정 slice의 변화만 구독할 수 있고, devtools middleware를 사용하면 action과 state 변화 흐름을 Redux DevTools에서 추적할 수 있습니다.

이번 글에서 배우는 내용

  • React 렌더 구독과 외부 subscribe의 차이
  • subscribeWithSelector 사용법
  • unsubscribe와 cleanup
  • 이전 값과 다음 값 비교
  • devtools middleware 연결
  • action 이름을 명시하는 방법
  • TaskFlow filter 변화 외부 구독

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기