ReactReact 기초 · 10기초

React 실전 프로젝트 — 할일 관리 앱

React프로젝트TypeScriptContextRouter

프로젝트 구조

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를 배웁니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.