Front/React

React를 이용한 todo list app 만들기

oodada 2024. 3. 14. 10:33

할 일 관리 앱 만들기

0. 프로젝트 생성하기

todo-Todo 디렉토리를 생성 후 프로젝트를 생성합니다.

npx create-next-app@latest ./

https://ko.react.dev/learn/start-a-new-react-project

1. 컴포넌트 구조 설계하기

2. 컴포넌트 제작

- Header 컴포넌트 만들기

// src/components/TodoHd.jsx
import React from 'react';

const TodoHd = () => {
  return (
    <div>
      <h1>📝 할 일 관리 앱</h1>
      <p>2024.03.11 오늘의 할 일을 적어보세요.</p>
    </div>
  );
};

export default TodoHd;

- TodoEditor 컴포넌트 만들기

// src/components/TodoEditor.jsx
import React from 'react';

const TodoEditor = () => {
  return (
    <div>
      <h2>새로운 Todo 작성하기 ✏ </h2>
      <div>
        <input placeholder="할 일을 추가로 입력해주세요." />
        <button>추가</button>
      </div>
    </div>
  );
};

export default TodoEditor;

- TodoList 컴포넌트 만들기

TodoItem 컴포넌트를 TodoList 컴포넌트에서 렌더링을 연습합니다.

// src/components/TodoList.jsx
import React from 'react';

const TodoList = () => {
  return (
    <div>
      <h2>할 일 목록 📃</h2>
      <input placeholder="검색어를 입력하세요" />
      <ul>
        <TodoItem isDone={true} task="고양이 밥주기" createdDate="2024.03.11" />
        <TodoItem isDone={false} task="감자 캐기" createdDate="2024.03.11" />
        <TodoItem isDone={false} task="고양이 놀아주기" createdDate="2024.03.11" />
      </ul>
    </div>
  );
};

export default TodoList;

- TodoItem 컴포넌트 만들기

// src/components/TodoItem.jsx
const TodoItem = () => {
  return (
    <div>
      <li>
        <input type="checkbox" />
        <span>고양이 밥주기</span>
        <span>2024.03.11</span>
        <button>삭제</button>
      </li>
    </div>
  );
};

export default TodoItem;

- 컴포넌트 구조 설계하기

// src/components/Todo.jsx
import TodoHd from '@/components/TodoHd';
import TodoEditor from '@/components/TodoEditor';
import TodoList from '@/components/TodoList';

const Todo = () => {
  return (
    <div className="flex flex-col gap-5">
      <TodoHd />
      <TodoEditor />
      <TodoList />
    </div>
  );
}
export default Todo;

3. 기능 구현

  • Todo 컴포넌트 : 할 일 데이터 관리
  • Header 컴포넌트 : 오늘 날짜 표시
  • TodoEditor 컴포넌트 : 할 일 추가
  • TodoList 컴포넌트 : 검색에 따라 필터링된 할 일 목록 표시
  • TodoItem 컴포넌트 : 할 일 목록의 수정 및 삭제

데이터를 다루는 4가지 기능 (CRUD)을 구현합니다.
추가(Create), 조회(Read), 수정(Update), 삭제(Delete)

- 오늘 날짜 표시하기

날짜를 표시하는 라이브러리로 date-fns를 사용합니다.

date-fns 공홈 /
date-fns 설치

npm install date-fns
yarn add date-fns

data-fns를 사용하여 오늘 날짜를 표시하는 코드를 작성합니다.
문법 : format(new Date(), 'yyyy.MM.dd')

// src/components/TodoHd.jsx
import React from 'react';
import { format } from 'date-fns';

const TodoHd = () => {
  return (
    <div>
      <h1>📝 할 일 관리 앱</h1>
      <strong>{format(new Date(), 'yyyy.MM.dd')}</strong>
      <p>오늘의 할 일을 적어보세요.</p>
    </div>
  );
};

export default TodoHd;

- 기초 데이터 및 타입 설정하기

// src/data/todoData.js
export const mockTodoData = [
    {
        id: 1,
        isDone: false,
        task: '고양이 밥주기',
        createdDate: new Date().getTime(), // 현재 시간
    },
    {
        id: 2,
        isDone: false,
        task: '감자 캐기',
        createdDate: new Date().getTime(),
    },
    {
        id: 3,
        isDone: false,
        task: '고양이 놀아주기',
        createdDate: new Date().getTime(),
    },
];

