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와 입력 타입을 포함한 전체 코드가 있습니다.
src/App.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskFilterWatcher.tsx— 이전 단계에 없던 파일을 만듭니다.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' }));
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후 터미널의 localhost 주소를 엽니다.- 기존 Store 바깥에 devtools·subscribeWithSelector를 연결하고 TaskFilterWatcher를 App에 붙입니다. 필터만 외부 구독하며 구독 해제 시 이후 기록이 늘지 않아야 합니다.
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 확인
- Redux DevTools Extension을 엽니다.
- TaskFlowStore를 선택합니다.
- filter를 변경합니다.
- selectedTaskId를 변경합니다.
- 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 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
공식 문서
- subscribeWithSelector: https://zustand.docs.pmnd.rs/middlewares/subscribe-with-selector
- devtools: https://zustand.docs.pmnd.rs/middlewares/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개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.