ReactReact 기초강의 10개 중 10번째난이도 기초

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

발행 읽는 시간 약 1 분

  • React
  • 프로젝트
  • TypeScript
  • Context
  • Router

강의 소개

React 기초 시리즈 총정리 프로젝트입니다. Context, 커스텀 훅, 라우팅, TypeScript를 모두 활용해 완성도 있는 할일 관리 앱을 만듭니다.

프로젝트 구조#

Text
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

타입 정의#

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#

TSX
// 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>
    );
}

컴포넌트 구현#

TSX
// 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>
    );
}

페이지와 라우팅#

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

Follow · Ask · Work

이 글이 도움이 됐다면

짧은 작업 기록과 데모는 인스타그램에 올립니다. 만들고 싶은 것이 있다면 프로젝트 의뢰나 협업 제안도 편하게 보내 주세요.

글쓴이

파란여우

직접 만든 AI 앱·제품의 개발·배포를 공개하는 실전형 테크 크리에이터

파란여우 소개 Instagram @bluefox._.hi

이어서 보기

만들고 싶은 것이 있다면 프로젝트 의뢰로 알려 주세요.