// src/data/index.js
export * from './todoData';

Read: 할 일 목록 렌더링하기

- 로직

  1. Todo 컴포넌트 : 할 일 데이터 관리
  2. TodoList 컴포넌트 : 할 일 목록 렌더링

컴포넌트의 재사용성을 높이기 위해 데이터를 Todo 컴포넌트에서 관리하고, TodoList 컴포넌트에서 할 일 목록을 렌더링합니다.

- 할 일 목록 렌더링하기

// src/components/Todo.jsx
import { useState } from "react";
import TodoHd from "@/components/TodoHd";
import TodoEditor from "@/components/TodoEditor";
import TodoList from "@/components/TodoList";
import { mockTodoData } from "@/data";

const Todo = () => {
  const [todos, setTodos] = useState(mockTodoData);

  return (
    <div className="flex flex-col gap-5">
      <TodoHd />
      <TodoEditor />
      <TodoList todos={todos} />
    </div>
  );
};

export default Todo;
// src/components/TodoList.jsx
import React from 'react';
import TodoItem from './TodoItem';

const TodoList = ({ todos }) => {
    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input placeholder="검색어를 입력하세요"/>
            <ul>
                {todos.map((todo) => (
                    <TodoItem key={todo.id} {...todo} />
                ))}

            </ul>
        </div>
    );
};

export default TodoList;
// src/components/TodoItem.jsx
import React from 'react';

const TodoItem = ({ id, isDone, task, createdDate }) => {

  console.log(`TodoItem ${task}: isDone = ${isDone}`); // 추가

  return (
    <div>
      <li key={id}>
        <input
          type="checkbox"
          checked={isDone}
          onChange={() => {}} // 나중에 구현
        />
        <span>{task}</span>
        <span>{new Date(createdDate).toLocaleDateString()}</span>
        <button>삭제</button>
      </li>
    </div>
  );
};
export default TodoItem;

Create: 할 일 추가하기

- 로직

  1. 사용자 : 할 일 입력, 추가 버튼 클릭
  2. TodoEditor : 입력한 할 일을 받아와서 할 일 목록을 관리하는 상태인 todo에 추가
  3. TodoList : 추가된 할 일 목록을 전달하여 화면에 표시
  4. Todo : 추가된 할 일 목록을 브라우저의 로컬 스토리지에 저장
  5. TodoEditor
    • 빈 입력 방지
    • input 초기화 및 포커스
    • Enter 키로 할 일 추가
    • 소문자 검색

- 할 일 추가하기

// src/components/Todo.jsx
import { useState } from "react";
import TodoHd from "@/components/TodoHd";
import TodoEditor from "@/components/TodoEditor";
import TodoList from "@/components/TodoList";
import { mockTodoData } from "@/data";
import type { TodoType } from "@/types/todo";

const Todo = () => {
  const [todos, setTodos] = useState(mockTodoData);

  // 할 일을 추가하는 함수를 만듭니다.
  const addTodo = (task) => {
    const newTodo = {
      id: todos.length + 1, // ID는 기존 할 일 개수 + 1
      task,
      isDone: false, // 완료 상태 기본값은 false
      createdDate: new Date().getTime(), // 생성 시간 기록
    };
    setTodos([newTodo, ...todos]); // 새로운 할 일을 기존 목록 앞에 추가
  };

  return (
    <div className="flex flex-col gap-5">
      <TodoHd />
      <TodoEditor addTodo={addTodo} />
      <TodoList todos={todos} />
    </div>
  );
};

export default Todo;

- 할 일 추가 함수 호출하기

// src/components/TodoEditor.jsx
import React, { useRef, useState } from 'react';

