Zustand selector로 필요한 상태만 구독하려면 어떻게 작성하나요?
Zustand 컴포넌트가 store 전체를 읽으면 자신에게 필요 없는 값의 변경에도 반응할 수 있습니다. selector로 실제 사용하는 state나 action만 선택해 구독 범위를 좁힙니다.
적용 조건·범위: 위 코드는 새 객체를 반환하는 문제를 보여주는 예이며 그대로 사용할 구현이 아닙니다. Zustand 5의 기본 create에서는 selector 결과 참조가 안정적이어야 합니다. 상태가 같아도 매번 새 객체를 반환하면 불필요한 렌더를 넘어 무한 업데이트가 발생할 수 있습니다. 아래처럼 값을 각각 선택하거나 얕게 같은 결과를 재사용하는 useShallow로 묶으세요. 공식 useShallow 문서의 오류 설명도 같은 참조 조건을 다룹니다.
본문 근거: 공식 useShallow 문서의 오류 설명 · zustand.docs.pmnd.rs · zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow
이 기술만 따로 실행하기
이 개념 직접 실행
상태를 바꾸고 화면에 반영되는 결과를 확인합니다.
예제 크게 보기 (새 탭)실행 환경: Zustand 5가 설치된 별도 React·TypeScript 연습 앱입니다. 환경 준비는 Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기를 참고하세요. 기존 TaskFlow 데이터는 사용하지 않습니다. 아래 전체 코드를 src/App.tsx에 저장하고 기존 src/main.tsx의 App 연결을 유지합니다.
import { create } from 'zustand';
type State = { filter: string; count: number; changeFilter: () => void; increase: () => void };
const useDemo = create<State>()((set) => ({
filter: 'all',
count: 0,
changeFilter: () => set((state) => ({ filter: state.filter === 'all' ? 'completed' : 'all' })),
increase: () => set((state) => ({ count: state.count + 1 })),
}));
function FilterLabel() {
const filter = useDemo((state) => state.filter);
console.count('FilterLabel render');
return <p>필터: {filter}</p>;
}
function CountLabel() {
const count = useDemo((state) => state.count);
return <p>횟수: {count}</p>;
}
export default function App() {
const increase = useDemo((state) => state.increase);
const changeFilter = useDemo((state) => state.changeFilter);
return (
<main>
<FilterLabel />
<CountLabel />
<button onClick={increase}>횟수 증가</button>
<button onClick={changeFilter}>필터 변경</button>
</main>
);
}
npm run dev
npm run build
확인 순서: 횟수 증가를 두 번 누른 뒤 필터 변경
기대 결과: 횟수만 2로 변하는 동안 필터는 all을 유지하고, 필터 변경 후 completed가 됩니다. 콘솔에서 FilterLabel이 어떤 변경을 구독하는지 비교합니다.
자가 확인: FilterLabel의 selector를 (state) => state로 바꾸면 횟수 변경도 구독할까요? 전체 상태 객체가 바뀌므로 구독 범위가 넓어집니다.
기존 검증 기록: 2026-09-28, TypeScript strict 검사와 Vite 라이브러리 빌드, jsdom 버튼 조작 후 표시값 검사를 통과했습니다. 실제 브라우저 배치와 누적 프로젝트 전체 실행은 확인 범위 밖입니다.
기본 selector
const tasks = useTaskStore(
(state) => state.tasks,
);
이 컴포넌트는 tasks가 바뀔 때 다시 렌더링됩니다.
action도 같은 방식으로 가져옵니다.
const addTask = useTaskStore(
(state) => state.addTask,
);
Zustand action 함수는 일반적으로 store 생성 후 같은 함수 참조를 유지하므로 필요한 action만 구독하는 패턴이 단순합니다.
store 전체 구독
다음 코드는 편해 보입니다.
const store = useTaskStore();
하지만 store의 다른 state 변경에도 컴포넌트가 영향을 받을 수 있습니다.
작은 예제에서는 문제가 잘 보이지 않지만 앱이 커질수록 구독 범위를 구분하는 편이 좋습니다.
파생 값 selector
필요한 값을 바로 계산할 수도 있습니다.
const remainingCount = useTaskStore(
(state) =>
state.tasks.filter(
(task) => !task.completed,
).length,
);
이 경우 selector 결과가 숫자이므로 비교가 단순합니다.
객체를 반환하는 selector
다음 selector는 호출될 때마다 새 객체를 만들 수 있습니다.
const summary = useTaskStore((state) => ({
total: state.tasks.length,
completed: state.tasks.filter(
(task) => task.completed,
).length,
}));
위 코드는 새 객체를 반환하는 문제를 보여주는 예이며 그대로 사용할 구현이 아닙니다. Zustand 5의 기본 create에서는 selector 결과 참조가 안정적이어야 합니다. 상태가 같아도 매번 새 객체를 반환하면 불필요한 렌더를 넘어 무한 업데이트가 발생할 수 있습니다. 아래처럼 값을 각각 선택하거나 얕게 같은 결과를 재사용하는 useShallow로 묶으세요. 공식 useShallow 문서의 오류 설명도 같은 참조 조건을 다룹니다.
여러 값을 묶어 가져와야 한다면 각각 선택하거나 Zustand의 shallow 비교 도구를 검토합니다.
import { useShallow } from 'zustand/react/shallow';
const { filter, selectedTaskId } =
useTaskStore(
useShallow((state) => ({
filter: state.filter,
selectedTaskId: state.selectedTaskId,
})),
);
무조건 shallow를 붙이는 것이 아니라 실제로 여러 값을 객체나 배열로 묶어 선택할 필요가 있을 때 사용합니다.
선택 훅으로 반복 줄이기
반복이 많다면 작은 선택 훅을 만들 수 있습니다.
export const useTasks = () =>
useTaskStore((state) => state.tasks);
export const useTaskActions = () =>
useTaskStore(
useShallow((state) => ({
addTask: state.addTask,
toggleTask: state.toggleTask,
deleteTask: state.deleteTask,
})),
);
다만 추상화가 오히려 어떤 state를 구독하는지 숨긴다면 직접 selector를 쓰는 편이 더 명확할 수 있습니다.
학습 완료 체크리스트
- selector가 구독 범위를 정한다는 것을 이해했습니다.
- store 전체 구독을 피할 수 있습니다.
- 파생 값을 selector에서 계산할 수 있습니다.
- 객체/배열 selector의 참조 문제를 설명할 수 있습니다.
- 필요할 때 useShallow를 사용할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.
TaskList, TaskForm, TaskFilter가 필요한 값만 구독하도록 정리합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 직접 실행
TaskList는 tasks와 filter만 선택하고 TaskSummary는 전체·완료 숫자를 구독합니다. 필터는 저장된 원본을 삭제하지 않고 표시만 바꿉니다.
예제 크게 보기 (새 탭)중간부터 시작하는 전체 실습 파일
기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
src/App.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskList.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskSummary.tsx— 이전 단계에 없던 파일을 만듭니다.
변경 이유와 결과: TaskList는 tasks와 filter만 선택하고 TaskSummary는 전체·완료 숫자를 구독합니다. 필터는 저장된 원본을 삭제하지 않고 표시만 바꿉니다.
완료본의 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';
export default function App() {
const tasks = useTaskStore((state) => state.tasks);
const remainingCount = tasks.filter((task) => !task.completed).length;
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return (
<main className="app">
<Header />
<NetworkStatus />
<p>남은 작업: {remainingCount}</p>
<TaskFilter />
<TaskForm />
<TaskList />
<TaskSelection />
<TaskModalControls />
<TaskSummary />
<StateResetDemo />
</main>
);
}
src/components/TaskList.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
import TaskItem from './TaskItem';
export default function TaskList() {
const tasks = useTaskStore(
(state) => state.tasks,
);
const filter = useTaskStore(
(state) => state.filter,
);
const visibleTasks = tasks.filter((task) => {
if (filter === 'active') {
return !task.completed;
}
if (filter === 'completed') {
return task.completed;
}
return true;
});
if (visibleTasks.length === 0) {
return <p>표시할 작업이 없습니다.</p>;
}
return (
<ul>
{visibleTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
/>
))}
</ul>
);
}
src/components/TaskSummary.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
export default function TaskSummary() {
const totalCount = useTaskStore(
(state) => state.tasks.length,
);
const completedCount = useTaskStore(
(state) =>
state.tasks.filter(
(task) => task.completed,
).length,
);
return (
<p>
전체 {totalCount}개 · 완료 {completedCount}개
</p>
);
}
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후 터미널의 localhost 주소를 엽니다.- TaskList는 tasks와 filter만 선택하고 TaskSummary는 전체·완료 숫자를 구독합니다. 필터는 저장된 원본을 삭제하지 않고 표시만 바꿉니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
원문에 있는 기존 체크포인트 검증 기록은 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증 통과입니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
이어서 만들기: Zustand action 사용법: 상태 변경 로직을 store로 분리하기의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.
TaskList는 tasks와 filter, TaskForm은 addTask, TaskSummary는 개수만 구독하도록 각 함수 컴포넌트 내부의 useTaskStore 호출을 바꿉니다. action의 구현은 바꾸지 않아 구독 범위 차이만 비교합니다.
이전 체크포인트
zustand-step-06-actions
이번 목표
TaskList, TaskForm, TaskFilter가 필요한 값만 구독하도록 정리합니다.
전체 파일·코드 보기
src/components/TaskList.tsx
import { useTaskStore } from '../store/useTaskStore';
import TaskItem from './TaskItem';
export default function TaskList() {
const tasks = useTaskStore(
(state) => state.tasks,
);
const filter = useTaskStore(
(state) => state.filter,
);
const visibleTasks = tasks.filter((task) => {
if (filter === 'active') {
return !task.completed;
}
if (filter === 'completed') {
return task.completed;
}
return true;
});
if (visibleTasks.length === 0) {
return <p>표시할 작업이 없습니다.</p>;
}
return (
<ul>
{visibleTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
/>
))}
</ul>
);
}
src/components/TaskSummary.tsx
import { useTaskStore } from '../store/useTaskStore';
export default function TaskSummary() {
const totalCount = useTaskStore(
(state) => state.tasks.length,
);
const completedCount = useTaskStore(
(state) =>
state.tasks.filter(
(task) => task.completed,
).length,
);
return (
<p>
전체 {totalCount}개 · 완료 {completedCount}개
</p>
);
}
구독 범위 점검
TaskForm
→ addTask만 구독
TaskItem
→ toggleTask, deleteTask
TaskFilter
→ filter, setFilter
TaskList
→ tasks, filter
TaskSummary
→ tasks에서 계산한 숫자
이제 각 컴포넌트가 어떤 store 값에 의존하는지 코드에서 바로 확인할 수 있습니다.
현재 확장 단계
✓ 1~6. Store와 action 이전
→ 7. selector
○ 8. 리렌더링 측정
○ 9. 리렌더링 오류 해결
○ 10. persist
○ 11~15. middleware·migration·실무 설계
실행 확인
npm run dev
npm run build
- 필터 변경 시 목록이 정상 바뀌는지 확인합니다.
- 작업 추가·완료·삭제가 정상 동작하는지 확인합니다.
- 각 컴포넌트가 필요한 state만 selector로 읽는지 확인합니다.
완료 체크포인트
zustand-step-07-selectors
다음 학습
다음 글에서는 console 로그와 React DevTools를 이용해 실제로 어떤 컴포넌트가 다시 렌더링되는지 확인하고 selector 반환값을 최적화합니다.
다음 글: Zustand 리렌더링 원리와 selector 최적화 방법
공식 문서
- Zustand 공식 문서: https://zustand.docs.pmnd.rs/
- useShallow: https://zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow
글 소개·학습 준비·전체 목차

Zustand · 7 / 15 · 필수 학습 · selector
Zustand store에 상태와 action이 모이면 다음으로 확인할 것은 구독 범위입니다.
컴포넌트가 store 전체를 읽으면 필요하지 않은 state가 바뀌어도 다시 렌더링될 수 있습니다. selector를 사용하면 컴포넌트가 실제로 필요한 값만 구독할 수 있습니다.
이번 글에서 배우는 내용
- selector의 역할
- store 전체 구독을 피하는 이유
- state와 action을 각각 선택하는 방법
- 파생 값 selector
- 객체 selector와 참조 안정성
- TaskFlow 구독 범위 정리
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.