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
속성을 정의합니다.TodoState
todos
배열을 정의합니다.TodoActions
addTodo
, toggleTodo
, removeTodo
함수들의 시그니처를 정의합니다.create
set
함수를 사용하여 각 액션이 어떻게 상태를 변경하는지 구체적으로 구현합니다. map
과 filter
를 사용하여 불변성을 지키면서 배열을 업데이트하는 방식에 주목해 주세요.TodoInput.tsx
TodoList.tsx
todos
배열을 스토어에서 가져와 목록 형태로 보여주는 역할.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
를 적재적소에 사용하여 컴포넌트의 역할을 분리하고 성능까지 고려하는 설계 방식을 익혔습니다.