const TodoEditor = ({ addTodo }) => {
    // 할 일을 입력하는 input 상태를 관리합니다.
  const [task, setTask] = useState('');

  // input에 할 일이 입력되면 입력한 값을 task 상태에 업데이트하는 함수를 만듭니다.
  const onChangeTask = e => setTask(e.target.value);

  // 추가 버튼을 클릭하면 할 일을 추가하는 함수를 호출합니다.
  const onSubmit = () => {
    // 할 일을 추가하는 함수를 호출합니다.
    addTodo(task);
  };

    return (
        <div>
            <h2>새로운 Todo 작성하기 ✏ </h2>
            <form>
                {/* task 상태값을 value로 설정 */}
                {/* onChange 이벤트에 onChangeTask 함수를 연결 */}
                <input 
                  type="text" 
                  value={task} 
                  ref={inputRef} 
                  onChange={onChangeTask} 
                  placeholder="할 일을 입력하세요." 
                  className='p-3 text-black' 
                />

                {/* 추가 버튼을 클릭하면 onSubmit 함수를 호출 */}
                <button 
                  type='submit'
                  onClick={onSubmit} 
                  disabled={!task} 
                  className={
                    classNames('p-3', task ? 'bg-blue-300' : 'bg-gray-300')
                  }>할 일 추가</button>
            <form>
        </div>
    );
};

export default TodoEditor;

- input 초기화 후 포커스 맞추기

// src/components/TodoEditor.jsx
import React, { useRef, useState } from 'react';

const TodoEditor = ({ addTodo }) => {
    const [task, setTask] = useState('');
    // inputRef 변수가 useRef()로 생성됩니다.
    // 연결된 input 요소에 포커스를 맞추기 위해 사용합니다.
    const inputRef = useRef();

    const onChangeTask = e => setTask(e.target.value);

    const onSubmit = () => {
      if (!task) return
      addTodo(task);

      // 할 일을 추가한 후 input을 초기화합니다.
      setTask('')
      // input에 포커스를 맞춥니다.
      inputRef.current.focus();
    };

    return (
        <div>
            <h2>새로운 Todo 작성하기 ✏ </h2>
            <div>
                {/* inputRef 변수를 사용하여 input 요소에 포커스를 맞춥니다. */}
                <input
                ref={inputRef}
                value={task}
                onChange={onChangeTask}
                placeholder="할 일을 추가로 입력해주세요."
                />
                <button onClick={onSubmit}>추가</button>
            </div>
        </div>
    );
};

export default TodoEditor;

- Enter 키로 할 일 추가하기

// src/components/TodoEditor.jsx
import React, { useRef, useState } from 'react';

const TodoEditor = ({ addTodo }) => {
    (...)

    // input에서 Enter 키를 누르면 할 일을 추가하는 함수를 호출합니다.
    const onKeyDown = (e) => {
        if (e.key === 'Enter') {
            onSubmit()
        }
    }

    return (
        <div>
            <h2>새로운 Todo 작성하기 ✏ </h2>
            <div>
                {/* onKeyDown 이벤트에 onKeyDown 함수를 연결합니다. */}
                <input
                ref={inputRef}
                value={task}
                onChange={onChangeTask}
                onKeyDown={onKeyDown}
                placeholder="할 일을 추가로 입력해주세요."
                />
                <button onClick={onSubmit}>추가</button>
            </div>
        </div>
    );
};

export default TodoEditor;

Search: 할 일 검색하기

- 할 일 검색 & 소문자 검색

// src/components/TodoList.jsx
import React, { useState } from 'react';
import TodoItem from './TodoItem';

const TodoList = ({ todos }) => {

    // state를 이용하여 input에 입력된 검색어를 관리합니다.
    const [search, setSearch] = useState('');

    // input에 입력된 검색어를 상태로 관리합니다.
    const onChangeSearch = e => {
        setSearch(e.target.value);
    };

    // 검색어를 포함하는 할 일 목록을 저장합니다.
    const filteredTodo = () => {
        // 검색어가 포함된 할 일 목록을 반환합니다.
        // todo.filter() 함수는 todo 배열을 순회하면서 검색어가 포함된 할 일 목록을 반환합니다.
        // toLowerCase()를 이용하여 검색어와 할 일 목록의 task를 소문자로 변경합니다.
        return todos.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()));
    };

    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input
            value={search}
            onChange={onChangeSearch}
            placeholder="검색어를 입력하세요"/>
            <ul>
                {filteredTodo().map((todo) => (
                    <TodoItem key={todo.id} {...todo} />
                ))}

            </ul>
        </div>
    );
};

