본문 바로가기
Programming/React

zustand 사용 (js) - todo list sample

by web data 2026. 10. 16.

1. 패키지 설치

npm install zustand
# 또는
yarn add zustand

 

 

2. 스토어(Store) 정의 파일 (src/store/useStore.js)

import { create } from 'zustand';

// create 함수를 이용해 스토어를 생성합니다.
// set: 상태를 업데이트(병합)하는 함수
// get: 현재 스토어의 상태를 조회할 수 있는 함수
export const useStore = create((set, get) => ({
  // ==============================
  // 1. 카운터 관련 상태 및 액션
  // ==============================
  count: 0,
  
  // 1씩 증가시키는 액션
  increase: () => set((state) => ({ count: state.count + 1 })),
  
  // 1씩 감소시키는 액션
  decrease: () => set((state) => ({ count: state.count - 1 })),
  
  // 특정 값만큼 증가시키는 액션 (인자 받기 가능)
  increaseBy: (amount) => set((state) => ({ count: state.count + amount })),

  // ==============================
  // 2. 할 일(Todo) 목록 및 비동기 액션
  // ==============================
  todos: [],
  isLoading: false,
  error: null,

  // 로컬에서 Todo 추가하기
  addTodo: (text) => 
    set((state) => ({
      todos: [
        ...state.todos, 
        { id: Date.now(), text, completed: false }
      ]
    })),

  // Todo 완료 상태 토글하기
  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    })),

  // 비동기 API 호출 예시 (서버에서 할 일 목록을 가져오는 시뮬레이션)
  fetchTodos: async () => {
    // 로딩 시작, 이전 에러 초기화
    set({ isLoading: true, error: null });
    
    try {
      // 1초 뒤에 데이터가 응답한다고 가정 (실제로는 fetch나 axios 사용)
      await new Promise((resolve) => setTimeout(resolve, 1000));
      
      const mockData = [
        { id: 1, text: 'Zustand 공부하기', completed: true },
        { id: 2, text: '리액트 컴포넌트 작성하기', completed: false },
      ];

      // 성공 시 데이터 저장 및 로딩 종료
      set({ todos: mockData, isLoading: false });
    } catch (err) {
      // 에러 발생 시 에러 메시지 저장 및 로딩 종료
      set({ error: '데이터를 불러오는데 실패했습니다.', isLoading: false });
    }
  },

  // ==============================
  // 3. 계산된 상태(Getter/Selectors) 예시
  // ==============================
  // 완료되지 않은 할 일의 개수를 반환하는 함수
  getUncompletedCount: () => {
    // get()을 사용하여 현재 스토어의 state에 접근할 수 있습니다.
    const state = get();
    return state.todos.filter((todo) => !todo.completed).length;
  },
}));

 

3. 컴포넌트 사용 예시 (src/App.jsx)

import React, { useState } from 'react';
import { useStore } from './store/useStore';

export default function App() {
  // 1. 필요한 상태와 액션만 골라서(Destructuring) 가져옵니다.
  // Zustand는 필요한 것만 콕 집어 가져오기 때문에 불필요한 리렌더링을 방지합니다.
  const count = useStore((state) => state.count);
  const increase = useStore((state) => state.increase);
  const decrease = useStore((state) => state.decrease);
  const increaseBy = useStore((state) => state.increaseBy);

  const todos = useStore((state) => state.todos);
  const isLoading = useStore((state) => state.isLoading);
  const error = useStore((state) => state.error);
  const addTodo = useStore((state) => state.addTodo);
  const toggleTodo = useStore((state) => state.toggleTodo);
  const fetchTodos = useStore((state) => state.fetchTodos);
  const getUncompletedCount = useStore((state) => state.getUncompletedCount);

  // 로컬 입력값 상태 (컴포넌트 내부 상태는 기존 방식대로 useState 사용 가능)
  const [inputText, setInputText] = useState('');

  const handleAddSubmit = (e) => {
    e.preventDefault();
    if (!inputText.trim()) return;
    addTodo(inputText);
    setInputText(''); // 입력창 초기화
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '600px', margin: '0 auto' }}>
      <h1>🚀 Zustand 상태 관리 샘플</h1>

      {/* ================= 카운터 섹션 ================= */}
      <section style={{ marginBottom: '30px', padding: '15px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <h2>카운터: {count}</h2>
        <div style={{ display: 'flex', gap: '10px' }}>
          <button onClick={increase}>+ 1 증가</button>
          <button onClick={decrease}>- 1 감소</button>
          <button onClick={() => increaseBy(5)}>+ 5 한번에 증가</button>
        </div>
      </section>

      {/* ================= 투두 리스트 섹션 ================= */}
      <section style={{ padding: '15px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <h2>할 일 목록 (남은 할 일: {getUncompletedCount()}개)</h2>

        {/* 비동기 데이터 가져오기 버튼 */}
        <div style={{ marginBottom: '15px' }}>
          <button onClick={fetchTodos} disabled={isLoading}>
            {isLoading ? '로딩 중...' : '서버에서 Todo 불러오기 (비동기)'}
          </button>
        </div>

        {/* 에러 메시지 출력 */}
        {error && <p style={{ color: 'red' }}>{error}</p>}

        {/* 새로운 Todo 입력 폼 */}
        <form onSubmit={handleAddSubmit} style={{ marginBottom: '15px' }}>
          <input
            type="text"
            value={inputText}
            onChange={(e) => setInputText(e.target.value)}
            placeholder="새로운 할 일을 입력하세요..."
            style={{ padding: '6px', width: '70%', marginRight: '8px' }}
          />
          <button type="submit">추가</button>
        </form>

        {/* Todo 목록 렌더링 */}
        <ul>
          {todos.map((todo) => (
            <li
              key={todo.id}
              onClick={() => toggleTodo(todo.id)}
              style={{
                cursor: 'pointer',
                textDecoration: todo.completed ? 'line-through' : 'none',
                color: todo.completed ? '#888' : '#000',
                marginBottom: '6px',
              }}
            >
              {todo.text} {todo.completed ? '✅' : '⏳'}
            </li>
          ))}
        </ul>
      </section>
    </div>
  );
}

 

.