프로젝트 구조
src/
├── types/
│ └── todo.ts
├── contexts/
│ └── TodoContext.tsx
├── hooks/
│ ├── useLocalStorage.ts
│ └── useFilter.ts
├── components/
│ ├── TodoInput.tsx
│ ├── TodoItem.tsx
│ └── TodoFilter.tsx
├── pages/
│ ├── TodoPage.tsx
│ └── StatsPage.tsx
└── App.tsx
타입 정의
// src/types/todo.ts
export interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: Date;
category: string;
}
export type FilterType = "all" | "active" | "completed";
export interface TodoStats {
total: number;
completed: number;
active: number;
completionRate: number;
}
Todo Context
// src/contexts/TodoContext.tsx
import { createContext, useContext, useCallback, ReactNode } from "react";
import { Todo, FilterType } from "../types/todo";
import { useLocalStorage } from "../hooks/useLocalStorage";
interface TodoContextValue {
todos: Todo[];
filter: FilterType;
addTodo: (text: string, category: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
editTodo: (id: string, text: string) => void;
clearCompleted: () => void;
setFilter: (filter: FilterType) => void;
}
const TodoContext = createContext<TodoContextValue | null>(null);
export function useTodo() {
const ctx = useContext(TodoContext);
if (!ctx) throw new Error("useTodo는 TodoProvider 안에서 사용해야 합니다.");
return ctx;
}
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, setTodos] = useLocalStorage<Todo[]>("todos", []);
const [filter, setFilter] = useLocalStorage<FilterType>("filter", "all");
const addTodo = useCallback((text: string, category: string) => {
const todo: Todo = {
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date(),
category,
};
setTodos(prev => [todo, ...prev]);
}, [setTodos]);
const toggleTodo = useCallback((id: string) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}, [setTodos]);
const deleteTodo = useCallback((id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, [setTodos]);
const editTodo = useCallback((id: string, text: string) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, text } : t
));
}, [setTodos]);
const clearCompleted = useCallback(() => {
setTodos(prev => prev.filter(t => !t.completed));
}, [setTodos]);
return (
<TodoContext.Provider value={{
todos, filter, addTodo, toggleTodo, deleteTodo, editTodo,
clearCompleted, setFilter,
}}>
{children}
</TodoContext.Provider>
);
}
컴포넌트 구현
// src/components/TodoInput.tsx
import { useState, FormEvent } from "react";
import { useTodo } from "../contexts/TodoContext";
const CATEGORIES = ["업무", "개인", "학습", "건강"];
function TodoInput() {
const [text, setText] = useState("");
const [category, setCategory] = useState(CATEGORIES[0]);
const { addTodo } = useTodo();
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!text.trim()) return;
addTodo(text.trim(), category);
setText("");
}
return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<select value={category} onChange={e => setCategory(e.target.value)}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="할 일을 입력하세요"
className="flex-1 border rounded px-3 py-2"
/>
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
추가
</button>
</form>
);
}
// src/components/TodoItem.tsx
import { useState, KeyboardEvent } from "react";
import { Todo } from "../types/todo";
import { useTodo } from "../contexts/TodoContext";
function TodoItem({ todo }: { todo: Todo }) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const { toggleTodo, deleteTodo, editTodo } = useTodo();
function handleEditSave() {
if (editText.trim()) editTodo(todo.id, editText.trim());
setIsEditing(false);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape") { setEditText(todo.text); setIsEditing(false); }
}
return (
<li className={`flex items-center gap-2 p-3 border-b ${todo.completed ? "opacity-50" : ""}`}>
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
{isEditing ? (
<input
value={editText}
onChange={e => setEditText(e.target.value)}
onBlur={handleEditSave}
onKeyDown={handleKeyDown}
autoFocus
className="flex-1 border rounded px-2"
/>
) : (
<span
className={`flex-1 ${todo.completed ? "line-through" : ""}`}
onDoubleClick={() => setIsEditing(true)}
>
<span className="text-xs text-gray-400 mr-1">[{todo.category}]</span>
{todo.text}
</span>
)}
<button onClick={() => deleteTodo(todo.id)} className="text-red-400">삭제</button>
</li>
);
}
페이지와 라우팅
// src/pages/TodoPage.tsx
import { useMemo } from "react";
import { useTodo } from "../contexts/TodoContext";
function TodoPage() {
const { todos, filter, setFilter, clearCompleted } = useTodo();
const filtered = useMemo(() => {
if (filter === "active") return todos.filter(t => !t.completed);
if (filter === "completed") return todos.filter(t => t.completed);
return todos;
}, [todos, filter]);
const activeCount = todos.filter(t => !t.completed).length;
return (
<div className="max-w-lg mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">할일 관리</h1>
<TodoInput />
<div className="flex gap-2 mb-3">
{(["all", "active", "completed"] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={filter === f ? "font-bold" : "text-gray-400"}
>
{f === "all" ? "전체" : f === "active" ? "진행중" : "완료"}
</button>
))}
</div>
<ul>{filtered.map(todo => <TodoItem key={todo.id} todo={todo} />)}</ul>
<div className="flex justify-between text-sm text-gray-500 mt-3">
<span>{activeCount}개 남음</span>
<button onClick={clearCompleted}>완료 항목 삭제</button>
</div>
</div>
);
}
// src/App.tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { TodoProvider } from "./contexts/TodoContext";
function App() {
return (
<TodoProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<TodoPage />} />
</Routes>
</BrowserRouter>
</TodoProvider>
);
}
학습 정리
React 기초 시리즈에서 배운 내용:
| 편 | 주제 |
|---|---|
| 1편 | React 소개, JSX, 첫 컴포넌트 |
| 2편 | Props와 State, 불변성 |
| 3편 | 이벤트 처리, 폼 |
| 4편 | useEffect, 데이터 페칭 |
| 5편 | 조건부 렌더링, 리스트와 key |
| 6편 | useRef, useMemo, useCallback |
| 7편 | Context API |
| 8편 | 커스텀 훅 |
| 9편 | React Router |
| 10편 | 실전 프로젝트 |
다음은 Next.js 기초 — React 위에서 SSR, 파일 기반 라우팅, API Routes를 배웁니다.