export default TodoList;

Update: 작업 완료

- 로직

  1. 사용자 : TodoItem 체크박스 틱(체크표시) 합니다.
  2. TodoItem : onUpate 함수를 호출하고 해당 체크박스의 id를 전달합니다.
  3. Todo : id에 해당하는 할 일의 isDone을 변경합니다.
  4. TodoList : 변경된 할 일 목록을 전달하여 화면에 표시합니다.
  5. TodoItem : 할 일 목록의 수정 및 삭제 버튼을 추가합니다.

- 완료 표시하기

// src/components/Todo.jsx
(...)

const Todo = () => {
  (...)
  // 완료 표시를 클릭시 호출되는 함수 onUpdate를 만들고, id에 해당하는 할 일의 isDone을 변경합니다.
  const onUpdate = (id) => {
    setTodos(
      // map() 함수를 이용하여 todo 배열을 순회하면서
      todos.map((todo) =>
        // id에 해당하는 할 일의 isDone을 변경합니다.
        // 특정 항목의 완료 상태를 토글(toggle)할 때 자주 사용
        // 현재 todo의 id가 매개변수로 받은 id와 일치하는지 확인
        // 스프레드 연산자(...)로 기존 todo 객체의 모든 속성을 복사
        // isDone 속성만 현재 값의 반대로 변경 (true → false 또는 false → true)
        // id가 일치하지 않으면 기존 todo 객체를 그대로 반환
        todo.id === id ? { ...todo, isDone: !todo.isDone } : todo 
      )
    );
  };

  return (
    <div className="flex flex-col gap-5">
      <TodoHd />
      <TodoEditor addTodo={addTodo} />
      {/* onUpdate 함수를 TodoList 컴포넌트에 전달합니다. */}
      <TodoList todos={todos} onUpdate={onUpdate} /> 
    </div>
  );
};

export default Todo;
// src/components/TodoList.jsx
(...)

const TodoList = ({ todos, onUpdate }) => {

    (...)

    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input
            value={search}
            onChange={onChangeSearch}
            placeholder="검색어를 입력하세요"/>
            <ul>
                {filteredTodo().map((todo) => (
                    {/* onUpdate 함수를 TodoItem 컴포넌트에 전달합니다. */}
                    <TodoItem key={todo.id} onUpdate={onUpdate} {...todo} />
                ))}

            </ul>
        </div>
    );
};

export default TodoList;
// src/components/TodoItem.jsx
import React from 'react';

// onUpdate 함수를 추가합니다.
const TodoItem = ({ id, isDone, task, createdDate, onUpdate }) => {
  console.log(`TodoItem ${task}: isDone = ${isDone}`); // 추가

  return (
    <div>
      <li key={id}>
        <input
          type="checkbox"
          checked={isDone}
          onChange={() => onUpdate(id)} // onUpdate 함수를 호출합니다.
        />
        <span className={`${isDone ? 'line-through text-gray-400' : 'no-underline text-black'}`}>
          {task}
        </span>
        <span>{new Date(createdDate).toLocaleDateString()}</span>
        <button>삭제</button>
      </li>
    </div>
  );
};
export default TodoItem;

Delete : 할 일 삭제하기

- 로직

  1. 사용자 : TodoItem 삭제 버튼 클릭
  2. TodoItem : onDelete 함수를 호출하고 해당 삭제 버튼의 id를 전달합니다.
  3. Todo : id에 해당하는 할 일을 삭제합니다.
  4. TodoList : 변경된 할 일 목록을 전달하여 화면에 표시합니다.

- 할 일 삭제하기

// src/components/Todo.jsx
(...)

const Todo = () => {
  (...)

  // 삭제 버튼을 클릭시 호출되는 함수 onDelete를 만들고, id에 해당하는 할 일을 삭제합니다.
  const onDelete = (id) => {
      // 해당 id 요소를 뺀 나머지 요소들만 반환합니다.
      setTodos(todos.filter((todo) => todo.id !== id));
  }

  return (
    <div className="flex flex-col gap-5">
      <TodoHd />
      <TodoEditor addTodo={addTodo} />
      {/* onDelete 함수를 TodoList 컴포넌트에 전달합니다. */}
      <TodoList todos={todos} onUpdate={onUpdate} onDelete={onDelete} />
    </div>
  );
};

