Zustand Todo에서 actions, 선택 훅, persist를 어떻게 연결하나요?
작업 추가·완료 전환은 Zustand action에 모으고 컴포넌트는 필요한 값만 구독합니다. persist에 필터만 저장하면 새로고침 뒤 필터는 남지만 메모리의 작업 목록은 초기화됩니다.
적용 조건·범위: 환경: Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기에서 준비한 React·TypeScript 및 Zustand 5 연습 앱입니다. 기존 TaskFlow는 필요하지 않습니다. src/App.tsx 전체를 아래 내용으로 교체합니다.
본문 근거: zustand.docs.pmnd.rs · zustand.docs.pmnd.rs/middlewares/persist · zustand.docs.pmnd.rs/middlewares/devtools
이 기술만 따로 실행하기
이 개념 직접 실행
상태를 바꾸고 화면에 반영되는 결과를 확인합니다.
예제 크게 보기 (새 탭)환경: Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기에서 준비한 React·TypeScript 및 Zustand 5 연습 앱입니다. 기존 TaskFlow는 필요하지 않습니다. src/App.tsx 전체를 아래 내용으로 교체합니다.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Task = { id: number; title: string; completed: boolean };
type Filter = 'all' | 'completed';
type State = {
tasks: Task[];
nextId: number;
filter: Filter;
add: () => void;
toggle: (id: number) => void;
setFilter: (filter: Filter) => void;
};
const useDemo = create<State>()(persist((set) => ({
tasks: [], nextId: 1, filter: 'all',
add: () => set((state) => ({
tasks: [...state.tasks, { id: state.nextId, title: '문서 작성', completed: false }],
nextId: state.nextId + 1,
})),
toggle: (id) => set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task),
})),
setFilter: (filter) => set({ filter }),
}), { name: 'blogflow-independent-todo-demo', partialize: (state) => ({ filter: state.filter }) }));
function TaskList() {
const tasks = useDemo((state) => state.tasks);
const filter = useDemo((state) => state.filter);
const toggle = useDemo((state) => state.toggle);
const visible = tasks.filter((task) => filter === 'all' || task.completed);
return (
<section>
<p>표시 {visible.length}개</p>
{visible.map((task) => (
<p key={task.id}>
{task.title}: {task.completed ? '완료' : '진행 중'}
<button onClick={() => toggle(task.id)}>완료 전환</button>
</p>
))}
</section>
);
}
export default function App() {
const add = useDemo((state) => state.add);
const filter = useDemo((state) => state.filter);
const setFilter = useDemo((state) => state.setFilter);
return (
<main>
<p>필터: {filter}</p>
<button onClick={add}>추가</button>
<button onClick={() => setFilter('all')}>전체</button>
<button onClick={() => setFilter('completed')}>완료만</button>
<TaskList />
</main>
);
}
npm run dev
npm run build
확인 순서: 전체 → 추가 → 완료 전환 → 완료만 → 새로고침
기대 결과: 추가한 작업이 완료 목록에 보입니다. 새로고침 후 filter는 completed로 유지되지만 tasks는 메모리 전용이라 비어 있습니다. 저장 정책을 화면 결과와 연결해 확인합니다.
자가 확인: 로컬 Todo를 영구 보관하려면 무엇을 persist해야 할까요? tasks와 ID 정책도 함께 설계해야 합니다. 서버 원본으로 바뀌면 UI 필터와 서버 작업을 분리합니다.
검증: 2026-09-28, TypeScript strict 검사와 Vite 라이브러리 빌드, jsdom 버튼 조작 후 표시값 검사를 통과했습니다. 실제 브라우저 배치와 누적 프로젝트 전체 실행은 확인 범위 밖입니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.
이번 주제: Zustand Todo 완성 실습: actions·선택 훅·persist 연결.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 직접 실행
src/store/taskSelectors.ts로 선택 훅을 정리하고 TaskList·TaskForm을 연결합니다. 작업 추가·완료·삭제, 필터·선택·표시설정·persist·구독을 같은 TaskFlow에서 종합 확인합니다.
예제 크게 보기 (새 탭)중간부터 시작하는 전체 실습 파일
기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
src/components/TaskForm.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskList.tsx— 기존 파일의 같은 위치를 갱신합니다.src/store/taskSelectors.ts— 이전 단계에 없던 파일을 만듭니다.
변경 이유와 결과: src/store/taskSelectors.ts로 선택 훅을 정리하고 TaskList·TaskForm을 연결합니다. 작업 추가·완료·삭제, 필터·선택·표시설정·persist·구독을 같은 TaskFlow에서 종합 확인합니다.
완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.
src/components/TaskForm.tsx 전체 파일
import { useState } from 'react';
import { useAddTask } from '../store/taskSelectors';
export default function TaskForm() {
const [title, setTitle] = useState('');
const addTask = useAddTask();
const handleSubmit = (
event: React.FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
const value = title.trim();
if (!value) return;
addTask(value);
setTitle('');
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">새 작업</label>
<input
id="task-title"
value={title}
onChange={(event) =>
setTitle(event.target.value)
}
/>
<button type="submit">추가</button>
</form>
);
}
src/components/TaskList.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
import { useTasks, useTaskFilter } from '../store/taskSelectors';
import TaskItem from './TaskItem';
export default function TaskList() {
const tasks = useTasks();
const filter = useTaskFilter();
const preferences = useTaskStore((state) => state.preferences);
const visibleTasks = tasks.filter((task) => {
if (!preferences.list.showCompleted && task.completed) return false;
if (filter === 'active') {
return !task.completed;
}
if (filter === 'completed') {
return task.completed;
}
return true;
});
if (visibleTasks.length === 0) {
return <p>표시할 작업이 없습니다.</p>;
}
return (
<ul style={{ lineHeight: preferences.list.compact ? 1.4 : 2 }}>
{visibleTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
/>
))}
</ul>
);
}
src/store/taskSelectors.ts 전체 파일
import { useTaskStore } from './useTaskStore';
export const useTasks = () => useTaskStore((state) => state.tasks);
export const useTaskFilter = () => useTaskStore((state) => state.taskFilter);
export const useAddTask = () => useTaskStore((state) => state.addTask);
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후 터미널의 localhost 주소를 엽니다.- src/store/taskSelectors.ts로 선택 훅을 정리하고 TaskList·TaskForm을 연결합니다. 작업 추가·완료·삭제, 필터·선택·표시설정·persist·구독을 같은 TaskFlow에서 종합 확인합니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
기존 체크포인트 검증 기록: TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과한 기록입니다. 별도 실제 브라우저 화면 검수는 그 파일 검증 범위에 포함하지 않았습니다. 이번 문장 보완에서 재실행한 결과는 아닙니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
프로젝트 마지막 단계입니다. 누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.
이전 프로젝트 단계: Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.
아래는 여러 회차에서 확장한 TaskFlow의 최종 구성 설명입니다. 위 단독 실습은 작은 별도 App으로, 아래 코드의 대체 시작본이 아닙니다. 기존 TaskItem·App 연결 등 이전 파일을 유지하며 최종 Store와 선택 훅·TaskForm·TaskList를 합칩니다.
과정 시작 전 구조
React 과정의 시작점은 다음과 같았습니다.
TaskContext
TaskProvider
taskReducer
dispatch
useContext
이 구조는 React 기본 기능만으로 전역 상태 공유와 reducer 흐름을 이해하기에 좋습니다.
Zustand 과정 종료 구조
useTaskStore
├─ state
│ ├─ tasks
│ ├─ taskFilter
│ ├─ selectedTaskId
│ ├─ isTaskModalOpen
│ └─ preferences
├─ actions
│ ├─ addTask
│ ├─ toggleTask
│ ├─ deleteTask
│ ├─ selectTask
│ ├─ openTaskModal
│ ├─ closeTaskModal
│ ├─ setFilter
│ └─ toggleCompact
├─ selectors
├─ persist
├─ devtools
└─ migration
최종 Store
현재 TaskFlow의 tasks는 학습용 로컬 데이터입니다. 실제 API 기반 프로젝트에서는 서버 원본 tasks와 mutation을 TanStack Query로 옮기고 Zustand에는 UI 상태를 남기는 구조가 적합합니다.
import { create } from 'zustand';
import {
devtools,
persist,
} from 'zustand/middleware';
export type Task = {
id: string;
title: string;
completed: boolean;
};
export type TaskFilter =
| 'all'
| 'active'
| 'completed';
type Preferences = {
compact: boolean;
};
type TaskStore = {
tasks: Task[];
taskFilter: TaskFilter;
selectedTaskId: string | null;
isTaskModalOpen: boolean;
preferences: Preferences;
addTask: (title: string) => void;
toggleTask: (id: string) => void;
deleteTask: (id: string) => void;
selectTask: (id: string | null) => void;
openTaskModal: () => void;
closeTaskModal: () => void;
setFilter: (filter: TaskFilter) => void;
toggleCompact: () => void;
};
type PersistedTaskStore = {
taskFilter: TaskFilter;
selectedTaskId: string | null;
preferences: Preferences;
};
export const useTaskStore =
create<TaskStore>()(
devtools(
persist(
(set) => ({
tasks: [],
taskFilter: 'all',
selectedTaskId: null,
isTaskModalOpen: false,
preferences: {
compact: false,
},
addTask: (title) =>
set(
(state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
},
],
}),
false,
'task/add',
),
toggleTask: (id) =>
set(
(state) => ({
tasks: state.tasks.map(
(task) =>
task.id === id
? {
...task,
completed:
!task.completed,
}
: task,
),
}),
false,
'task/toggle',
),
deleteTask: (id) =>
set(
(state) => ({
tasks: state.tasks.filter(
(task) =>
task.id !== id,
),
}),
false,
'task/delete',
),
selectTask: (selectedTaskId) =>
set(
{ selectedTaskId },
false,
'task/select',
),
openTaskModal: () =>
set(
{ isTaskModalOpen: true },
false,
'task/modal-open',
),
closeTaskModal: () =>
set(
{ isTaskModalOpen: false },
false,
'task/modal-close',
),
setFilter: (taskFilter) =>
set(
{ taskFilter },
false,
'task/filter',
),
toggleCompact: () =>
set(
(state) => ({
preferences: {
...state.preferences,
compact:
!state.preferences.compact,
},
}),
false,
'ui/toggle-compact',
),
}),
{
name: 'taskflow-ui',
version: 2,
partialize: (state) => ({
taskFilter: state.taskFilter,
selectedTaskId:
state.selectedTaskId,
preferences:
state.preferences,
}),
migrate: (
persistedState,
version,
) => {
const previous =
persistedState as
Partial<
PersistedTaskStore & {
filter: TaskFilter;
}
>;
if (version < 2) {
return {
taskFilter:
previous.filter ??
previous.taskFilter ??
'all',
selectedTaskId:
previous.selectedTaskId ??
null,
preferences:
previous.preferences ?? {
compact: false,
},
};
}
return persistedState;
},
},
),
{
name: 'TaskFlowStore',
},
),
);
선택 훅
반복되는 의미 있는 selector만 작은 훅으로 정리할 수 있습니다.
export const useTasks = () =>
useTaskStore(
(state) => state.tasks,
);
export const useTaskFilter = () =>
useTaskStore(
(state) => state.taskFilter,
);
export const useSelectedTaskId = () =>
useTaskStore(
(state) => state.selectedTaskId,
);
모든 selector를 감싸지 않고 반복되거나 의미 있는 구독 단위만 추상화합니다.
TaskList
import { useTaskStore } from '../store/useTaskStore';
import TaskItem from './TaskItem';
export default function TaskList() {
const tasks = useTaskStore(
(state) => state.tasks,
);
const taskFilter = useTaskStore(
(state) => state.taskFilter,
);
const visibleTasks = tasks.filter(
(task) => {
if (taskFilter === 'active') {
return !task.completed;
}
if (taskFilter === '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>
);
}
TaskForm
form 입력값은 전역 공유할 필요가 없으므로 로컬 state로 유지합니다.
import { useState } from 'react';
import { useTaskStore } from '../store/useTaskStore';
export default function TaskForm() {
const [title, setTitle] = useState('');
const addTask = useTaskStore(
(state) => state.addTask,
);
const handleSubmit = (
event: React.FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
const value = title.trim();
if (!value) return;
addTask(value);
setTitle('');
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">
새 작업
</label>
<input
id="task-title"
value={title}
onChange={(event) =>
setTitle(event.target.value)
}
/>
<button type="submit">
추가
</button>
</form>
);
}
최종 persist 기준
저장:
taskFilter
selectedTaskId
preferences
저장하지 않음:
isTaskModalOpen
form input
임시 loading / error
서버 원본 데이터
현재 로컬 Todo 학습에서는 tasks를 메모리 state로 유지합니다. 브라우저 로컬 Todo가 목적이라면 tasks를 persist할 수도 있지만, API 기반 TaskFlow로 확장할 계획이라면 서버 원본 tasks를 Zustand에 저장하지 않습니다.
reducer + Context와 비교
Before
TaskProvider
taskReducer
dispatch
useContext
Provider 연결
action 객체
After
useTaskStore
행동 중심 action
필요한 state selector 구독
Provider 불필요
persist / devtools 확장
migration 가능
Zustand가 항상 더 낫다는 뜻은 아닙니다. React 기본 상태 공유 원리를 이해한 뒤 앱의 상태 규모와 변경 패턴에 따라 선택하는 것이 중요합니다.
TanStack Query와 함께 사용할 때
실제 서비스 TaskFlow가 API를 사용한다면 역할을 다음처럼 나눕니다.
tasks 조회
작업 추가 API
작업 수정 API
작업 삭제 API
캐시 / refetch
→ TanStack Query
selectedTaskId
taskFilter
modal
UI preferences
→ Zustand
최종 실행 확인
npm run dev
npm run build
다음 시나리오를 확인합니다.
- 작업 추가
- 작업 완료/취소
- 작업 삭제
- filter 변경
- 선택 상태 변경
- modal 열기/닫기
- compact 설정 변경
- 새로고침
- persist 대상만 복원
- Redux DevTools에서 action 이름 확인
Zustand 과정 최종 체크리스트
- store와 state 구조를 설계할 수 있습니다.
- 행동 중심 action을 만들 수 있습니다.
- selector로 필요한 값만 구독할 수 있습니다.
- 객체·배열 참조와 리렌더링 관계를 이해했습니다.
- persist 대상을 최소화할 수 있습니다.
- combine과 immer 도입 기준을 설명할 수 있습니다.
- subscribe cleanup을 적용할 수 있습니다.
- devtools에서 상태 변경을 추적할 수 있습니다.
- persist migration을 작성할 수 있습니다.
- 서버 상태와 Zustand 역할을 구분할 수 있습니다.
완료 체크포인트
zustand-step-15-final
이 체크포인트가 Zustand 과정의 최종 TaskFlow 상태입니다.
다음 학습
서버 상태와 API 캐시
→ TanStack Query
Next.js SSR / hydration과 Zustand 조합
→ Next.js Zustand 심화
클라이언트 전역 상태 규모 확장
→ store slice와 상태 경계 심화
공식 문서
- Zustand 공식 문서: https://zustand.docs.pmnd.rs/
- persist: https://zustand.docs.pmnd.rs/middlewares/persist
- devtools: https://zustand.docs.pmnd.rs/middlewares/devtools
- Guides: https://zustand.docs.pmnd.rs/guides/
글 소개·학습 준비·전체 목차

Zustand · 15 / 15 · 최종 실습 · TaskFlow 통합
작업 추가·완료 전환을 action으로 만들고 필요한 값만 선택해 구독합니다. persist에는 filter만 저장하면 새로고침 뒤 필터는 남고 메모리 작업은 초기화됩니다. 아래 작은 Todo에서 이 연결과 저장 범위를 직접 비교합니다.
독립 앱은 이전 과정 파일 없이 목록·추가·완료·필터를 확인하는 예제입니다. 하단에서는 기존 reducer와 Context로 시작한 TaskFlow에 선택 훅과 최종 구성 요소를 연결하고, devtools·구독·저장 정책을 함께 점검합니다.
이번 글에서 배우는 내용
- Zustand 최종 Store 구조
- 행동 중심 action
- 선택 훅과 selector
- persist 저장 범위
- devtools action 추적
- persist migration
- reducer + Context 대비 최종 구조
- TanStack Query와의 역할 경계
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.