지난 To-Do 리스트 만들기에서는 다양한 기능을 추가하다 보니 추가와 수정을 통해서 복잡해지고, 관리하기가 어려웠던 경험을 바탕으로 이번에는 To-Do 리스트 프로젝트를 컴포넌트를 분리해서 각 컴포넌트가 어떻게 서로 데이터를 주고받는지, props를 활용해서 단계별로 학습해보려고 합니다.
구성 : App.js(루트 컴포넌트), ToDoList.js(전체 리스트 컴포넌트), ToDoItem.js(개별 항목 컴포넌트)
ToDoItem.js -- 개별 항목 컴포넌트
import React from "react";
// ToDo 항목 렌더링 컴포넌트
function ToDoItem({todo, onDelete, onLike, onToggle}) {
return (
<li style={{margin: '10px 0'}}>
<input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} />
<span style={{textDecoration: todo.completed ? 'line-through' : 'none', marginLeft: '10px', marginRight: '10px'}}>
{todo.text}
</span>
<span>좋아요 수 : {todo.likes}</span>{' '}
<button onClick={() => onDelete(todo.id)}>삭제</button>
<button onClick={() => onLike(todo.id)} disabled={todo.liked}>좋아요</button>
</li>
);
}
export default ToDoItem;
ToDoList.js -- 목록 및 상태 관리 컴포넌트
import React, {useState} from 'react';
import ToDoItem from './ToDoItem'; // 항목 컴포넌트 불러오기
function ToDoList() {
const [input, setInput] = useState(''); // 입력값 저장 상태
const [todos, setTodos] = useState([]); // 항목들을 저장하는 배열 상태
// 항목 추가
const addTodo = () => {
if(input.trim() === '') return; //빈 입력값 무시
const newTodo = {
id: Date.now(), // 고유 ID 생성
text: input, // 사용자가 입력한 텍스트
likes: 0, // 초기 좋아요 수 = 0
liked: false, // 좋아요 누름 여부
completed: false, // 완료 여부 체크
};
setTodos([...todos, newTodo]); // 기존 리스트에 항목 추가
setInput(''); // 입력창 비우기
};
// 항목 삭제
const deleteTodo = (id) => {
// 선택 항목 제외 리스트 재구성
const isConfitmed = window.confirm('정말 삭제하시겠습니까?');
if(isConfitmed) {
setTodos(todos.filter((todo) => todo.id !== id));
}
};
// 좋아요 증가
const likeTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id && !todo.liked ?
{... todo, likes: todo.likes + 1, liked: true} : todo
)
);
};
// 체크박스
const toggleCompleted =(id) => {
setTodos(
todos.map((todo) => todo.id === id ?
{...todo, completed: !todo.completed}: todo
)
);
};
// enter 키 입력 처리
const handleKeyDown = (e) => {
// enter키 입력 시 addTodo 실행
if(e.key === 'Enter') addTodo();
};
return (
<div>
<h2>To-Do 리스트</h2>
{/* 입력창 : 입력값 + 키 입력 이벤트 연결 */}
<input type="text" value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="할 일을 입력하세요." />
{/* 추가 버튼으로 addTodo 실행 */}
<button onClick={addTodo}>추가</button>
{/* To-Do 항목 출력 */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((todo) => (
<ToDoItem
key={todo.id} todo={todo} onDelete={deleteTodo}
onLike={likeTodo} onToggle={toggleCompleted}
/>
))}
</ul>
</div>
);
}
export default ToDoList;
App.js
import logo from './logo.svg';
import './App.css';
import ToDoList from './components/ToDoList';
function App() {
return (
<div className="App">
<h1>React : To-Do 리스트</h1>
<ToDoList />
</div>
);
}
export default App;