export default Todo;
// src/components/TodoList.jsx
(...)

{/* onDelete 함수를 TodoItem 컴포넌트에 전달합니다. */}
const TodoList = ({ todos, onUpdate, onDelete }) => {
    (...)

    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input
            value={search}
            onChange={onChangeSearch}
            placeholder="검색어를 입력하세요"/>
            <ul>
                {filteredTodo().map((todo) => (
                    {/* onDelete 함수를 TodoItem 컴포넌트에 전달합니다. */}
                    <TodoItem key={todo.id} onUpdate={onUpdate} onDelete={onDelete} {...todo} />
                ))}

            </ul>
        </div>
    );
};

export default TodoList;
// src/components/TodoItem.jsx
import React from 'react';

{/* onDelete 함수를 추가합니다. */}
const TodoItem = ({ id, isDone, task, createdDate, onUpdate, onDelete }) => {
  console.log(`TodoItem ${task}: isDone = ${isDone}`); // 추가

  return (
    <div>
      <li key={id}>
        (...)

        {/* onDelete 함수를 호출합니다. */}
        <span>{new Date(createdDate).toLocaleDateString()}</span>
        <button onClick={() => onDelete(id)}>삭제</button>
      </li>
    </div>
  );
};
export default TodoItem;

4. useEffect를 활용한 확장

목표: 할 일을 로컬 스토리지에 저장하고, 초기 렌더링 시 저장된 데이터를 불러오도록 변경.

수정 내용:

  • useEffect로 로컬 스토리지에 todos를 저장.
  • 컴포넌트가 마운트될 때 로컬 스토리지에서 데이터를 불러와 초기 상태로 설정.
// src/components/Todo.jsx
import { useState, useEffect } from "react";

const Todo = () => {
  const [todos, setTodos] = useState([]);

  // 1. 초기 데이터 로드
  useEffect(() => {
      // localStorage에서 'todos' 키로 저장된 데이터를 가져옴
      // 데이터가 없으면 null을 반환
      // JSON.parse() 함수를 이용하여 문자열을 객체로 변환
      const savedTodos = JSON.parse(localStorage.getItem('todos')) || [];
      // 가져온 데이터로 상태 업데이트
      setTodos(savedTodos);
  }, []) // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행

  // 2. 데이터 자동 저장
  useEffect(() => {
      // todos 상태가 변경될 때마다 localStorage에 저장
      // JSON.stringify() 함수를 이용하여 객체를 문자열로 변환
      localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]) // todos가 변경될 때마다 실행

  // 기존 addTodo, onUpdate, onDelete 함수 유지
};
  1. localStorage.getItem('todos')
  • 로컬 스토리지에서 'todos' 키에 저장된 데이터를 가져옴
  • 데이터가 없으면 null을 반환
  1. JSON.parse()
  • 로컬 스토리지는 문자열로만 저장되므로, JSON 형태의 문자열을 JavaScript 객체로 변환
  • 예: "[{\"id\":1,\"task\":\"운동하기\"}]" → [{id:1, task:"운동하기"}]
  1. || []
  • 만약 로컬 스토리지에 데이터가 없으면(null이면) 빈 배열을 사용
// 로컬 스토리지에 데이터가 있는 경우
// localStorage: { todos: '[{"id":1,"task":"운동하기"}]' }
const savedTodos = JSON.parse(localStorage.getItem('todos')) || [];
// result: [{id:1, task:"운동하기"}]

// 로컬 스토리지에 데이터가 없는 경우
// localStorage: { }
const savedTodos = JSON.parse(localStorage.getItem('todos')) || [];
// result: []

- localStorage란?

localStorage는 웹 브라우저에서 제공하는 데이터 저장소로 데이터를 브라우저에 저장할 수 있습니다.

  • 브라우저를 닫아도 데이터가 유지됩니다.
  • 도메인 별로 데이터를 저장할 수 있습니다.
  • 데이터는 문자열 형태로만 저장할 수 있습니다.

- localStorage 사용법

// 데이터 저장
localStorage.setItem('key', 'value');

