
이 글은 프론트엔드 라이브러리 연결 과정의 일부입니다. React 컴포넌트·props·state·이벤트와 TypeScript 객체·배열 타입을 먼저 익혀 주세요. 원본 강의의 문장을 옮기는 대신 독립적인 예제와 확인 과제를 구성했습니다.
학습 목표: 할 일 추가·완료·삭제를 action으로 모으고 새로고침 후 상태를 복원합니다.
예상 학습 시간: 45분. 개인별 차이가 있으며 설치 시간은 제외합니다.
입력값과 저장할 목록을 나눕니다
타이핑 중인 content는 폼 안에서만 필요하므로 useState에 둡니다. 저장한 todos는 목록과 다른 화면이 함께 볼 수 있으므로 store에 둡니다. 입력 한 글자마다 전역 상태를 바꿀 이유는 없습니다. 이 실습은 브라우저 저장소를 원본으로 사용하는 로컬 목록이며, 다음 서버 실습의 목록과 자동으로 동기화하지 않습니다.
actions 객체와 전용 훅
add·toggle·remove를 actions에 모으면 컴포넌트는 기능 이름을 호출하는 데 집중합니다. useTodoItems와 useTodoActions는 필요한 부분만 선택합니다. actions 객체 참조가 유지되는 동안 todos 변경만으로 action 선택 결과가 달라지지 않습니다. 다만 부모 렌더 등 다른 경로의 렌더까지 차단하는 것은 아닙니다.
전용 훅은 store 내부 경로 변경을 한곳에서 처리하게 해 줍니다. 모든 함수에 훅을 하나씩 만드는 규칙이 필수인 것은 아닙니다. 이 예제에서는 목록과 action 묶음이라는 두 역할만 공개해 과도한 파일 분리를 피했습니다. 이름과 타입이 안정된 공개 인터페이스를 만드는 것이 목적입니다.
ID·입력 검증·삭제 조건
목록 마지막 ID + 1은 마지막 항목을 삭제한 뒤 재사용되거나 외부 데이터와 충돌할 수 있습니다. 실습은 crypto.randomUUID로 문자열 ID를 만듭니다. localhost 또는 HTTPS 환경에서 사용하며 서버 Todo에서는 서버가 ID를 생성합니다. 공백은 action에서도 거부하여 UI 외부에서 호출해도 빈 항목이 들어가지 않게 합니다.
삭제는 filter(item => item.id !== id)입니다. 비교 연산자를 대입으로 바꾸면 ID가 손상되거나 엉뚱한 항목이 남습니다. 완료 체크는 checked와 onChange를 함께 쓰며 label로 클릭 대상을 연결합니다. 빈 목록에도 설명을 표시해 오류와 정상 빈 상태를 구분합니다.
persist에 저장하는 범위
partialize는 todos만 선택합니다. JSON 직렬화에서 함수는 저장되지 않습니다. 특히 actions 객체가 빈 객체로 직렬화된 뒤 얕은 복원으로 덮이면 함수가 사라질 수 있으므로 저장할 데이터만 명시합니다. partialize는 모든 store에서 강제되는 옵션이 아니라 저장 계약을 좁히는 선택입니다. 스키마가 바뀌면 version·migrate를 추가하는 기존 학습으로 이어집니다.
직접 해보기와 확인 질문
할 일 두 개 추가 → 하나 완료 → 하나 삭제 → 새로고침 → 다시 추가를 실행하세요. 개발자 도구에서 저장된 JSON을 확인하세요.
풀이 기준 보기
todos 배열만 남고 actions는 저장되지 않아야 합니다. 새로고침 뒤에도 추가·삭제가 동작해야 합니다. 브라우저 데이터를 지우면 목록이 사라지므로 서버 백업으로 오해하지 않습니다.
공통 실습 실행
아래 공통 ZIP을 풀고 Node.js 24 환경에서 실행합니다. package-lock.json에 실습 버전을 고정했습니다. 본문의 경로와 ZIP의 경로는 같습니다.
npm ci
npm run dev
터미널에 표시된 주소를 열고 이 글에 해당하는 메뉴를 선택하세요. 서버 Todo만 별도 터미널의 npm run server가 필요합니다. 본문 코드에서 생략한 공통 설정과 UI 파일까지 ZIP에 포함되어 있습니다.
src/types.ts
공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.
export type Todo = { id: string; content: string; isDone: boolean };
src/stores/local-todos.ts
공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.
import { create } from "zustand";
import {
combine,
devtools,
persist,
subscribeWithSelector,
} from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import type { Todo } from "../types";
export const useLocalTodos = create(
devtools(
persist(
subscribeWithSelector(
immer(
combine({ todos: [] as Todo[] }, (set) => ({
actions: {
add: (content: string) =>
set((state) => {
const value = content.trim();
if (!value) return;
state.todos.push({
id: crypto.randomUUID(),
content: value,
isDone: false,
});
}),
toggle: (id: string) =>
set((state) => {
const todo = state.todos.find((item) => item.id === id);
if (todo) todo.isDone = !todo.isDone;
}),
remove: (id: string) =>
set((state) => {
state.todos = state.todos.filter((item) => item.id !== id);
}),
},
})),
),
),
{
name: "blogflow-library-local-v1",
partialize: (state) => ({ todos: state.todos }),
},
),
{ name: "BlogFlow Local Todo", enabled: import.meta.env.DEV },
),
);
export const useTodoItems = () => useLocalTodos((state) => state.todos);
export const useTodoActions = () => useLocalTodos((state) => state.actions);
src/pages/local-page.tsx
공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.
import { useState, type FormEvent } from "react";
import { useTodoActions, useTodoItems } from "../stores/local-todos";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export default function LocalPage() {
const todos = useTodoItems();
const actions = useTodoActions();
const [content, setContent] = useState("");
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!content.trim()) return;
actions.add(content);
setContent("");
}
return (
<section>
<h2>로컬 Todo</h2>
<p>이 목록은 현재 브라우저에만 저장합니다.</p>
<form onSubmit={submit}>
<label htmlFor="local-content">할 일</label>
<Input
id="local-content"
value={content}
onChange={(event) => setContent(event.target.value)}
/>
<Button type="submit">추가</Button>
</form>
<ul>
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between gap-4 border p-4"
>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={todo.isDone}
onChange={() => actions.toggle(todo.id)}
/>
{todo.content}
</label>
<Button
variant="destructive"
onClick={() => actions.remove(todo.id)}
>
삭제
</Button>
</li>
))}
</ul>
{todos.length === 0 && <p>할 일이 없습니다.</p>}
</section>
);
}
UI 파일 범위: ZIP의 src/components/ui는 shadcn CLI 생성물이 아닙니다. 레지스트리 접속 실패로 Radix·Sonner·Embla를 사용하는 축약 학습용 구현을 작성했습니다. 공식 설치 절차는 별도 프로젝트에서 비교하세요. 공식 컴포넌트 전체 기능과 동일하다고 보장하지 않습니다.
검증 범위
Node.js 24.19.0, React 19.3.0, React Router 7.18.3, Zustand 5.0.15, TanStack Query 5.102.8, Tailwind CSS 4.3.3에서 타입 검사와 Vite 빌드를 확인했습니다. 자동 테스트는 jsdom 기반입니다. 실제 브라우저의 포커스·레이아웃·화면낭독기와 모든 네트워크 경쟁 상황은 별도 확인 대상입니다.
npm test
npm run build
공식 문서
2026-09-14 확인. 공식 최신 문서의 버전과 실습 고정 버전이 다를 수 있으므로 설치 버전은 ZIP을 기준으로 비교합니다.
이 글이 도움이 되었나요?
Zustand 학습 순서
필수 14개 · 전체 15개
읽음 기록 관리
전체 과정 목차 (15개)
- 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
- 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
- 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
- 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
- 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기
- 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기
- 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
- 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
- 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기
- 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
- 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
- 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
- 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성
- 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
- 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결 현재 글
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.