로컬스토리지 저장 및 불러오기
ToDoList.js 수정
import React, {useState, useEffect} from 'react';
import ToDoItem from './ToDoItem'; // 항목 컴포넌트 불러오기
function ToDoList() {
const [input, setInput] = useState(''); // 입력값 저장 상태
const [todos, setTodos] = useState(() => {
const stored = localStorage.getItem('todos');
return stored ? JSON.parse(stored) : [];
});
// 항목 추가
const addTodo = () => {
if(input.trim() === '') return; //빈 입력값 무시
const newTodo = {
id: Date.now(), // 고유 ID 생성
text: input, // 사용자가 입력한 텍스트
likes: 0, // 초기 좋아요 수 = 0
liked: false, // 좋아요 누름 여부
completed: false, // 완료 여부 체크
};
setTodos([...todos, newTodo]); // 기존 리스트에 항목 추가
setInput(''); // 입력창 비우기
};
// 항목 삭제
const deleteTodo = (id) => {
// 선택 항목 제외 리스트 재구성
const isConfitmed = window.confirm('정말 삭제하시겠습니까?');
if(isConfitmed) {
setTodos(todos.filter((todo) => todo.id !== id));
}
};
// 좋아요 증가
const likeTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id && !todo.liked ?
{... todo, likes: todo.likes + 1, liked: true} : todo
)
);
};
// 체크박스
const toggleCompleted =(id) => {
setTodos(
todos.map((todo) => todo.id === id ?
{...todo, completed: !todo.completed}: todo
)
);
};
// enter 키 입력 처리
const handleKeyDown = (e) => {
// enter키 입력 시 addTodo 실행
if(e.key === 'Enter') addTodo();
};
// todos. 상태가 변경될 때마다 localStorage에 저장
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
return (
<div>
<h2>To-Do 리스트</h2>
{/* 입력창 : 입력값 + 키 입력 이벤트 연결 */}
<input type="text" value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="할 일을 입력하세요." />
{/* 추가 버튼으로 addTodo 실행 */}
<button onClick={addTodo}>추가</button>
{/* To-Do 항목 출력 */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((todo) => (
<ToDoItem
key={todo.id} todo={todo} onDelete={deleteTodo}
onLike={likeTodo} onToggle={toggleCompleted}
/>
))}
</ul>
</div>
);
}
export default ToDoList;
실행 후 결과 확인