// 데이터 불러오기
localStorage.getItem('key');

// 데이터 삭제
localStorage.removeItem('key');

- 주의할 점

  • localStorage에는 문자열만 저장할 수 있습니다

    • 객체나 배열을 저장할 때는 JSON.stringify()로 문자열로 변환
    • 불러올 때는 JSON.parse()로 다시 객체로 변환
  • 용량 제한이 있습니다

    • 보통 5MB ~ 10MB 정도 (브라우저마다 다름)
  • 보안에 민감한 데이터는 저장하면 안됩니다

    • 비밀번호나 개인정보 등은 피해야 함

실제로 확인하는 방법

  1. 브라우저에서 F12 키를 눌러 개발자 도구 열기
  2. Application 탭 선택
  3. 왼쪽 메뉴에서 Local Storage 선택
  4. 저장된 데이터 확인 가능

이렇게 localStorage를 사용하면 새로고침을 하거나 브라우저를 닫았다가 다시 열어도 Todo 목록이 유지되는 것입니다.

5. useReducer로 상태 관리 전환

목표

  • 상태 관리 로직을 useReducer로 변경하여 코드의 가독성과 유지보수성을 높이기.
  • dispatch를 사용하여 상태 업데이트의 흐름을 명확히 하기.

useReducer란?

  • 복잡한 상태 관리 로직을 한 곳에 모으는 데 유용.
  • 상태 변경 로직을 Reducer 함수로 분리.
  • dispatch로 특정 액션을 호출하여 상태를 변경.

구성 요소

  • Reducer 함수: 상태와 액션을 받아 새로운 상태를 반환.
  • State 초기값: 상태의 초기값을 정의.
  • Dispatch: 상태를 변경하기 위한 액션을 보냄.

변경할 내용

  • reducer 함수 정의 (예: ADD_TODO, UPDATE_TODO, DELETE_TODO).
  • useReducer로 상태 관리 변경.
// reducers/todoReducer.js
// uuid 패키지를 사용하여 고유한 ID를 생성하고, 초기 상태와 Reducer 함수를 정의합니다.
import {v4 as uuidv4} from "uuid";

// 액션 타입 정의
// 문법 : export const 액션타입 = "액션타입";
export const ADD_TODO = "ADD_TODO";
export const UPDATE_TODO = "UPDATE_TODO";
export const DELETE_TODO = "DELETE_TODO";

// 초기 상태
export const initialState = [];

// Reducer 함수
// 문법 : export const 리듀서함수명 = (state, action) => { switch(action.type) { case 액션타입: return 변경된상태; default: throw new Error(`Unknown action type: ${action.type}`); } };
export const todoReducer = (state, action) => {
  switch (action.type) {
    case ADD_TODO:
      return [
        {
          id: uuidv4(), // 고유한 UUID 생성
          task: action.payload.task,
          isDone: false,
          createdDate: new Date().getTime(),
        },
        ...state,
      ];
    case UPDATE_TODO:
      return state.map((todo) =>
        todo.id === action.payload.id ? { ...todo, isDone: !todo.isDone } : todo
      );
    case DELETE_TODO:
      return state.filter((todo) => todo.id !== action.payload.id);
    default:
      throw new Error(`Unknown action type: ${action.type}`);
  }
};
// app/components/Todo.jsx
"use client";

import { useReducer, useEffect } from "react";
import { todoReducer, initialState, ADD_TODO, UPDATE_TODO, DELETE_TODO } from "@/reducers/todoReducer";
import TodoHd from "@/components/TodoHd";
import TodoEditor from "@/components/TodoEditor";
import TodoList from "@/components/TodoList";

// 로컬 스토리지 키 선언
const LOCAL_STORAGE_KEY = "my-todo-app-todos";

