Zustand action으로 컴포넌트의 상태 변경 규칙을 어떻게 store에 모으나요?
Zustand action은 상태 변경 규칙을 store 안에 모은 함수입니다. 컴포넌트마다 목록 수정 방식을 반복하는 대신 추가·완료 전환·삭제를 action으로 호출합니다.
적용 조건·범위: reducer가 잘못된 것은 아닙니다. Zustand에서는 작은 store라면 action 메서드 방식이 읽기 쉬운 경우가 많습니다.
본문 근거: zustand.docs.pmnd.rs · zustand.docs.pmnd.rs/guides/updating-state
이 기술만 따로 실행하기
이 개념 직접 실행
상태를 바꾸고 화면에 반영되는 결과를 확인합니다.
예제 크게 보기 (새 탭)기대 결과: 작업 0개 → 작업 1개/진행 중 → 완료 → 작업 0개 순서로 바뀝니다.
직접 실행: 환경 준비와 전체 파일
실행 환경: Zustand 5가 설치된 별도 React·TypeScript 연습 앱입니다. 환경 준비는 Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기를 참고하세요. 기존 TaskFlow 데이터는 사용하지 않습니다. 아래 전체 코드를 src/App.tsx에 저장하고 기존 src/main.tsx의 App 연결을 유지합니다.
import { create } from 'zustand';
type Task = { id: number; title: string; completed: boolean };
type State = {
tasks: Task[];
nextId: number;
addTask: () => void;
toggleTask: (id: number) => void;
deleteTask: (id: number) => void;
};
const useTasks = create<State>()((set) => ({
tasks: [],
nextId: 1,
addTask: () => set((state) => ({
tasks: [...state.tasks, { id: state.nextId, title: '문서 작성', completed: false }],
nextId: state.nextId + 1,
})),
toggleTask: (id) => set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task),
})),
deleteTask: (id) => set((state) => ({
tasks: state.tasks.filter((task) => task.id !== id),
})),
}));
export default function App() {
const tasks = useTasks((state) => state.tasks);
const addTask = useTasks((state) => state.addTask);
const toggleTask = useTasks((state) => state.toggleTask);
const deleteTask = useTasks((state) => state.deleteTask);
return (
<main>
<p>작업 {tasks.length}개</p>
<button onClick={addTask}>추가</button>
<ul>
{tasks.map((task) => (
<li key={task.id}>
{task.title}: {task.completed ? '완료' : '진행 중'}
<button onClick={() => toggleTask(task.id)}>완료 전환</button>
<button onClick={() => deleteTask(task.id)}>삭제</button>
</li>
))}
</ul>
</main>
);
}
npm run dev
npm run build
확인 순서: 추가 → 완료 전환 → 삭제
자가 확인: 추가를 두 번 누른 후 첫 작업만 완료하면 둘째 작업은 진행 중으로 남아야 합니다.
검증: 2026-09-28, TypeScript strict 검사와 Vite 라이브러리 빌드, jsdom 버튼 조작 후 표시값 검사를 통과했습니다. 실제 브라우저 배치와 누적 프로젝트 전체 실행은 확인 범위 밖입니다.
setter만 노출하면 생기는 문제
다음처럼 setter를 외부에 그대로 노출할 수 있습니다.
setSelectedTaskId: (id) =>
set({ selectedTaskId: id }),
단순한 값에는 문제가 없지만, 비즈니스 로직이 컴포넌트로 흩어지기 쉽습니다.
setTasks(
tasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
이제 컴포넌트가 상태 구조와 변경 방법을 모두 알아야 합니다.
행동 중심 action
store가 변경 방법을 책임지도록 만듭니다.
toggleTask: (id) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
})),
컴포넌트는 다음처럼 단순해집니다.
const toggleTask = useTaskStore(
(state) => state.toggleTask,
);
toggleTask(task.id);
action 이름 기준
좋은 action 이름은 상태의 구현보다 사용자의 행동을 표현합니다.
setModal(true)
→ openTaskModal()
setModal(false)
→ closeTaskModal()
setSelectedTaskId(id)
→ selectTask(id)
setTasks(...)
→ addTask(), toggleTask(), deleteTask()
reducer와 비교
reducer에서는 다음처럼 action 객체를 전달합니다.
dispatch({
type: 'task/toggled',
id,
});
Zustand에서는 메서드 형태로 표현할 수 있습니다.
toggleTask(id);
reducer가 잘못된 것은 아닙니다. Zustand에서는 작은 store라면 action 메서드 방식이 읽기 쉬운 경우가 많습니다.
학습 완료 체크리스트
- setter와 행동 중심 action을 구분할 수 있습니다.
- action 안에서 set 함수형 업데이트를 사용할 수 있습니다.
- 배열을 불변 방식으로 변경할 수 있습니다.
- 컴포넌트에서 상태 변경 구현을 제거할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.
TaskFlow의 로컬 작업 데이터를 Zustand로 옮기고 addTask, toggleTask, deleteTask action을 store에 만듭니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 직접 실행
작업 원본과 add/toggle/delete를 Store로 이전합니다. main.tsx의 Provider 사용을 끝내고 App·TaskForm·TaskList·TaskItem을 함께 바꿔 두 원본이 공존하지 않게 합니다. 기존 Context 파일은 이전 단계 비교용으로 보존하지만 렌더링 경로에서는 사용하지 않습니다.
예제 크게 보기 (새 탭)중간부터 시작하는 전체 실습 파일
기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
src/App.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskForm.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskItem.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskList.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskModalControls.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskSelection.tsx— 기존 파일의 같은 위치를 갱신합니다.src/main.tsx— 기존 파일의 같은 위치를 갱신합니다.src/store/useTaskStore.ts— 기존 파일의 같은 위치를 갱신합니다.
변경 이유와 결과: 작업 원본과 add/toggle/delete를 Store로 이전합니다. main.tsx의 Provider 사용을 끝내고 App·TaskForm·TaskList·TaskItem을 함께 바꿔 두 원본이 공존하지 않게 합니다. 기존 Context 파일은 이전 단계 비교용으로 보존하지만 렌더링 경로에서는 사용하지 않습니다.
완료본의 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';
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 />
<StateResetDemo />
</main>
);
}
src/components/TaskForm.tsx 전체 파일
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>
);
}
src/components/TaskItem.tsx 전체 파일
import {
useTaskStore,
type Task,
} from '../store/useTaskStore';
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const toggleTask = useTaskStore(
(state) => state.toggleTask,
);
const deleteTask = useTaskStore(
(state) => state.deleteTask,
);
return (
<li>
<button
type="button"
onClick={() => toggleTask(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
<button
type="button"
onClick={() => deleteTask(task.id)}
>
삭제
</button>
</li>
);
}
src/components/TaskList.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
import TaskItem from './TaskItem';
export default function TaskList() {
const { tasks } = useTaskStore();
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/TaskModalControls.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
export default function TaskModalControls() {
const isOpen = useTaskStore(
(state) => state.isTaskModalOpen,
);
const open = useTaskStore((state) => state.openTaskModal);
const close = useTaskStore((state) => state.closeTaskModal);
return (
<section>
<h2>모달 상태</h2>
<p>{isOpen ? '열림' : '닫힘'}</p>
<button
type="button"
onClick={() => open()}
>
열기
</button>
<button
type="button"
onClick={() => close()}
>
닫기
</button>
</section>
);
}
src/components/TaskSelection.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
export default function TaskSelection() {
const selectedTaskId = useTaskStore(
(state) => state.selectedTaskId,
);
const selectTask = useTaskStore(
(state) => state.selectTask,
);
return (
<section>
<h2>선택 상태</h2>
<p>선택한 작업: {selectedTaskId ?? '없음'}</p>
<button
type="button"
onClick={() => selectTask('example-task')}
>
예제 작업 선택
</button>
<button
type="button"
onClick={() => selectTask(null)}
>
선택 해제
</button>
</section>
);
}
src/main.tsx 전체 파일
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
src/store/useTaskStore.ts 전체 파일
import { create } from 'zustand';
export type Task = {
id: string;
title: string;
completed: boolean;
};
export type TaskFilter =
| 'all'
| 'active'
| 'completed';
type TaskStore = {
tasks: Task[];
filter: TaskFilter;
selectedTaskId: string | null;
isTaskModalOpen: boolean;
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;
};
export const useTaskStore = create<TaskStore>((set) => ({
tasks: [],
filter: 'all',
selectedTaskId: null,
isTaskModalOpen: false,
addTask: (title) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
},
],
})),
toggleTask: (id) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
})),
deleteTask: (id) =>
set((state) => ({
tasks: state.tasks.filter(
(task) => task.id !== id,
),
})),
selectTask: (selectedTaskId) =>
set({ selectedTaskId }),
openTaskModal: () =>
set({ isTaskModalOpen: true }),
closeTaskModal: () =>
set({ isTaskModalOpen: false }),
setFilter: (filter) => set({ filter }),
}));
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후 터미널의 localhost 주소를 엽니다.- 작업 원본과 add/toggle/delete를 Store로 이전합니다. main.tsx의 Provider 사용을 끝내고 App·TaskForm·TaskList·TaskItem을 함께 바꿔 두 원본이 공존하지 않게 합니다. 기존 Context 파일은 이전 단계 비교용으로 보존하지만 렌더링 경로에서는 사용하지 않습니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
이 체크포인트는 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과했습니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
이어서 만들기: Zustand state 사용법: 값 읽기와 변경 흐름 익히기의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.
src/store/useTaskStore.ts에 addTask·toggleTask·deleteTask를 구현하고 TaskForm·TaskItem의 이벤트에서 호출합니다. 기존 작업을 읽는 TaskList도 같은 store를 구독해야 목록이 한 원본을 사용합니다.
이전 체크포인트
zustand-step-05-state
이번 목표
TaskFlow의 로컬 작업 데이터를 Zustand로 옮기고 addTask, toggleTask, deleteTask action을 store에 만듭니다.
현재 TaskFlow의 tasks는 학습용 로컬 데이터입니다. 이후 서버 API가 도입되면 서버 원본 tasks는 TanStack Query가 담당해야 합니다.
전체 파일·코드 보기
src/store/useTaskStore.ts
import { create } from 'zustand';
export type Task = {
id: string;
title: string;
completed: boolean;
};
export type TaskFilter =
| 'all'
| 'active'
| 'completed';
type TaskStore = {
tasks: Task[];
filter: TaskFilter;
selectedTaskId: string | null;
isTaskModalOpen: boolean;
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;
};
export const useTaskStore = create<TaskStore>((set) => ({
tasks: [],
filter: 'all',
selectedTaskId: null,
isTaskModalOpen: false,
addTask: (title) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
},
],
})),
toggleTask: (id) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
})),
deleteTask: (id) =>
set((state) => ({
tasks: state.tasks.filter(
(task) => task.id !== id,
),
})),
selectTask: (selectedTaskId) =>
set({ selectedTaskId }),
openTaskModal: () =>
set({ isTaskModalOpen: true }),
closeTaskModal: () =>
set({ isTaskModalOpen: false }),
setFilter: (filter) => set({ filter }),
}));
src/components/TaskForm.tsx
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>
);
}
form input은 TaskForm에서만 쓰기 때문에 로컬 state로 유지합니다.
src/components/TaskItem.tsx
import {
useTaskStore,
type Task,
} from '../store/useTaskStore';
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const toggleTask = useTaskStore(
(state) => state.toggleTask,
);
const deleteTask = useTaskStore(
(state) => state.deleteTask,
);
return (
<li>
<button
type="button"
onClick={() => toggleTask(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
<button
type="button"
onClick={() => deleteTask(task.id)}
>
삭제
</button>
</li>
);
}
Before / After
Before
Context + reducer + dispatch
After
Zustand store + 행동 중심 action
기존 TaskProvider와 taskReducer는 작업 상태에 더 이상 필요하지 않다면 제거할 수 있습니다. 다른 Context 역할이 남아 있다면 필요한 부분만 유지합니다.
실행 확인
npm run dev
npm run build
- 작업 추가
- 완료/완료 취소
- 삭제
- filter와 선택 상태
모두 정상 동작하는지 확인합니다.
완료 체크포인트
zustand-step-06-actions
다음 학습
다음 글에서는 컴포넌트가 store 전체를 구독하지 않고 필요한 상태만 선택하도록 selector를 정리합니다.
다음 글: Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
공식 문서
- Zustand 공식 문서: https://zustand.docs.pmnd.rs/
- Updating state: https://zustand.docs.pmnd.rs/guides/updating-state
글 소개·학습 준비·전체 목차

Zustand · 6 / 15 · 필수 학습 · action
Zustand의 장점은 상태를 전역에서 읽는 것보다, 상태 변경 규칙을 store 안에 모을 수 있다는 점에 있습니다.
먼저 빈 작업 목록을 가진 독립 예제로 추가·완료·삭제 action을 실행합니다. 하단에서는 같은 변경 규칙을 TaskFlow의 store로 옮깁니다.
이번 글에서 배우는 내용
- Zustand action의 역할
- setter와 action의 차이
- action 이름을 행동 중심으로 설계하는 방법
- 이전 state를 사용한 배열 업데이트
- 컴포넌트에서 변경 로직 제거
- reducer dispatch 구조와 비교
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.