React를 배우고 계신가요? 가장 좋은 연습 프로젝트는 바로 투두리스트(To-Do List) 입니다. 이 글에서는 단순히 리스트만 보여주는 것이 아니라, 추가, 수정, 삭제, 검색, 그리고 전체 삭제 기능까지 포함된 완성도 높은 앱을 만들어보겠습니다.
🎯 이 튜토리얼에서 배울 내용
useReducerHook을 사용한 복잡한 상태 관리Context API를 활용한 효율적인 데이터 전달- 컴포넌트 분리와 재사용성
- 검색, 필터링 등 실무 기능 구현
📊 전체 구조 이해하기
먼저 우리가 만들 앱의 전체 구조를 살펴봅시다.
컴포넌트 구조
graph TD
A[App.jsx] --> B[Header]
A --> C[TodoEditor]
A --> D[TodoList]
D --> E[TodoItem]
D --> F[TodoItem]
D --> G[TodoItem]
style A fill:#61dafb,stroke:#000,stroke-width:2px
style B fill:#f9f9f9,stroke:#333
style C fill:#f9f9f9,stroke:#333
style D fill:#f9f9f9,stroke:#333
style E fill:#e9ecef,stroke:#333
style F fill:#e9ecef,stroke:#333
style G fill:#e9ecef,stroke:#333
데이터 흐름
graph LR
A[사용자 입력] -->|onCreate| B[App.jsx Reducer]
B -->|dispatch| C[상태 업데이트]
C -->|Context| D[TodoList]
D -->|props| E[TodoItem들]
E -->|OnUpdate/OnDelete| B
style A fill:#ffd43b,stroke:#333
style B fill:#61dafb,stroke:#333
style C fill:#51cf66,stroke:#333
style D fill:#748ffc,stroke:#333
style E fill:#ff6b6b,stroke:#333
왜 useReducer와 Context API를 사용할까요?
🤔 문제 상황:
useState만 사용하면 할 일 추가, 수정, 삭제… 너무 많은 상태 관리 함수가 필요합니다- 깊이 중첩된 컴포넌트에 props를 계속 전달하는 것은 번거롭습니다 (props drilling)
✅ 해결 방법:
- useReducer: 관련된 상태 변경 로직을 한 곳에 모아서 관리
- Context API: 중간 컴포넌트를 거치지 않고 필요한 곳에 직접 데이터를 전달
sequenceDiagram
participant U as 사용자
participant TE as TodoEditor
participant A as App (Reducer)
participant TL as TodoList
participant TI as TodoItem
U->>TE: 할 일 입력
TE->>A: onCreate() 호출
A->>A: CREATE 액션 처리
A->>TL: Context로 전달
TL->>TI: 새 아이템 렌더링
U->>TI: 완료 체크
TI->>A: OnUpdate() 호출
A->>A: UPDATE 액션 처리
A->>TL: 업데이트된 상태 전달
TL->>TI: 리렌더링
1. 프로젝트 준비
먼저 Vite를 사용하여 React 프로젝트를 생성합니다.
npm create vite@latest todo-app -- --template react
cd todo-app
npm install
폴더 구조
src 폴더 안에 component 폴더를 만들고 다음과 같이 파일을 구성할 것입니다.
src/
├── App.jsx # 메인 앱 로직 (Reducer + Context)
├── App.css # 전체 레이아웃
├── main.jsx # 앱 진입점
├── index.css # 글로벌 스타일
└── component/
├── Header.jsx # 헤더 (날짜 표시)
├── Header.css
├── TodoEditor.jsx # 할 일 추가 입력창
├── TodoEditor.css
├── TodoList.jsx # 할 일 목록 + 검색
├── TodoList.css
├── TodoItem.jsx # 개별 할 일 아이템
└── TodoItem.css
2. 기본 스타일링 (CSS)
앱을 예쁘게 만들기 위해 CSS를 먼저 작성해둡시다.
App.css
전체 레이아웃을 잡습니다.
/* src/App.css */
.App {
max-width: 500px;
margin: 0 auto;
padding: 20px;
background-color: #f8f9fa;
min-height: 100vh;
box-sizing: border-box;
}
Header.css
날짜를 표시할 헤더 스타일입니다.
/* src/component/Header.css */
.Header {
margin-bottom: 20px;
}
.Header h3 {
color: #2c3e50;
margin: 0;
}
.Header h1 {
color: #34495e;
margin-top: 5px;
font-size: 2rem;
}
TodoEditor.css
할 일을 입력하는 에디터 스타일입니다.
/* src/component/TodoEditor.css */
.TodoEditor {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e9ecef;
}
.TodoEditor input {
flex: 1;
padding: 12px;
border: 1px solid #dee2e6;
border-radius: 5px;
font-size: 16px;
}
.TodoEditor input:focus {
outline: none;
border-color: #4dabf7;
}
.TodoEditor button {
padding: 0 20px;
background-color: #228be6;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
}
.TodoEditor button:hover {
background-color: #1c7ed6;
}
TodoList.css
리스트와 검색창 스타일입니다.
/* src/component/TodoList.css */
.TodoList {
background-color: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.list_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.list_header h3 {
margin: 0;
font-size: 18px;
}
.searchbar {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.list_wrapper {
display: flex;
flex-direction: column;
gap: 10px;
}
.clear_btn {
padding: 6px 12px;
background-color: #ff6b6b;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.clear_btn:hover {
background-color: #fa5252;
}
TodoItem.css
개별 아이템 스타일입니다.
/* src/component/TodoItem.css */
.TodoItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f1f3f5;
}
.TodoItem:last-child {
border-bottom: none;
}
.checkbox_col {
width: 20px;
}
.title_col {
flex: 1;
font-size: 16px;
}
.date_col {
font-size: 12px;
color: #868e96;
}
.btn_col button {
padding: 5px 10px;
background-color: #f1f3f5;
border: none;
border-radius: 4px;
cursor: pointer;
color: #495057;
}
.btn_col button:hover {
background-color: #e9ecef;
}
3. 핵심 개념: useReducer 이해하기
Reducer란?
Reducer는 “현재 상태”와 “어떤 액션”을 받아서 “새로운 상태”를 반환하는 함수입니다.
function reducer(현재상태, 액션) {
// 액션의 종류에 따라 다른 작업 수행
switch(액션.type) {
case "추가":
return 추가된_새로운_상태;
case "삭제":
return 삭제된_새로운_상태;
default:
return 현재상태;
}
}
우리 앱의 Reducer
graph TD
A[Reducer] -->|CREATE| B[새 항목 추가]
A -->|UPDATE| C[완료 상태 토글]
A -->|DELETE| D[항목 삭제]
A -->|CLEAR| E[전체 삭제]
B --> F[새로운 상태 반환]
C --> F
D --> F
E --> F
style A fill:#61dafb,stroke:#000,stroke-width:2px
style F fill:#51cf66,stroke:#333
4. 컴포넌트 구현
이제 핵심 로직과 컴포넌트를 하나씩 구현해봅시다.
App.jsx (핵심 로직)
이것이 우리 앱의 두뇌입니다! 모든 상태와 로직이 여기에 모여 있습니다.
// src/App.jsx
import './App.css'
import React, { useReducer, useRef } from 'react';
import Header from './component/Header'
import TodoEditor from './component/TodoEditor'
import TodoList from './component/TodoList'
// 초기 데이터
const mockTodo = [
{
id: 0,
isDone: false,
content: 'React 공부하기',
createDate: new Date().getTime(),
},
{
id: 1,
isDone: false,
content: '블로그 글 쓰기',
createDate: new Date().getTime(),
},
];
// Reducer 함수: 상태 변화 로직을 담당
function reducer(state, action) {
switch(action.type) {
case "CREATE":
// 기존 배열 앞에 새 항목 추가
return [action.newItem, ...state];
case "UPDATE":
// id가 일치하는 항목의 isDone을 반전
return state.map(it =>
it.id === action.id
? { ...it, isDone: !it.isDone }
: it
);
case "DELETE":
// id가 일치하지 않는 항목만 남김
return state.filter(it => it.id !== action.id);
case "CLEAR":
// 빈 배열 반환
return [];
default:
return state;
}
}
// Context 생성 - 전역 데이터 공유
export const TodoContext = React.createContext();
function App() {
// useReducer: [현재상태, dispatch함수] = useReducer(리듀서함수, 초기상태)
const [todo, dispatch] = useReducer(reducer, mockTodo);
// useRef: 컴포넌트가 리렌더링되어도 값이 유지됨 (id 카운터용)
const idRef = useRef(3);
// 기능 1: 할 일 추가
const onCreate = (content) => {
dispatch({
type: "CREATE",
newItem: {
id: idRef.current,
isDone: false,
content,
createDate: new Date().getTime()
}
});
idRef.current++; // 다음 id를 위해 증가
};
// 기능 2: 완료 상태 토글
const OnUpdate = (targetId) => {
dispatch({
type: "UPDATE",
id: targetId
});
};
// 기능 3: 삭제
const OnDelete = (targetId) => {
dispatch({
type: "DELETE",
id: targetId
});
};
// 기능 4: 전체 삭제
const OnClear = () => {
if (window.confirm('정말 모든 할 일을 삭제하시겠습니까?')) {
dispatch({ type: "CLEAR" });
}
};
return (
<div className='App'>
<Header />
{/* Context로 데이터와 함수를 하위 컴포넌트에 제공 */}
<TodoContext.Provider value={{ todo, onCreate, OnUpdate, OnDelete, OnClear }}>
<TodoEditor />
<TodoList />
</TodoContext.Provider>
</div>
)
}
export default App
💡 핵심 포인트:
useReducer는 복잡한 상태 로직을 한 곳에 모아줍니다dispatch를 호출하면 reducer 함수가 실행됩니다Context.Provider로 감싸면 하위 컴포넌트 어디서든 데이터에 접근 가능합니다
Header.jsx
오늘 날짜를 보여주는 간단한 컴포넌트입니다.
// src/component/Header.jsx
import './Header.css';
const Header = () => {
return (
<div className='Header'>
<h3>오늘은 📅</h3>
<h1>{new Date().toDateString()}</h1>
</div>
);
};
export default Header;
TodoEditor.jsx
새로운 할 일을 입력받는 컴포넌트입니다.
// src/component/TodoEditor.jsx
import { useContext, useRef, useState } from 'react';
import { TodoContext } from '../App';
import './TodoEditor.css';
const TodoEditor = () => {
// Context에서 onCreate 함수 가져오기
const { onCreate } = useContext(TodoContext);
// 입력값을 저장할 상태
const [content, setContent] = useState("");
// input 요소에 접근하기 위한 ref
const inputRef = useRef();
// 입력값이 변경될 때마다 상태 업데이트
const onChangeContent = (e) => {
setContent(e.target.value);
};
// 추가 버튼 클릭 시
const onSubmit = () => {
// 빈 값 체크
if (!content) {
inputRef.current.focus();
return;
}
onCreate(content);
setContent(""); // 입력창 비우기
};
// Enter 키 입력 처리
const onKeyDown = (e) => {
if (e.keyCode === 13) {
onSubmit();
}
};
return (
<div className='TodoEditor'>
<input
ref={inputRef}
value={content}
onChange={onChangeContent}
onKeyDown={onKeyDown}
placeholder='새로운 할 일을 입력하세요...'
/>
<button onClick={onSubmit}>추가</button>
</div>
);
};
export default TodoEditor;
💡 핵심 포인트:
useContext로 App에서 제공한onCreate함수를 가져옵니다useState로 입력값을 관리합니다useRef로 input에 포커스를 줄 수 있습니다
TodoList.jsx
할 일 목록을 보여주고, 검색 기능을 제공합니다.
// src/component/TodoList.jsx
import { useContext, useState } from 'react';
import { TodoContext } from '../App';
import TodoItem from './TodoItem';
import './TodoList.css';
const TodoList = () => {
// Context에서 데이터와 함수 가져오기
const { todo, OnClear } = useContext(TodoContext);
// 검색어 상태
const [search, setSearch] = useState("");
// 검색어 변경 시
const onChangeSearch = (e) => {
setSearch(e.target.value);
};
// 검색 결과 필터링
const getSearchResult = () => {
return search === ""
? todo // 검색어가 없으면 전체 표시
: todo.filter((it) =>
// 대소문자 구분 없이 검색
it.content.toLowerCase().includes(search.toLowerCase())
);
};
return (
<div className='TodoList'>
<div className='list_header'>
<h3>할 일 목록 📝</h3>
{/* 할 일이 있을 때만 전체 삭제 버튼 표시 */}
{todo.length > 0 && (
<button className='clear_btn' onClick={OnClear}>
전체 삭제
</button>
)}
</div>
<input
value={search}
onChange={onChangeSearch}
className='searchbar'
placeholder='검색어를 입력하세요'
/>
<div className='list_wrapper'>
{/* 검색 결과를 TodoItem으로 렌더링 */}
{getSearchResult().map((it) => (
<TodoItem key={it.id} {...it} />
))}
</div>
</div>
);
};
export default TodoList;
💡 핵심 포인트:
filter로 검색어가 포함된 항목만 표시합니다toLowerCase()로 대소문자 구분 없이 검색합니다map으로 각 할 일을TodoItem컴포넌트로 렌더링합니다
TodoItem.jsx
개별 할 일 아이템입니다.
// src/component/TodoItem.jsx
import { useContext } from 'react';
import { TodoContext } from '../App';
import './TodoItem.css';
const TodoItem = ({ id, isDone, content, createDate }) => {
// Context에서 업데이트/삭제 함수 가져오기
const { OnUpdate, OnDelete } = useContext(TodoContext);
// 체크박스 변경 시
const onChangeCheckbox = () => {
OnUpdate(id);
};
// 삭제 버튼 클릭 시
const onClickDelete = () => {
OnDelete(id);
};
return (
<div className='TodoItem'>
<div className='checkbox_col'>
<input
onChange={onChangeCheckbox}
checked={isDone}
type="checkbox"
/>
</div>
<div className='title_col'>{content}</div>
<div className='date_col'>
{new Date(createDate).toLocaleDateString()}
</div>
<div className='btn_col'>
<button onClick={onClickDelete}>삭제</button>
</div>
</div>
);
};
export default TodoItem;
💡 핵심 포인트:
- Props로 받은 데이터를 화면에 표시합니다
useContext로 업데이트/삭제 함수를 가져와 사용합니다
5. 데이터 흐름 정리
전체 과정을 다시 한번 정리해볼까요?
sequenceDiagram
autonumber
participant User as 👤 사용자
participant Editor as TodoEditor
participant App as App (Reducer)
participant Context as Context
participant List as TodoList
participant Item as TodoItem
User->>Editor: "React 공부하기" 입력
Editor->>App: onCreate("React 공부하기")
App->>App: dispatch({type: "CREATE", ...})
App->>App: reducer 실행
App->>Context: 새로운 todo 상태 제공
Context->>List: todo 배열 전달
List->>Item: map으로 각 항목 렌더링
Item-->>User: 화면에 표시
🔄 업데이트 흐름:
- 사용자가 체크박스 클릭
- TodoItem이
OnUpdate(id)호출 - App의
dispatch가 실행되어 reducer 호출 - reducer가 새로운 상태 반환
- Context가 업데이트된 상태를 전파
- TodoList와 TodoItem이 리렌더링
6. 마무리
이제 터미널에서 npm run dev를 실행하여 앱을 확인해보세요!
✅ 체크리스트
- 할 일을 추가할 수 있나요?
- 체크박스로 완료 표시가 되나요?
- 삭제 버튼이 작동하나요?
- 검색이 잘 되나요?
- 전체 삭제가 동작하나요?
🚀 더 나아가기
이 프로젝트를 바탕으로 다음 기능을 추가해보세요:
-
로컬 스토리지 연동
useEffect(() => { localStorage.setItem('todos', JSON.stringify(todo)); }, [todo]); -
완료/미완료 필터
- 전체 보기 / 완료된 것만 / 미완료만 보기
-
우선순위 기능
- 중요도를 설정하고 색상으로 구분
-
수정 기능
- 할 일 내용을 나중에 수정할 수 있게
Happy Coding! 🚀
참고 자료: 이 글은 ‘한입 크기로 잘라 먹는 리액트’를 바탕으로 작성되었습니다.