const Todo = () => {
  const [todos, dispatch] = useReducer(todoReducer, initialState);

  // 로컬 스토리지에서 초기 상태 로드
  useEffect(() => {
    const savedTodos = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY)) || [];
    savedTodos.forEach((todo) => dispatch({ type: ADD_TODO, payload: todo }));
  }, []);

  // 상태 변경 시 로컬 스토리지에 저장
  useEffect(() => {
    localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos));
  }, [todos]);

  // 할 일을 추가하는 함수
  const addTodo = (task) => {
    dispatch({ type: ADD_TODO, payload: { task } });
  };

  // 완료 상태를 업데이트하는 함수
  const onUpdate = (id) => {
    dispatch({ type: UPDATE_TODO, payload: { id } });
  };

  // 할 일을 삭제하는 함수
  const onDelete = (id) => {
    dispatch({ type: DELETE_TODO, payload: { id } });
  };

  return (
    <div className="flex flex-col gap-5">
      <TodoHd />
      <TodoEditor addTodo={addTodo} />
      <TodoList todos={todos} onUpdate={onUpdate} onDelete={onDelete} />
    </div>
  );
};

export default Todo;

6. useContext로 전역 상태 관리

목표

  • Context API를 사용해 상태와 dispatch를 전역적으로 공유.
  • 컴포넌트 간 상태 전달을 단순화.

변경할 내용

  • TodoContext를 생성하여 상태와 dispatch를 전역으로 제공.
  • 컴포넌트에서 useContext를 사용해 상태와 dispatch를 쉽게 접근.
// src/contexts/TodoContext.js
"use client";

import { createContext, useContext, useReducer, useEffect } from "react";
import { todoReducer, initialState, ADD_TODO, UPDATE_TODO, DELETE_TODO } from "@/reducers/todoReducer";

// Context 생성
// 문법 : const context = createContext(defaultValue);
const TodoContext = createContext();

// Custom Hook
// 문법 : const customHook = () => useContext(context);
export const useTodoContext = () => {
  const context = useContext(TodoContext);
  if (!context) {
    throw new Error("useTodoContext must be used within a TodoProvider");
  }
  return context;
};

// Provider 컴포넌트
// 문법 : const Provider = ({ children }) => { return <context.Provider value={value}>{children}</context.Provider>; };
export const TodoProvider = ({ children }) => {
  const [todos, dispatch] = useReducer(todoReducer, initialState);
  const LOCAL_STORAGE_KEY = "my-todo-app-todos";

  // 로컬 스토리지에서 초기 상태 로드
  useEffect(() => {
    const savedTodos = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY)) || [];
    savedTodos.forEach((todo) => dispatch({ type: ADD_TODO, payload: todo }));
  }, []);

  // 상태 변경 시 로컬 스토리지에 저장
  useEffect(() => {
    localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos));
  }, [todos]);

  // 할 일 추가
  const addTodo = (task) => {
    dispatch({ type: ADD_TODO, payload: { task } });
  };

  // 완료 상태 업데이트
  const onUpdate = (id) => {
    dispatch({ type: UPDATE_TODO, payload: { id } });
  };

  // 할 일 삭제
  const onDelete = (id) => {
    dispatch({ type: DELETE_TODO, payload: { id } });
  };

  const value = {
    todos,
    addTodo,
    onUpdate,
    onDelete,
  };

  return <TodoContext.Provider value={value}>{children}</TodoContext.Provider>;
};
// src/components/Todo.jsx
"use client";

import { TodoProvider } from "@/contexts/TodoContext";
import TodoHd from "@/components/TodoHd";
import TodoEditor from "@/components/TodoEditor";
import TodoList from "@/components/TodoList";

const Todo = () => {
  return (
    <TodoProvider>
      <div className="flex flex-col gap-5">
        <TodoHd />
        <TodoEditor />
        <TodoList />
      </div>
    </TodoProvider>
  );
};

export default Todo;
// src/components/TodoEditor.jsx
import { useState, useRef } from "react";
import { useTodoContext } from "@/contexts/TodoContext";

const TodoEditor = () => {
  const [task, setTask] = useState("");
  const inputRef = useRef();
  const { addTodo } = useTodoContext();

  const onSubmit = () => {
    if (!task) return; // 빈 입력 방지
    addTodo(task);
    setTask(""); // 입력창 초기화
    inputRef.current.focus(); // 입력창 포커스
  };

  const onKeyDown = (e) => {
    if (e.key === "Enter") onSubmit();
  };

  return (
    <div>
      <input
        ref={inputRef}
        value={task}
        onChange={(e) => setTask(e.target.value)}
        onKeyDown={onKeyDown}
        placeholder="할 일을 입력하세요"
      />
      <button onClick={onSubmit}>추가</button>
    </div>
  );
};

