오늘 수업에서는 React 개념들을 총동원한 Todo 앱을 만들어봤다. useState보다 복잡한 상태 관리를 위해 useReducer + Immer를 쓰고, React Router로 다중 페이지도 구성했다.


목차

  • Action Type 상수
  • useReducer + Immer
  • useCallback
  • useMemo
  • props를 객체로 묶어서 스프레드로 전달하는 방식
  • BrowserRouter & 동적 라우팅
  • Todo 앱 만들기

Action Type 상수

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 호출하는 쪽이 같은 상수를 쓰니까 일관성도 생긴다.


useReducer + Immer

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

useCallback

const addTodo = useCallback((todo) => {
    dispatch({ type: ADD_TODO, payload: todo });
}, []);

const toggleTodo = useCallback((id) => {
    dispatch({ type: TOGGLE_TODO, payload: id });
}, []);

useCallback은 함수를 메모이제이션해주는 훅이다.
dispatch는 useReducer가 항상 같은 참조를 보장해주기 때문에 의존성 배열을 []로 써도 된다.
덕분에 이 함수들은 컴포넌트가 처음 렌더링될 때 딱 한 번만 만들어지고,
자식 컴포넌트에 넘겨도 참조가 바뀌지 않아서 불필요한 리렌더링을 줄일 수 있다.


useMemo

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 이 세 값 중 하나라도 바뀔 때만 다시 계산하고, 그 외엔 이전 결과를 그대로 쓴다.


props를 객체로 묶어서 스프레드로 전달하는 방식

const todoProps = {
    todos: state.todos,
    filteredTodos,
    filter: state.filter,
    searchQuery: state.searchQuery,
    addTodo,
    toggleTodo,
    deleteTodo,
    updateTodo,
    setFilter,
    setSearchQuery,
    clearCompleted,
};

// 사용할 때
<Todos {...todoProps} />

자식한테 넘길 props가 많아지면 하나씩 쓰는 게 너무 길어진다.
객체로 묶어서 스프레드로 한 번에 넘기면 코드가 훨씬 깔끔해진다.
물론 props가 몇 개 안 된다면 그냥 직접 넘기는 게 낫다.


BrowserRouter & 동적 라우팅

// 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 필요

Todo 앱 만들기

전체 라우팅 구조

/             -> 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로 동적 라우팅

0개의 댓글