
오늘 수업에서는 React 개념들을 총동원한 Todo 앱을 만들어봤다. useState보다 복잡한 상태 관리를 위해 useReducer + Immer를 쓰고, React Router로 다중 페이지도 구성했다.
const ADD_TODO = "ADD_TODO";
const TOGGLE_TODO = "TOGGLE_TODO";
const DELETE_TODO = "DELETE_TODO";
dispatch를 쓸 때 'ADD_TODO'처럼 문자열을 직접 넣으면 오타가 나도 에러가 안 뜨고 그냥 조용히 동작을 안 한다.
상수로 빼두면 오타가 났을 때 'ADD_TODO is not defined'처럼 에러가 바로 나서 어디가 잘못됐는지 바로 알 수 있다.
reducer 쪽이랑 dispatch 호출하는 쪽이 같은 상수를 쓰니까 일관성도 생긴다.
import { produce } from "immer";
const todoReducer = (state, action) => {
return produce(state, (draft) => {
switch (action.type) {
case ADD_TODO:
draft.todos.push({
id: Date.now(),
text: action.payload.text,
completed: false,
priority: action.payload.priority || "medium",
category: action.payload.category || "기타",
});
break;
case TOGGLE_TODO: {
const todo = draft.todos.find((t) => t.id == action.payload);
if (todo) todo.completed = !todo.completed;
break;
}
case DELETE_TODO:
draft.todos = draft.todos.filter((t) => t.id !== action.payload);
break;
}
});
};
useReducer는 상태를 바꾸는 로직을 한 군데 모아두는 방식이다. 상태가 복잡해질수록 useState보다 훨씬 관리하기 편하다.
여기에 Immer를 같이 쓰면 draft를 직접 수정하듯이 코드를 쓸 수 있다.
실제로는 원본 state를 건드리는 게 아니라 Immer가 알아서 새 상태를 만들어주는 거라 불변성은 지켜진다.
| 기존 방식 | Immer | |
|---|---|---|
| 배열 추가 | [...state.todos, newTodo] | draft.todos.push(newTodo) |
| 중첩 수정 | spread를 여러 번 중첩 | draft.a.b.c = value |
const addTodo = useCallback((todo) => {
dispatch({ type: ADD_TODO, payload: todo });
}, []);
const toggleTodo = useCallback((id) => {
dispatch({ type: TOGGLE_TODO, payload: id });
}, []);
useCallback은 함수를 메모이제이션해주는 훅이다.
dispatch는 useReducer가 항상 같은 참조를 보장해주기 때문에 의존성 배열을 []로 써도 된다.
덕분에 이 함수들은 컴포넌트가 처음 렌더링될 때 딱 한 번만 만들어지고,
자식 컴포넌트에 넘겨도 참조가 바뀌지 않아서 불필요한 리렌더링을 줄일 수 있다.
const filteredTodos = useMemo(() => {
return state.todos.filter((todo) => {
const matchFilter =
state.filter === "all" ||
(state.filter === "active" && !todo.completed) ||
(state.filter === "completed" && todo.completed);
const matchesSearch = todo.text
.toLowerCase()
.includes(state.searchQuery.toLowerCase());
return matchFilter && matchesSearch;
});
}, [state.todos, state.filter, state.searchQuery]);
필터링을 Todos 페이지 안에서 하면 관련 없는 이유로 리렌더링이 일어날 때도 매번 다시 계산한다.
App에서 useMemo로 미리 계산해두면 todos, filter, searchQuery 이 세 값 중 하나라도 바뀔 때만 다시 계산하고, 그 외엔 이전 결과를 그대로 쓴다.
const todoProps = {
todos: state.todos,
filteredTodos,
filter: state.filter,
searchQuery: state.searchQuery,
addTodo,
toggleTodo,
deleteTodo,
updateTodo,
setFilter,
setSearchQuery,
clearCompleted,
};
// 사용할 때
<Todos {...todoProps} />
자식한테 넘길 props가 많아지면 하나씩 쓰는 게 너무 길어진다.
객체로 묶어서 스프레드로 한 번에 넘기면 코드가 훨씬 깔끔해진다.
물론 props가 몇 개 안 된다면 그냥 직접 넘기는 게 낫다.
// App.jsx
<BrowserRouter>
<Navigation />
<Routes>
<Route path="/" element={<Home todos={state.todos} />} />
<Route path="/todos" element={<Todos {...todoProps} />} />
<Route path="/statistics" element={<Statistics todos={state.todos} />} />
<Route path="/posts" element={<PostList posts={POSTS} />} />
<Route path="/posts/:id" element={<PostDetail posts={POSTS} />} />
</Routes>
</BrowserRouter>
BrowserRouter로 감싸면 URL이 바뀔 때 페이지 새로고침 없이 해당 컴포넌트로 전환된다.
보통 main.jsx에서 쓰는데 App.jsx에서 써도 된다.
/posts/:id처럼 :id 부분을 동적으로 처리하면 게시글마다 다른 URL을 가질 수 있다.
해당 페이지에서는 useParams()로 URL에 있는 값을 꺼내 쓴다.
// PostDetail.jsx
import { useParams } from "react-router-dom";
const { id } = useParams(); // URL의 :id 값
const post = posts.find((p) => p.id === parseInt(id)); // URL 파라미터는 문자열이라 parseInt 필요
/ -> Home (요약 통계 + 이동 버튼)
/todos -> Todos (할 일 CRUD + 필터/검색)
/statistics -> Statistics (우선순위/카테고리별 통계)
/posts -> PostList (게시글 목록)
/posts/:id -> PostDetail (동적 라우팅으로 상세 보기)
src/
├── components/
│ ├── Navigation.jsx # 상단 공통 네비게이션
│ ├── TodoForm.jsx # 할 일 입력 폼
│ ├── TodoList.jsx # 할 일 목록 렌더링
│ ├── TodoItem.jsx # 개별 할 일 항목 (수정/삭제/토글)
│ └── FilterBar.jsx # 필터 버튼 + 검색창
├── pages/
│ ├── Home.jsx
│ ├── Todos.jsx
│ ├── Statistics.jsx
│ ├── PostList.jsx
│ └── PostDetail.jsx
└── App.jsx
App.jsx — 상태 관리 허브
todos, filter, searchQuery 전체 상태를 useReducer 하나로 관리한다.
useCallback으로 만든 함수들과 useMemo로 계산한 filteredTodos를 todoProps로 묶어서 Todos 페이지에 넘겨준다.
const [state, dispatch] = useReducer(todoReducer, initialState);
const todoProps = {
todos: state.todos,
filteredTodos,
filter: state.filter,
// ...
};
TodoForm.jsx — 입력 폼
export const TodoForm = memo(({ addTodo }) => {
const [text, setText] = useState("");
const [priority, setPriority] = useState("medium");
const [category, setCategory] = useState("기타");
const handleSubmit = useCallback(
(e) => {
e.preventDefault();
if (text.trim()) {
addTodo({ text: text.trim(), priority, category });
setText("");
setPriority("medium");
setCategory("기타");
}
},
[text, priority, category, addTodo],
);
// ...
});
React.memo로 감싸서 addTodo 참조가 바뀌지 않는 한 리렌더링되지 않게 했다.
입력값은 굳이 App까지 올릴 필요가 없어서 로컬 useState로 관리한다.
TodoItem.jsx — 수정 모드 전환
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const handleSave = () => {
if (editText.trim()) {
updateTodo(todo.id, { text: editText });
setIsEditing(false);
}
};
const handleCancel = () => {
setEditText(todo.text); // 원본으로 되돌리기
setIsEditing(false);
};
isEditing으로 보기 모드와 수정 모드를 전환한다.
취소를 누르면 todo.text(원본 텍스트)로 다시 세팅해서 수정 전 상태로 돌아간다.
TodoList.jsx — 이중 메모이제이션
export const TodoList = memo(({ filteredTodos, toggleTodo, deleteTodo, updateTodo }) => {
const memoizedTodos = useMemo(() => {
return filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} ... />
));
}, [filteredTodos, toggleTodo, deleteTodo, updateTodo]);
// ...
});
memo : TodoList 자체가 리렌더링될 필요가 있는지 확인useMemo : TodoItem 배열을 새로 만들 필요가 있는지 확인Navigation.jsx — displayName
export const Navigation = memo(() => {
// ...
});
Navigation.displayName = "Navigation";
memo()로 감싸면 React DevTools에서 컴포넌트 이름이 "memo(Anonymous)"로 뜬다.
displayName을 지정해두면 DevTools에서 제대로 된 이름으로 보여서 디버깅할 때 편하다.
| 개념 | 핵심 요약 |
|---|---|
| Action Type 상수 | 오타를 즉시 에러로 잡기 위한 문자열 상수 |
| useReducer + Immer | 복잡한 상태 관리, draft를 직접 수정하듯 쓸 수 있음 |
| useCallback | 함수를 메모이제이션해서 불필요한 리렌더링 방지 |
| useMemo | 관련 상태가 바뀔 때만 재계산, 그 외엔 캐싱된 값 사용 |
| todoProps 패턴 | 여러 props를 객체로 묶어 스프레드로 한 번에 전달 |
| BrowserRouter | 페이지 새로고침 없이 라우팅, /posts/:id로 동적 라우팅 |