export default TodoEditor;
// src/components/TodoList.jsx
import { useTodoContext } from "@/contexts/TodoContext";
import TodoItem from "@/components/TodoItem";

const TodoList = () => {
  const { todos, onUpdate, onDelete } = useTodoContext();

  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          {...todo}
          onUpdate={onUpdate}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
};

export default TodoList;

7. 최적화

최적화란 불필요한 렌더링을 줄여 성능을 향상시키는 것을 말합니다.

목표:

  • React.memouseMemo를 사용하여 성능 최적화.

  • React.memo: 컴포넌트의 props가 변경되지 않으면 리렌더링을 방지. (컴포넌트의 props가 바뀌지 않으면 다시 그리지 않도록 함.)

  • useMemo: 계산 비용이 높은 함수의 결과를 캐싱하여 성능을 향상시키는 방법은, 복잡한 계산을 반복하지 않고 한 번 계산한 결과를 저장해 두었다가 필요할 때 다시 사용하는 것입니다. 이렇게 하면 동일한 계산을 여러 번 수행할 필요가 없어져서 프로그램의 속도가 빨라집니다.

  • useCallback: 함수를 캐싱하여 성능을 향상시키는 방법은, 함수를 새로 만들지 않고 재사용하는 것입니다. 이렇게 하면 동일한 함수를 여러 번 만들 필요가 없어져서 프로그램의 속도가 빨라집니다.

TodoItem.js 최적화

React.memo를 사용하여 동일한 props가 전달되면 컴포넌트 리렌더링을 방지.

// src/components/TodoItem.jsx
import React from 'react';

// React.memo를 사용하여 동일한 props가 전달되면 컴포넌트 리렌더링을 방지.
const TodoItem = React.memo(({ id, isDone, task, createdDate, onUpdate, onDelete }) => {
  console.log(`TodoItem ${task}: isDone = ${isDone}`); // 추가

  return (
    <div>
      <li key={id}>
        <input
          type="checkbox"
          checked={isDone}
          onChange={() => onUpdate(id)}
        />
        <span className={`${isDone ? 'line-through text-gray-400' : 'no-underline text-black'}`}>
          {task}
        </span>
        <span>{new Date(createdDate).toLocaleDateString()}</span>
        <button onClick={() => onDelete(id)}>삭제</button>
      </li>
    </div>
  );
});

// displayName 추가
TodoItem.displayName = 'TodoItem';

export default TodoItem;

TodoList.js 최적화

React.memo를 사용하여 동일한 props가 전달되면 컴포넌트 리렌더링을 방지.

// src/components/TodoList.jsx
import React, { useCallback, useMemo, useState } from 'react';
import TodoItem from './TodoItem';
import {useTodoContext} from "@/contexts/TodoContext";

const TodoList = () => {

    // useTodoContext()를 통해 todos, onUpdate, onDelete 함수를 가져온다.
    const { todos, onUpdate, onDelete } = useTodoContext();
    const [search, setSearch] = useState('');

    // 검색어 변경 핸들러를 useCallback으로 메모이제이션
    const onChangeSearch = useCallback(e => {
        setSearch(e.target.value);
    }, []);

    // 검색된 할 일을 useMemo로 캐싱
    const filteredTodo = useMemo(() => {
        return todos.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()));
    }, [todos, search]);

    // TodoItem에서 사용할 핸들러를 useCallback으로 메모이제이션
    const handleUpdate = useCallback((id) => onUpdate(id), [onUpdate]);
    const handleDelete = useCallback((id) => onDelete(id), [onDelete]);

    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input
            value={search}
            onChange={onChangeSearch}
            placeholder="검색어를 입력하세요"/>
            <ul>
                {filteredTodo().map((todo) => (
                    <TodoItem 
                    key={todo.id} 
                    onUpdate={() => handleUpdate(todo.id)} // 메모이제이션된 업데이트 핸들러
                    onDelete={() => handleDelete(todo.id)} // 메모이제이션된 삭제 핸들러 {...todo} 
                    />
                ))}

            </ul>
        </div>
    );
};

export default TodoList;
티스토리 친구하기