다음으로 useRef를 이용해서 입력창에 포커스 자동 설정하기
새 할일을 추가한 뒤 입력창에 자동으로 커서가 돌아올 수 있도록.
처음 렌더링될 때도 마찬가지로 입력창에 커서가 있도록.
import React, {useState, useEffect, useRef} from 'react';
import ToDoItem from './ToDoItem'; // 항목 컴포넌트 불러오기
function ToDoList() {
const [input, setInput] = useState(''); // 입력값 저장 상태
const [todos, setTodos] = useState(() => {
const stored = localStorage.getItem('todos');
return stored ? JSON.parse(stored) : [];
});
const inputRef = useRef(null); // 입력창 참조를 위한 ref 객체 생성
// 항목 추가
const addTodo = () => {
if(input.trim() === '') return; //빈 입력값 무시
const newTodo = {
id: Date.now(), // 고유 ID 생성
text: input, // 사용자가 입력한 텍스트
likes: 0, // 초기 좋아요 수 = 0
liked: false, // 좋아요 누름 여부
completed: false, // 완료 여부 체크
};
setTodos([...todos, newTodo]); // 기존 리스트에 항목 추가
setInput(''); // 입력창 비우기
inputRef.current.focus(); // 입력창 포커스
};
// 항목 삭제
const deleteTodo = (id) => {
// 선택 항목 제외 리스트 재구성
const isConfitmed = window.confirm('정말 삭제하시겠습니까?');
if(isConfitmed) {
setTodos(todos.filter((todo) => todo.id !== id));
}
};
// 좋아요 증가
const likeTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id && !todo.liked ?
{... todo, likes: todo.likes + 1, liked: true} : todo
)
);
};
// 체크박스
const toggleCompleted =(id) => {
setTodos(
todos.map((todo) => todo.id === id ?
{...todo, completed: !todo.completed}: todo
)
);
};
// enter 키 입력 처리
const handleKeyDown = (e) => {
// enter키 입력 시 addTodo 실행
if(e.key === 'Enter') addTodo();
};
// todos. 상태가 변경될 때마다 localStorage에 저장
useEffect(() => {
inputRef.current?.focus();
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
return (
<div>
<h2>To-Do 리스트</h2>
{/* 입력창 : 입력값 + 키 입력 이벤트 연결 */}
<input type="text" value={input} ref={inputRef}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="할 일을 입력하세요." />
{/* 추가 버튼으로 addTodo 실행 */}
<button onClick={addTodo}>추가</button>
{/* To-Do 항목 출력 */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((todo) => (
<ToDoItem
key={todo.id} todo={todo} onDelete={deleteTodo}
onLike={likeTodo} onToggle={toggleCompleted}
/>
))}
</ul>
</div>
);
}
export default ToDoList;


상태 리팩토링 : useReduce로 여러 상태를 관리하는 구조 변경하기.
- 여러 상태가 하나의 관련 로직으로 묶일 때(todos 배열에 추가, 삭제, 수정, 토글 등)
- 상태 업데이트 로직을 switch 문으로 구분하고 싶을 때
- 컴포넌트 내부 로직이 복잡해질수록 useReducer가 더 구조화된 코드 작성에 도움
리듀서 함수 만들기
export const initialState = {
todos: [],
};
export function todoReducer(state, action) {
switch(action.type) {
case 'INIT': return {todos:action.payload};
case 'ADD': return {todos: [...state.todos, action.payload],};
case 'DELETE': return {todos: state.todos.filter((todo) =>
todo.id !== action.payload),
};
case 'LIKE': return {todos: state.todos.map((todo) =>
todo.id === action.payload && !todo.liked ?
{... todo, likes: todo.likes + 1, liked: true} : todo
),
};
case 'TOGGLE': return{todos: state.todos.map((todo) =>
todo.id === action.payload ? {...todo, completed: !todo.completed} : todo
),
};
default: return state;
}
}
ToDoList.js 수정
import React, {useState, useEffect, useRef, useReducer} from 'react';
import ToDoItem from './ToDoItem'; // 항목 컴포넌트 불러오기
import { todoReducer, initialState} from './reducers/todoReducer';
function ToDoList() {
const [input, setInput] = useState(''); // 입력값 저장 상태
const inputRef = useRef(null); // 입력창 참조를 위한 ref 객체 생성
const [state, dispatch] = useReducer(todoReducer, initialState,
(init) => {
const stored = localStorage.getItem('todos');
return stored ? {todos: JSON.parse(stored)} : init;
}
);
// 항목 추가
const addTodo = () => {
if(input.trim() === '') return; //빈 입력값 무시
const newTodo = {
id: Date.now(), // 고유 ID 생성
text: input, // 사용자가 입력한 텍스트
likes: 0, // 초기 좋아요 수 = 0
liked: false, // 좋아요 누름 여부
completed: false, // 완료 여부 체크
};
dispatch({type: 'ADD', payload: newTodo});
setInput(''); // 입력창 비우기
inputRef.current?.focus(); // 입력창 포커스
};
// 항목 삭제
const deleteTodo = (id) => {
// 선택 항목 제외 리스트 재구성
const confirm = window.confirm('정말 삭제하시겠습니까?');
if(confirm) dispatch({type: 'DELETE', payload: id});
};
// 좋아요 증가
const likeTodo = (id) => {
dispatch({type: 'LIKE', payload: id});
};
// 체크박스
const toggleCompleted =(id) => {
dispatch({type: 'TOGGLE', payload: id});
};
// enter 키 입력 처리
const handleKeyDown = (e) => {
// enter키 입력 시 addTodo 실행
if(e.key === 'Enter') addTodo();
};
// 로컬 저장소 초기 로드
/*
useEffect(() => {
const stored = localStorage.getItem('todos');
console.log('localStorage load:', stored);
if (stored) {
dispatch({ type: 'INIT', payload: JSON.parse(stored) });
}
}, []);
*/
useEffect(() => {
console.log('Saving todos:', state.todos);
localStorage.setItem('todos', JSON.stringify(state.todos));
}, [state.todos]);
return (
<div>
<h2>To-Do 리스트</h2>
{/* 입력창 : 입력값 + 키 입력 이벤트 연결 */}
<input type="text" value={input} ref={inputRef}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="할 일을 입력하세요." />
{/* 추가 버튼으로 addTodo 실행 */}
<button onClick={addTodo}>추가</button>
{/* To-Do 항목 출력 */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{state.todos.map((todo) => (
<ToDoItem
key={todo.id} todo={todo} onDelete={deleteTodo}
onLike={likeTodo} onToggle={toggleCompleted}
/>
))}
</ul>
</div>
);
}
export default ToDoList;


필터 + 검색 기능을 리듀서와 함께 통합하기
리듀서 컴포넌트 상태 구조 확장

액션 추가 및 수정

...state로 나머지 상태 보존 > searchTerm과 filterType이 계속 유지되고 filteredTodos 로직도 안정적으로 작동.
ToDoList 컴포넌트 추가 및 수정


결과 확인




주요 기술과 구성 요소별 역할
1. 컴포넌트 분리
- ToDoList : 전체 앱의 상태 관리 및 UI 렌더링을 담당하는 메인 컴포넌트
- ToDoItem : 개별 할 일 항목 렌더링 및 사용자 동작(삭제, 좋아요, 완료 체크 등)을 수행하는 컴포넌트
컴포넌트를 분리하는 이유는 관심사의 분리와 유지보수 용이성, 재사용성을 높이기 위해서.
부모 컴포넌트에서 props로 데이터를 전달받아 자식 컴포넌트가 동작하는데 주로 활용.
2. useReducer

- 상태(할 일 목록, 필터 상태, 검색어 등)를 체계적으로 관리
- 상태 변경 로직을 컴포넌트 외부의 todoReducer 함수에서 일괄 처리
상태 변경이 많고, 서로 관련된 상태가 존재할 경우 useState보다 useRedcer가 직관적이고 유지 관리에 유리하다.

다양한 액션 타입에 따라 상태를 업데이트 하는데 주로 활용.
3. 로컬 스토리지 연동

- 할 일 목록을 브라우저에 저장하여 새로고침 또는 앱 재실행 시에도 유지되도록 구현
사용자가 앱을 닫아도 데이터가 보존하도록 하기 위해서.
useEffect 훅으로 todos 변경 감지 후 자동 저장, 초기 로딩시에 localStorage에서 불러온다.
4. useRef 활용(입력창 포커스)

- 새로운 할 일을 추가한 후 입력창에 자동으로 커서가 위치되도록 구현
UX향상, 사용자가 연속적으로 할 일을 입력하기 쉽게 하기위해 사용.
DOM에 직접 접근할 필요가 있을 때 useRef를 활용한다.
5. 검색 및 필터 기능

- 검색어에 해당하는 항목만 필터링
- 전체 / 완료 / 미완료 항목을 버튼으로 필터링
사용자의 편의성과 데이터 탐색성 향상을 위해서 사용,
filterType, searchTerm을 상태로 관리하고 조건에 따라 필터링된 리스트를 생성.
지금까지의 과정을 통해 React의 상태 관리, 컴포넌트 구조화, 사용자 입력 처리, 데이터 영속성 유지까지의 흐름을 직접 구현했고, 작은 To-Do 이지만 실제 서비스에 흔히 쓰이는 기능들 위주로 학습했습니다.
'React' 카테고리의 다른 글
| [React] To-Do 리스트 만들기 (1) | 2025.07.20 |
|---|---|
| [React] 조건부 렌더링(로그인 + 회원가입) (0) | 2025.07.14 |
| [React] 배열을 이용한 반복 출력 (1) | 2025.07.14 |
| [React] 사용자 입력 처리(input + state + onChange) (2) | 2025.07.14 |
| [React] Props와 State, 버튼 클릭 이벤트 만들기 (2) | 2025.07.12 |