미니 프로젝트: 투두 앱을 처음부터 완성까지 만들면서 지금까지 배운 useState, 이벤트, 리스트 렌더링, 조건부 렌더링을 실전에서 통합합니다.
🚀 미니 프로젝트: 투두(Todo) 앱 완성
이번 강의는 지금까지 배운 내용을 실제로 통합하는 미니 프로젝트입니다. 코드를 보기 전에 어떤 기능이 필요한지 먼저 정의합니다. 이 과정 자체가 실무 개발 방식과 동일합니다.
기능 목록 (기획 단계):
- 할 일 추가 (입력창 + 버튼 or 엔터)
- 할 일 완료 체크 (토글)
- 할 일 삭제
- 필터: 전체 / 할 일 / 완료
- 남은 할 일 개수 표시
- 전체 완료 시 메시지
🗂️ 타입 정의 및 상태 설계
코드를 시작하기 전에 데이터 구조를 먼저 정의합니다. TypeScript에서 타입을 먼저 설계하면 전체 구조가 명확해집니다:
interface Todo {
id: number
text: string
completed: boolean
createdAt: Date
}
type FilterType = 'all' | 'active' | 'completed'
// 상태: 투두 목록 + 필터
const [todos, setTodos] = useState<Todo[]>([])
const [filter, setFilter] = useState<FilterType>('all')
const [inputText, setInputText] = useState('')
💻 전체 구현
import { useState } from 'react'
interface Todo {
id: number
text: string
completed: boolean
createdAt: Date
}
type FilterType = 'all' | 'active' | 'completed'
export default function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([
{ id: 1, text: '리액트 공부하기', completed: false, createdAt: new Date() },
{ id: 2, text: '미니 프로젝트 완성', completed: false, createdAt: new Date() },
])
const [filter, setFilter] = useState<FilterType>('all')
const [inputText, setInputText] = useState('')
// 추가
function addTodo() {
const text = inputText.trim()
if (!text) return
setTodos((prev) => [
...prev,
{ id: Date.now(), text, completed: false, createdAt: new Date() },
])
setInputText('')
}
// 완료 토글
function toggleTodo(id: number) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
)
}
// 삭제
function deleteTodo(id: number) {
setTodos((prev) => prev.filter((t) => t.id !== id))
}
// 필터링
const filteredTodos = todos.filter((t) => {
if (filter === 'active') return !t.completed
if (filter === 'completed') return t.completed
return true
})
const activeCount = todos.filter((t) => !t.completed).length
const allCompleted = todos.length > 0 && todos.every((t) => t.completed)
return (
<div className="min-h-screen bg-gray-50 flex items-start justify-center pt-16 px-4">
<div className="w-full max-w-md">
<h1 className="text-3xl font-black text-gray-900 mb-6">오늘 할 일 ✅</h1>
{/* 입력창 */}
<div className="flex gap-2 mb-4">
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addTodo() }}
placeholder="할 일을 입력하세요..."
className="flex-1 border border-gray-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-indigo-400"
/>
<button
onClick={addTodo}
disabled={!inputText.trim()}
className="px-5 py-3 bg-indigo-600 text-white rounded-xl text-sm font-bold hover:bg-indigo-700 disabled:opacity-40"
>
추가
</button>
</div>
{/* 필터 탭 */}
<div className="flex gap-1 mb-4">
{(['all', 'active', 'completed'] as FilterType[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`flex-1 py-2 rounded-lg text-sm font-medium ${
filter === f
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{f === 'all' ? '전체' : f === 'active' ? '할 일' : '완료'}
</button>
))}
</div>
{/* 목록 */}
{allCompleted ? (
<div className="bg-green-50 rounded-2xl p-8 text-center">
<p className="text-2xl">🎉</p>
<p className="text-green-700 font-bold mt-2">오늘 할 일을 모두 완료했습니다!</p>
</div>
) : filteredTodos.length === 0 ? (
<div className="bg-white rounded-2xl p-8 text-center text-gray-400 border border-gray-100">
할 일이 없습니다
</div>
) : (
<ul className="space-y-2">
{filteredTodos.map((todo) => (
<li
key={todo.id}
className="bg-white rounded-xl border border-gray-100 px-4 py-3 flex items-center gap-3 group"
>
<button
onClick={() => toggleTodo(todo.id)}
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 ${
todo.completed
? 'bg-green-500 border-green-500'
: 'border-gray-300 hover:border-indigo-400'
}`}
>
{todo.completed && <span className="text-white text-xs">✓</span>}
</button>
<span
className={`flex-1 text-sm ${
todo.completed ? 'line-through text-gray-400' : 'text-gray-800'
}`}
>
{todo.text}
</span>
<button
onClick={() => deleteTodo(todo.id)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-opacity text-xs"
>
삭제
</button>
</li>
))}
</ul>
)}
{/* 남은 할 일 카운트 */}
{todos.length > 0 && (
<p className="text-center text-xs text-gray-400 mt-4">
남은 할 일: {activeCount}개
</p>
)}
</div>
</div>
)
}
🧩 코드 포인트 정리
이 프로젝트에서 적용한 개념들:
useState: todos 배열, filter, inputText 세 가지 상태 관리- 불변성:
map으로 새 배열 생성(토글),filter로 새 배열 생성(삭제), 스프레드로 추가 - 조건부 렌더링: 전체 완료 → 축하 메시지, 빈 목록 → 안내, 아니면 목록
- 리스트 렌더링:
map+key={todo.id} - 이벤트: onClick, onChange, onKeyDown, 그룹 hover로 삭제 버튼 표시
✅ 정리
- 타입 먼저 정의 → 상태 설계 → UI 구현 순서로 진행했습니다.
- 불변성 원칙: 추가는 스프레드, 수정은 map, 삭제는 filter.
group/group-hover클래스로 마우스 오버 시에만 삭제 버튼 표시.- 다음 강의에서 이 앱에 라우팅을 추가해봅니다.
관련 주제
- Todo 앱 요구사항 정의
- 컴포넌트 설계
- 입력 추가 기능
- 리스트 렌더링 실습
- 개발·프로그래밍
- 개발·프로그래밍 강의
- 리액트 입문 — 컴포넌트로 만드는 웹
- 무료강의
- 무료 온라인 강의
- NUGUNA
- 누구나
댓글
0/1000
불러오는 중...
