
selector, shallow와 같은 개념까지 깊이 있게 탐험했습니다.
이번 3편에서는 가장 대표적인 웹 애플리케이션 예제인 'To-Do 리스트 앱'을 직접 만들어 보겠습니다.
이 과정을 통해 우리는 다음과 같은 실전 기술들을 자연스럽게 익히게 될 것입니다.할 일 추가하기, 할 일 완료 처리하기, 할 일 삭제하기가 필요합니다.import { create } from 'zustand';
// 개별 '할 일' 항목에 대한 타입
export interface Todo {
id: number;
text: string;
isDone: boolean;
}
// 스토어의 상태(State)에 대한 타입
interface TodoState {
todos: Todo[];
}
// 스토어의 행동(Actions)에 대한 타입
interface TodoActions {
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
removeTodo: (id: number) => void;
}
// create 함수로 스토어를 생성합니다.
export const useTodoStore = create<TodoState & TodoActions>((set) => ({
// 초기 상태
todos: [
{ id: 1, text: 'Zustand 공부하기', isDone: true },
{ id: 2, text: 'To-Do 앱 만들기', isDone: false },
],
// 할 일을 추가하는 액션
addTodo: (text) =>
set((state) => ({
todos: [
...state.todos,
{ id: Date.now(), text, isDone: false },
],
})),
// 할 일의 완료 상태를 변경하는 액션
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, isDone: !todo.isDone } : todo
),
})),
// 할 일을 삭제하는 액션
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
}));Todo 타입id, text, isDone 속성을 정의합니다.TodoStatetodos 배열을 정의합니다.TodoActionsaddTodo, toggleTodo, removeTodo 함수들의 시그니처를 정의합니다.createset 함수를 사용하여 각 액션이 어떻게 상태를 변경하는지 구체적으로 구현합니다. map과 filter를 사용하여 불변성을 지키면서 배열을 업데이트하는 방식에 주목해 주세요.TodoInput.tsxTodoList.tsxtodos 배열을 스토어에서 가져와 목록 형태로 보여주는 역할.TodoItem.tsx이미지 제안: App 컴포넌트 아래에 TodoInput과 TodoList가 있고, TodoList 아래에 여러 개의 TodoItem 컴포넌트가 있는 트리 다이어그램. 그리고 이 모든 컴포넌트들이 중앙의 'useTodoStore'를 가리키는 화살표를 그려, 컴포넌트 구조와 상태 저장소의 관계를 시각적으로 보여줍니다.

TodoInput.tsx: 폼 상태 다루기 🔗input 태그의 값은, '추가' 버튼을 누르기 전까지는 다른 어떤 컴포넌트와도 공유할 필요가 없습니다.
이것은 전형적인 useState를 사용해 관리하는 것이 가장 효율적입니다.import React, { useState } from 'react';
import { useTodoStore } from '../store/todoStore';
export function TodoInput() {
// 입력값은 컴포넌트 내부의 지역 상태로 관리
const [text, setText] = useState('');
// 스토어에서 'addTodo' 액션만 선택하여 가져옴
const addTodo = useTodoStore((state) => state.addTodo);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) return; // 비어있는 입력은 추가하지 않음
addTodo(text); // 전역 스토어의 액션을 호출
setText(''); // 입력 필드 초기화
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="새로운 할 일을 입력하세요..."
/>
<button type="submit">추가</button>
</form>
);
}addTodo 액션만 selector를 이용해 가져왔다는 점에 주목하세요. 이렇게 하면 todos 배열이 변경되어도 TodoInput 컴포넌트는 불필요하게 리렌더링되지 않습니다.TodoList.tsx와 TodoItem.tsx: 목록 표시와 상호작용 🔗TodoList는 스토어에서 todos 배열을 가져와 각 항목을 TodoItem 컴포넌트로 렌더링하는 역할만 합니다.import { useTodoStore, Todo } from '../store/todoStore';
import { TodoItem } from './TodoItem';
export function TodoList() {
// 스토어에서 todos 배열만 선택하여 가져옴
const todos = useTodoStore((state) => state.todos);
return (
<ul>
{todos.map((todo: Todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}TodoItem 컴포넌트에서 일어납니다.
zustand 4버전부터는 useShallow를 사용하여 적용을 해야 합니다.import { useTodoStore, type Todo } from "../store/todoStore";
import { useShallow } from "zustand/shallow";
interface TodoItemProps {
todo: Todo;
}
export function TodoItem({ todo }: TodoItemProps) {
// toggleTodo와 removeTodo 두 개의 액션을 가져옴
const { toggleTodo, removeTodo } = useTodoStore(
useShallow((state) => ({
toggleTodo: state.toggleTodo,
removeTodo: state.removeTodo,
})),
);
return (
<li>
<span
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.isDone ? "line-through" : "none" }}
>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>삭제</button>
</li>
);
}
TodoItem에서는 toggleTodo와 removeTodo 두 개의 액션 함수가 필요합니다.
2편에서 배운 것처럼, 여러 개의 상태나 액션을 객체로 묶어 가져올 때는 불필요한 리렌더링을 방지하기 위해 반드시 useShallow를 함께 사용해야 합니다.
prop drilling 없이도, 각 페이지 컴포넌트는 그저 동일한 useTodoStore 훅을 호출하기만 하면 됩니다.
스토어에 필터 상태를 추가해 보겠습니다.// ... 기존 타입 정의
interface TodoState {
todos: Todo[];
filter: 'all' | 'done' | 'undone'; // 필터 상태 추가
}
interface TodoActions {
// ... 기존 액션들
setFilter: (filter: 'all' | 'done' | 'undone') => void; // 필터 변경 액션 추가
}
// ... create 함수 내부
// ... 기존 상태 및 액션들
filter: 'all',
setFilter: (newFilter) => set({ filter: newFilter }),setFilter를 호출하고, 'To-Do 페이지'의 TodoList 컴포넌트에서는 todos와 filter 상태를 함께 가져와 필터링된 목록을 보여주기만 하면 됩니다.
어떤 페이지에 있든 같은 스토어를 바라보기 때문에 상태는 항상 동기화됩니다.useState를 사용한 지역 상태와 Zustand를 사용한 전역 상태를 역할에 맞게 구분했습니다.
또한, selector와 useShallow를 적재적소에 사용하여 컴포넌트의 역할을 분리하고 성능까지 고려하는 설계 방식을 익혔습니다.