Front/React

React를 이용한 할 일 관리 앱 만들기

oodada 2024. 3. 14. 10:33

할 일 관리 앱 만들기

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

- App 컴포넌트 만들기

// src/App.js
import React from 'react'
import TodoHd from './TodoHd'
import TodoEditor from './component/TodoEditor'
import TodoList from './component/TodoList'

export default function App() {
    return (
        <div>
            <TodoHd />
            <TodoEditor />
            <TodoList />
        </div>
    )
}

- Header 컴포넌트 만들기

// src/components/TodoHd.js
import React from 'react'

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

- TodoEditor 컴포넌트 만들기

// src/components/TodoEditor.js
import React from 'react'

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

- TodoList 컴포넌트 만들기

// src/components/TodoList.js
import React from 'react'
import TodoItem from './TodoItem'

export default function TodoList() {
    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input placeholder="검색어를 입력하세요" />
            <ul>
                <li>
                    <input type="checkbox" />
                    <span>고양이 밥주기</span>
                    <span>2024.03.11</span>
                    <button>삭제</button>
                </li>
                <li>
                    <input type="checkbox" />
                    <span>감자 캐기</span>
                    <span>2024.03.11</span>
                    <button>삭제</button>
                </li>
                <li>
                    <input type="checkbox" />
                    <span>고양이 놀아주기</span>
                    <span>2024.03.11</span>
                    <button>삭제</button>
                </li>
            </ul>
        </div>
    )
}

2. 기능 구현 준비하기

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

데이터를 다루는 4가지 기능인 추가(Create), 조회(Read), 수정(Update), 삭제(Delete) 기능을 앞글자만 따서 CRUD라고 부릅니다. 이러한 CRUD 기능을 구현하기 위해 다음과 같은 데이터 구조를 사용합니다.

- 오늘 날짜 표시하기

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

npm install date-fns
yarn add date-fns
// src/components/TodoHd.js
import React from 'react'
import { Text, Box } from '@chakra-ui/react'
import { format } from 'date-fns'

export default function TodoHd() {
    return (
        <Box>
            <Text fontSize={18}>
                <Text as="span" fontSize={24} fontWeight={700}>
                    📝 {format(new Date(), 'yyyy.MM.dd')}
                </Text>{' '}
                오늘의 할 일을 적어보세요.
            </Text>
        </Box>
    )
}

- 기초 데이터 설정하기

// src/App.js
import React, { useState } from 'react'
import TotoHd from './TodoHd'
import TodoEditor from './component/TodoEditor'
import TodoList from './component/TodoList'

// 할 일 데이터
const mockTodo = [
    {
        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(),
    },
]

export default function App() {
    // useState를 이용하여 할 일 데이터를 관리합니다.
    const [todo, setTodo] = useState(mockTodo)

    return (
        <div>
            <TotoHd />
            <TodoEditor />
            <TodoList />
        </div>
    )
}

Read: 할 일 목록 렌더링하기

- 로직

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

- 할 일 목록 렌더링하기

// src/App.js
import React, { useState } from 'react'
import TodoHd from './TodoHd'
import TodoEditor from './TodoEditor'
import TodoList from './TodoList'

const mockTodo = [
    (...)
]

export default function App() {
    const [todo, setTodo] = useState(mockTodo)

    return (
        <div>
            <TodoHd />
            <TodoEditor />
            // 할 일 목록을 TodoList 컴포넌트에 전달합니다.
            <TodoList todo={todo} />
        </div>
    )
}
// src/components/TodoList.js
import React from 'react'

export default function TodoList({ todo }) {
    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input placeholder="검색어를 입력하세요" />
            <ul>
                {todo.map((item) => (
                    // item.id를 key로 사용하여 고유한 값을 설정합니다.
                    // ...item은 item 객체의 모든 속성을 TodoItem 컴포넌트에 전달합니다.
                    <TodoItem key={item.id} {...item} />
                ))}
            </ul>
        </div>
    )
}
// src/components/TodoItem.js
const TodoItem = ({ id, isDone, task, createdDate }) => {
    return (
        <div>
            <li key={id}>
                <input type="checkbox" checked={isDone} />
                <span>{task}</span>
                <span>{new Date(createdDate).toLocaleDateString()}</span>
                <button>삭제</button>
            </li>
        </div>
    )
}
export default TodoItem

Create: 할 일 추가하기

- 로직

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

- 할 일 추가하기

// src/App.js
import React, { useState, useRef } from 'react'
import TodoHd from './TodoHd'
import TodoEditor from './TodoEditor'
import TodoList from './TodoList'

const mockTodo = [
    (...)
]

function App() {
    const [todo, setTodo] = useState(mockTodo)

    // 할 일을 추가하는 함수를 만듭니다.
    const addTodo = (task) => {
        // 새로운 할 일 객체를 만듭니다.
        const newTodo = {
            id: todos.length + 1,, // id는 1씩 증가하는 숫자로 설정합니다.
            isDone: false,
            task,
            createdDate: new Date().getTime(),
        }
        // 기존 할 일 목록에 새로운 할 일을 추가합니다. (새로운 할 일이 위로 올라가도록 합니다.)
        setTodo([newTodo, ...todo])
    }

    return (
        <div>
            <TodoHd />
            <TodoEditor addTodo={addTodo} />
            <TodoList todo={todo} />
        </div>
    )
}

export default App

- 할 일 추가 함수 호출하기

// src/components/TodoEditor.js
import { useState, useRef } 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 className="TodoEditor">
            <h2>새로운 Todo 작성하기 ✏ </h2>
            <div>
                {/* inputRef를 input 요소에 연결합니다. */}
                {/* task 상태값을 value로 설정 */}
                <input ref={inputRef} value={task} onChange={onChangeTask} placeholder="할 일을 추가로 입력해주세요." />
                <button onClick={onSubmit}>추가</button>
            </div>
        </div>
    )
}
export default TodoEditor

- input에 포커스 맞추기

// src/components/TodoEditor.js
import { useState, useRef } from 'react'

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

    (...)

    // 추가 버튼을 클릭하면 할 일을 추가하는 함수를 호출합니다.
    const onSubmit = () => {
        // task가 빈 문자열이면 함수를 종료합니다.
        if (!task) return
        // 할 일을 추가하는 함수를 호출합니다.
        addTodo(task)
    }

    return (
        <div className="TodoEditor">
            <h2>새로운 Todo 작성하기 ✏ </h2>
            <div>
                {/* inputRef가 존재하면 focus() 실행 */}
                <input ref={(inputRef) => inputRef && inputRef.focus()} value={task} onChange={onChangeTask} placeholder="할 일을 추가로 입력해주세요." />
                <button onClick={onSubmit}>추가</button>
            </div>
        </div>
    )
}
export default TodoEditor

- 아이템 입력 후 input 초기화하기

// src/components/TodoEditor.js
import { useState, useRef } from 'react'

const TodoEditor = ({ addTodo }) => {
    (...)
    const onSubmit = () => {
        if (!task) return
        addTodo(task)
        // 할 일을 추가한 후 input을 초기화합니다.
        setTask('')
    }

    return (
        <div className="TodoEditor">
            <h2>새로운 Todo 작성하기 ✏ </h2>
            <div>
                <input ref={inputRef} value={task} onChange={onChangeTask} placeholder="할 일을 추가로 입력해주세요." />
                <button onClick={onSubmit}>추가</button>
            </div>
        </div>
    )
}

export default TodoEditor

- Enter 키로 할 일 추가하기

// src/components/TodoEditor.js
import { useState, useRef } from 'react'

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

    // input에서 Enter 키를 누르면 할 일을 추가하는 함수를 호출합니다.
    const onKeyDown = (e) => {
        if (e.key === 'Enter') {
            onSubmit()
        }
    }
    return (
        <div className="TodoEditor">
            <h2>새로운 Todo 작성하기 ✏ </h2>
            <div>
                <input ref={inputRef} value={task} onChange={onChangeTask} onKeyDown={onKeyDown} placeholder="할 일을 추가로 입력해주세요." />
                <button onClick={onSubmit}>추가</button>
            </div>
        </div>
    )
}

export default TodoEditor

Search: 할 일 검색하기

- 할 일 검색하기

// src/components/TodoList.js
import React from 'react'
import TodoItem from './TodoItem'

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

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

    // 검색어를 포함하는 할 일 목록을 저장합니다.
    const filteredTodo = () => {
        // 검색어가 포함된 할 일 목록을 반환합니다.
        // todo.filter() 함수는 todo 배열을 순회하면서 검색어가 포함된 할 일 목록을 반환합니다.
        return todo.filter((item) => item.task.includes(search))
    }

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

- 대소문자 구분 없이 검색하기

// src/components/TodoList.js
import React from 'react'
import TodoItem from './TodoItem'

export default function TodoList({ todo }) {
    (...)

    const filteredTodo = () => {
        // toLowerCase()를 이용하여 검색어와 할 일 목록의 task를 소문자로 변경합니다.
        return todo.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()))
    }

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

Update: 작업 완료

- 로직

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

- 완료 표시하기

// src/components/App.js
(...)

function App() {
    const [todo, setTodo] = useState(mockTodo)
    (...)

    // 완료 표시를 클릭시 호출되는 함수 onUpdate를 만들고, id에 해당하는 할 일의 isDone을 변경합니다.
    const onUpdate = (id) => {
        // id에 해당하는 할 일의 isDone을 변경합니다.
        setTodo(
            // map() 함수를 이용하여 todo 배열을 순회하면서 id에 해당하는 할 일의 isDone을 변경합니다.
            todo.map((item) => {
                // id에 해당하는 할 일의 isDone을 변경합니다.
                if (item.id === id) {
                    return { ...item, isDone: !item.isDone }
                }
                // 변경된 할 일을 반환합니다.
                return item
            })
        )
    }

    return (
        <div>
            <TodoHd />
            <TodoEditor addTodo={addTodo} />
            <TodoList todo={todo} onUpdate={onUpdate} />
        </div>
    )
}
// src/components/TodoList.js
import React from 'react'
import TodoItem from './TodoItem'

export default function TodoList({ todo, onUpdate }) {
    (...)

    return (
        <div>
            <h2>할 일 목록 📃</h2>
            <input value={search} onChange={onChangeSearch} placeholder="검색어를 입력하세요" />
            <ul>
                {filteredTodo().map((item) => (
                    // onUpdate 함수를 TodoItem 컴포넌트에 전달합니다.
                    <TodoItem key={item.id} onUpdate={onUpdate} {...item} />
                ))}
            </ul>
        </div>
    )
}
// src/components/TodoItem.js
const TodoItem = ({ id, isDone, task, createdDate, onUpdate }) => {
    return (
        <div>
            <li key={id}>
                {/* 체크박스를 클릭하면 onUpdate 함수를 호출합니다. */}
                <input type="checkbox" checked={isDone} onChange={() => onUpdate(id)} />
                <span>{task}</span>
                <span>{format(new Date(createdDate), 'yyyy.MM.dd')}</span>
                <button>삭제</button>
            </li>
        </div>
    )
}

export default TodoItem

- 완료 취소선 표시하기

// src/components/TodoItem.js
const TodoItem = ({ id, isDone, task, createdDate, onUpdate }) => {
    return (
        <div>
            <li key={id}>
                <input type="checkbox" checked={isDone} onChange={() => onUpdate(id)} />
                {/* 완료된 할 일은 흐리게 표시합니다. */}
                <span style={{ textDecoration: isDone ? 'line-through' : 'none' }}>{task}</span>
                <span>{new Date(createdDate).toLocaleDateString()}</span>
                <button>삭제</button>
            </li>
        </div>
    )
}

export default TodoItem

Delete : 할 일 삭제하기

- 로직

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

- 할 일 삭제하기

// src/components/App.js
(...)

function App() {
    (...)

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

    return (
        <div>
            <TodoHd />
            <TodoEditor addTodo={addTodo} />
            <TodoList todo={todo} onUpdate={onUpdate} onDelete={onDelete} />
        </div>
    )
}
// src/components/TodoList.js
import React from 'react'
import TodoItem from './TodoItem'

export default function TodoList({ todo, onUpdate, onDelete }) {
    (...)

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

                ))}
            </ul>
        </div>
    )
}
// src/components/TodoItem.js
const TodoItem = ({ id, isDone, task, createdDate, onUpdate, onDelete }) => {
    return (
        <div>
            <li key={id}>
                <input type="checkbox" checked={isDone} onChange={() => onUpdate(id)} />
                <strong style={{ textDecoration: isDone ? 'line-through' : 'none' }}>{task}</strong>
                <span>{format(new Date(createdDate), 'yyyy.MM.dd')}</span>
                <button onClick={() => onDelete(id)}>삭제</button>
            </li>
        </div>
    )
}

export default TodoItem

useReducer로 상태 관리하기

- useReducer란

  • useState를 대체하여 컴포넌트에서 상태를 관리하는 훅입니다.
  • 상태를 변경하는 로직을 컴포넌트 바깥에 선언하여 재사용이 용이합니다.
  • 상태를 변경하는 로직이 복잡해져도 코드가 간결해집니다.

- useReducer 사용하기

// src/components/App.js
import React, { useReducer } from 'react'
import TodoHd from './TodoHd'
import TodoEditor from './TodoEditor'
import TodoList from './TodoList'

const mockTodo = [
    {
        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(),
    },
]

// reducer 함수로 상태를 변경합니다.
// state : 현재 상태
// action : 상태를 변경할 때 참조하는 값
function reducer(state, action) {
    switch (action.type) {
        case 'ADD':
            return [action.payload, ...state]
        case 'UPDATE':
            return state.map((it) => (it.id === action.payload ? { ...it, isDone: !it.isDone } : it))
        case 'DELETE':
            return state.filter((it) => it.id !== action.payload)
        default:
            return state
    }
}

function App() {
    const [todo, dispatch] = useReducer(reducer, mockTodo)

    const addTodo = (task) => {
        const newTodo = {
            id: Date.now(),
            isDone: false,
            task,
            createdDate: new Date().getTime(),
        }
        dispatch({ type: 'ADD', payload: newTodo })
    }

    const onUpdate = (id) => {
        dispatch({ type: 'UPDATE', payload: id })
    }

    const onDelete = (id) => {
        dispatch({ type: 'DELETE', payload: id })
    }

    return (
        <div>
            <TodoHd />
            <TodoEditor addTodo={addTodo} />
            <TodoList todo={todo} onUpdate={onUpdate} onDelete={onDelete} />
        </div>
    )
}

export default App
// src/components/TodoEditor.js
import React, { useState } from 'react'

export default function TodoEditor({ addTodo }) {
    const [task, setTask] = useState('')

    const onSubmit = () => {
        if (!task) return
        addTodo(task)
        setTask('')
    }

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

    return (
        <div>
            <h3>새로운 Todo 작성하기 ✏ </h3>
            <div>
                <input
                    type="text"
                    ref={(inputRef) => inputRef && inputRef.focus()} // inputRef가 존재하면 focus() 실행
                    placeholder="할 일을 추가로 입력해주세요."
                    onChange={(e) => setTask(e.target.value)}
                    onKeyDown={onKeyDown}
                    value={task} // task 상태값을 value로 설정
                />
                <button onClick={onSubmit}>할 일 추가</button>
            </div>
        </div>
    )
}
// src/components/TodoList.js
import React, { useState } from 'react'
import TodoItem from './TodoItem'

function TodoList({ todo, onUpdate, onDelete }) {
    const [search, setSearch] = useState('') // Added useState for search

    const onChangeSearch = (e) => {
        setSearch(e.target.value)
    }

    // Added filter for search
    const filteredTodo = () => {
        return todo.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()))
    }

    return (
        <div>
            <h3>할 일 목록 📃</h3>
            <input type="text" placeholder="검색어를 입력하세요" onChange={onChangeSearch} value={search} />

            <div>
                {/* filteredTodo() 함수를 호출하여 검색어가 포함된 할 일 목록을 출력합니다. */}
                {filteredTodo().map((item) => (
                    <TodoItem key={item.id} {...item} onUpdate={onUpdate} onDelete={onDelete} />
                ))}
            </div>
        </div>
    )
}

export default TodoList
import React from 'react'
import { format } from 'date-fns'

function TodoItem({ id, isDone, task, createdDate, onUpdate, onDelete }) {
    return (
        <li key={id}>
            <input type="checkbox" checked={isDone} onChange={() => onUpdate(id)} />
            <strong style={{ textDecoration: isDone ? 'line-through' : 'none' }}>{task}</strong>
            <span>{format(new Date(createdDate), 'yyyy.MM.dd')}</span>
            <button onClick={() => onDelete(id)}>삭제</button>
        </li>
    )
}

export default TodoItem

Context API로 상태 관리하기

- Context API란

  • 컴포넌트 트리 전체에 데이터를 제공하는 React의 기능입니다.
  • 중첩된 컴포넌트에 데이터를 전달하기 위해 props를 사용하지 않고, 컴포넌트 트리 전체에 데이터를 제공합니다.
  • 상태를 전역적으로 관리할 수 있어, 상태를 여러 컴포넌트에서 공유할 때 유용합니다.

- Context API 사용하기

// src/context/TodoContext.js

import React, { createContext, useContext, useReducer } from 'react'

const mockTodo = [
    {
        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(),
    },
]

// reducer 함수로 상태를 변경합니다.
function reducer(state, action) {
    switch (action.type) {
        case 'ADD':
            return [action.payload, ...state]
        case 'UPDATE':
            return state.map((it) => (it.id === action.payload ? { ...it, isDone: !it.isDone } : it))
        case 'DELETE':
            return state.filter((it) => it.id !== action.payload)
        default:
            return state
    }
}

// createContext() 함수로 Context 객체를 생성합니다.
const TodoContext = createContext()

// Context 객체의 Provider 컴포넌트를 만듭니다.
// Provider 컴포넌트는 value를 통해 하위 컴포넌트에 데이터를 전달합니다.
export const TodoProvider = ({ children }) => {
    const [todo, dispatch] = useReducer(reducer, mockTodo)

    return <TodoContext.Provider value={{ todo, dispatch }}>{children}</TodoContext.Provider>
}

// useContext() 함수로 Context 객체의 value를 사용할 수 있습니다.
export const useTodoContext = () => {
    const context = useContext(TodoContext)
    if (!context) {
        throw new Error('todoProvider를 찾을 수 없습니다.')
    }
    return context
}
// src/App.js
import React from 'react'
import TodoHd from './TodoHd'
import TodoEditor from './TodoEditor'
import TodoList from './TodoList'
import { TodoProvider } from './context/TodoContext'

function App() {
    return (
        <TodoProvider>
            <div>
                <TodoHd />
                <TodoEditor />
                <TodoList />
            </div>
        </TodoProvider>
    )
}

export default App
// src/components/TodoEditor.js
import React, { useState } from 'react'
import { useTodoContext } from '../../context/TodoContext'

export default function TodoEditor() {
    const [task, setTask] = useState('')
    const { dispatch } = useTodoContext()

    const addTodo = (task) => {
        const newTodo = {
            id: Date.now(),
            isDone: false,
            task,
            createdDate: new Date().getTime(),
        }
        dispatch({ type: 'ADD', payload: newTodo })
    }

    const onSubmit = () => {
        if (!task) return
        addTodo(task)
        setTask('')
    }

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

    return (
        <div>
            <h3>새로운 Todo 작성하기 ✏ </h3>
            <div>
                <input
                    type="text"
                    ref={(inputRef) => inputRef && inputRef.focus()} // inputRef가 존재하면 focus() 실행
                    placeholder="할 일을 추가로 입력해주세요."
                    onChange={(e) => setTask(e.target.value)}
                    onKeyDown={onKeyDown}
                    value={task} // task 상태값을 value로 설정
                />
                <button onClick={onSubmit}>할 일 추가</button>
            </div>
        </div>
    )
}
// src/components/TodoList.js
import React, { useState } from 'react'
import TodoItem from './TodoItem'
import { useTodoContext } from '../../context/TodoContext'

function TodoList() {
    const [search, setSearch] = useState('') // Added useState for search
    const { todo } = useTodoContext()

    const onChangeSearch = (e) => {
        setSearch(e.target.value)
    }

    // Added filter for search
    const filteredTodo = () => {
        return todo.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()))
    }

    return (
        <div>
            <h3>할 일 목록 📃</h3>
            <input type="text" placeholder="검색어를 입력하세요" onChange={onChangeSearch} value={search} />

            <div>
                {/* filteredTodo() 함수를 호출하여 검색어가 포함된 할 일 목록을 출력합니다. */}
                {filteredTodo().map((item) => (
                    <TodoItem key={item.id} {...item} />
                ))}
            </div>
        </div>
    )
}

export default TodoList
// src/components/TodoItem.js
import React from 'react'
import { format } from 'date-fns'
import { useTodoContext } from '../../context/TodoContext'

function TodoItem({ id, isDone, task, createdDate }) {
    const { dispatch } = useTodoContext()

    const onUpdate = (id) => {
        dispatch({ type: 'UPDATE', payload: id })
    }

    const onDelete = (id) => {
        dispatch({ type: 'DELETE', payload: id })
    }

    return (
        <li key={id}>
            <input type="checkbox" checked={isDone} onChange={() => onUpdate(id)} />
            <strong style={{ textDecoration: isDone ? 'line-through' : 'none' }}>{task}</strong>
            <span>{format(new Date(createdDate), 'yyyy.MM.dd')}</span>
            <button onClick={() => onDelete(id)}>삭제</button>
        </li>
    )
}

export default TodoItem

Redux로 상태 관리하기

- Redux란

  • React 애플리케이션의 상태를 관리하는 라이브러리로 Context API와 useReducer를 기반으로 만들어졌습니다.

- Redux 사용하기

npm install redux react-redux
yarn add redux react-redux
// src/store/actions/todoAction.js
// 액션 타입 정의 (대문자로 작성)
export const ADD_TODO = 'ADD_TODO' // 할 일 추가
export const UPDATE_TODO = 'UPDATE_TODO'
export const DELETE_TODO = 'DELETE_TODO'

// 액션 생성자 함수
export const addTodo = (task) => ({
    type: ADD_TODO, // 액션 타입
    payload: { id: Date.now(), isDone: false, task, createdDate: new Date().getTime() }, // 액션 페이로드
})

export const updateTodo = (id) => ({
    type: UPDATE_TODO,
    payload: id,
})

export const deleteTodo = (id) => ({
    type: DELETE_TODO,
    payload: id,
})
// src/store/reducers/todoReducer.js
import { ADD_TODO, UPDATE_TODO, DELETE_TODO } from '../actions/todoAction'

// 초기 상태
const initialState = {
    todos: [
        {
            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(),
        },
    ],
}

// 리듀서 함수
const todoReducer = (state = initialState, action) => {
    switch (action.type) {
        case ADD_TODO:
            return {
                // 기존 상태를 변경하지 않고 새로운 상태를 반환합니다.
                // redux는 불변성을 유지하고 이전 상태를 변경하지 않아야 합니다.
                ...state, // 기존 상태를 복사합니다.
                todos: [action.payload, ...state.todos], // 새로운 할 일을 추가합니다.
            }
        case UPDATE_TODO:
            return {
                ...state,
                todos: state.todos.map((it) => (it.id === action.payload ? { ...it, isDone: !it.isDone } : it)),
            }
        case DELETE_TODO:
            return {
                ...state,
                todos: state.todos.filter((it) => it.id !== action.payload),
            }
        default:
            return state
    }
}

export default todoReducer
// src/store/index.js
import todoReducer from './reducers/todoReducer'
import { combineReducers, createStore } from 'redux'

const rootReducer = combineReducers({
    // todoReducer를 todo라는 이름으로 사용합니다.
    todo: todoReducer,
    // cart: cartReducer,
})

const store = createStore(rootReducer)

export default store
// src/App.js
// Redux를 사용하기 위해 불필요한 TodoProvider 컴포넌트 삭제
// Redux의 Provider 컴포넌트로 감싸줍니다.

import React from 'react'
import TodoHd from './TodoHd'
import TodoEditor from './TodoEditor'
import TodoList from './TodoList'
import { Provider } from 'react-redux' // Redux의 Provider 임포트
import store from './store' // Redux 스토어 임포트

function App() {
    return (
        <Provider store={store}>
            {' '}
            {/* Redux의 Provider로 감싸기 */}
            <div>
                <TodoHd />
                <TodoEditor />
                <TodoList />
            </div>
        </Provider>
    )
}

export default App
// src/components/TodoEditor.js
// Redux와의 연동을 위해 더 이상 useTodoContext 훅 사용하지 않음
// 액션 함수를 디스패치하기 위해 useDispatch 훅을 사용
// TodoEditor 컴포넌트에서는 더 이상 useState 훅을 사용하여 상태를 관리하지 않음

import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { addTodo } from '../../store/actions/todoAction'

export default function TodoEditor() {
    const [task, setTask] = useState('')
    const dispatch = useDispatch() // useDispatch 훅으로 디스패치 함수 가져오기

    const onSubmit = () => {
        if (!task) return
        dispatch(addTodo(task)) // 액션 함수 디스패치
        setTask('')
    }

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

    return (
        <div>
            <h3>새로운 Todo 작성하기 ✏</h3>
            <div>
                <input
                    type="text"
                    ref={(inputRef) => inputRef && inputRef.focus()}
                    placeholder="할 일을 추가로 입력해주세요."
                    onChange={(e) => setTask(e.target.value)}
                    onKeyDown={onKeyDown}
                    value={task}
                />
                <button onClick={onSubmit}>할 일 추가</button>
            </div>
        </div>
    )
}
// src/components/TodoList.js
// Redux와의 연동을 위해 useSelector 훅을 사용하여 상태를 가져오기
// 액션 함수를 디스패치하기 위해 useDispatch 훅을 사용
import React, { useState } from 'react'
import TodoItem from './TodoItem'
import { useSelector } from 'react-redux' // useDispatch 대신 useSelector 사용

function TodoList() {
    const [search, setSearch] = useState('')
    // useSelector에서 state로 전달되는 객체는 rootReducer에서 정의한 객체입니다.
    // rootReducer에서 todoReducer를 todo라는 이름으로 사용했기 때문에 state.todo로 todo 상태를 가져옵니다.
    // 따라서 state.todo.todo로 todo 상태를 가져올 수 있습니다.
    const todo = useSelector((state) => state.todo.todos) // todo 상태를 가져옵니다.

    const onChangeSearch = (e) => {
        setSearch(e.target.value)
    }

    const filteredTodo = () => {
        // todo 상태가 배열인지 확인하고 배열 메서드를 사용하기
        if (Array.isArray(todo)) {
            return todo.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()))
        } else {
            return []
        }
    }

    return (
        <div>
            <h3>할 일 목록 📃</h3>
            <input type='text' placeholder="검색어를 입력하세요" onChange={onChangeSearch} value={search} />

            <div>
                {filteredTodo().map((item) => (
                    <TodoItem key={item.id} {...item} />
                ))}
            </div>
        </div>
    )
}

export default TodoList


export default TodoList
// src/components/TodoItem.js
// Redux와의 연동을 위해 useDispatch 훅을 사용하여 액션 함수 디스패치
import React from 'react'
import { format } from 'date-fns'
import { useDispatch } from 'react-redux'
import { deleteTodo, updateTodo } from '../../store/actions/todoAction'

function TodoItem({ id, isDone, task, createdDate }) {
    const dispatch = useDispatch()

    return (
        <li key={id}>
            <input type="checkbox" checked={isDone} onChange={() => dispatch(updateTodo(id))} /> // 액션 함수 디스패치
            <strong style={{ textDecoration: isDone ? 'line-through' : 'none' }}>{task}</strong>
            <span>{format(new Date(createdDate), 'yyyy.MM.dd')}</span>
            <button onClick={() => dispatch(deleteTodo(id))}>삭제</button> // 액션 함수 디스패치
        </li>
    )
}

export default TodoItem

Redux Toolkit으로 상태 관리하기

- Redux Toolkit이란

  • Redux를 사용할 때 더 쉽게 사용할 수 있도록 도와주는 라이브러리입니다.
  • Redux Toolkit을 사용하면 Redux를 더 쉽게 사용할 수 있습니다.

- createSlice란

  • Redux Toolkit에서 제공하는 함수로, 액션 생성자 함수와 리듀서 함수를 한 번에 생성합니다.

- Redux Toolkit 사용하기

npm install @reduxjs/toolkit
yarn add @reduxjs/toolkit
// src/store/slices/todoSlice.js
import { createSlice } from '@reduxjs/toolkit'

// 초기 상태
const initialState = {
    todos: [
        {
            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(),
        },
    ],
}

// todoSlice 함수를 사용하여 `slice`를 생성합니다.
// slice는 액션 생성자 함수와 리듀서 함수를 한 번에 생성합니다.
export const todoSlice = createSlice({
    name: 'todo', // 슬라이스 이름
    initialState, // 초기 상태
    // 객체 형태로 리듀서 함수를 정의합니다.
    reducers: {
        addTodo: (state, action) => {
            // state.todos 배열에 새로운 할 일을 추가합니다.
            // action.payload에는 새로운 할 일이 들어있습니다.
            state.todos.push(action.payload) // 새로운 할 일을 추가합니다.
        },
        updateTodo: (state, action) => {
            // state.todos 배열에서 id가 일치하는 할 일을 찾습니다.
            // action.payload에는 할 일의 id가 들어있습니다.
            // 삼항 연산자를 사용하여 isDone 값을 반전시킵니다.
            state.todos = state.todos.map((todo) =>
                todo.id === action.payload ? { ...todo, isDone: !todo.isDone } : todo,
            )
        },
        deleteTodo: (state, action) => {
            state.todos = state.todos.filter((todo) => todo.id !== action.payload)
        },
    },
})

// 액션 생성자 함수 내보내기
export const { addTodo, updateTodo, deleteTodo } = todoSlice.actions

// todoSlice.reducer 내보내기
export default todoSlice.reducer
// src/App.js
import React, { useReducer, useState } from 'react'
import TodoHd from './components/TodoHd'
import TodoEditor from './components/TodoEditor'
import TodoList from './components/TodoList'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import todoSlice from '../../store/slices/todoSlice'

// Redux store 생성
// configureStore 함수를 사용하여 Redux store를 생성합니다.
const store = configureStore({
    reducer: {
        todo: todoSlice,
    },
})

const Todo = () => {
    return (
        <Provider store={store}>
            <TodoHd />
            <TodoEditor />
            <TodoList />
        </Provider>
    )
}

export default Todo
// src/components/TodoEditor.js
// Redux와의 연동을 위해 더 이상 useTodoContext 훅 사용하지 않음
// 액션 함수를 디스패치하기 위해 useDispatch 훅을 사용
// TodoEditor 컴포넌트에서는 더 이상 useState 훅을 사용하여 상태를 관리하지 않음

import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { addTodo } from '../store/slices/todoSlice' // todoSlice에서 액션 함수 가져오기

export default function TodoEditor() {
    const [task, setTask] = useState('')
    const dispatch = useDispatch() // useDispatch 훅으로 디스패치 함수 가져오기

    const onSubmit = () => {
        if (!task) return
        dispatch(
            addTodo({
                id: Date.now(),
                isDone: false,
                task,
                createdDate: new Date().getTime(),
            }),
        ) // 액션 함수 디스패치
        setTask('')
    }

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

    return (
        <div>
            <h3>새로운 Todo 작성하기 ✏</h3>
            <div>
                <input
                    type="text"
                    ref={(inputRef) => inputRef && inputRef.focus()}
                    placeholder="할 일을 추가로 입력해주세요."
                    onChange={(e) => setTask(e.target.value)}
                    onKeyDown={onKeyDown}
                    value={task}
                />
                <button onClick={onSubmit}>할 일 추가</button>
            </div>
        </div>
    )
}
// src/components/TodoList.js
// Redux와의 연동을 위해 useSelector 훅을 사용하여 상태를 가져오기
// 액션 함수를 디스패치하기 위해 useDispatch 훅을 사용
import React, { useState } from 'react'
import TodoItem from './TodoItem'
import { useSelector } from 'react-redux' // useDispatch 대신 useSelector 사용

function TodoList() {
    const [search, setSearch] = useState('')
    // useSelector에서 state로 전달되는 객체는 rootReducer에서 정의한 객체입니다.
    // rootReducer에서 todoReducer를 todo라는 이름으로 사용했기 때문에 state.todo로 todo 상태를 가져옵니다.
    // 따라서 state.todo.todos로 todo 상태를 가져올 수 있습니다.
    const todo = useSelector((state) => state.todo.todos) // todo 상태를 가져옵니다.

    const onChangeSearch = (e) => {
        setSearch(e.target.value)
    }

    const filteredTodo = () => {
        // todo 상태가 배열인지 확인하고 배열 메서드를 사용하기
        if (Array.isArray(todo)) {
            return todo.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()))
        } else {
            return []
        }
    }

    return (
        <div>
            <h3>할 일 목록 📃</h3>
            <input type="text" placeholder="검색어를 입력하세요" onChange={onChangeSearch} value={search} />

            <div>
                {filteredTodo().map((item) => (
                    <TodoItem key={item.id} {...item} />
                ))}
            </div>
        </div>
    )
}

export default TodoList
// src/components/TodoItem.js
// Redux와의 연동을 위해 useDispatch 훅을 사용하여 액션 함수 디스패치
import React from 'react'
import { format } from 'date-fns'
import { useDispatch } from 'react-redux'
import { updateTodo, deleteTodo } from '../store/slices/todoSlice' // todoSlice에서 액션 함수 가져오기

function TodoItem({ id, isDone, task, createdDate }) {
    const dispatch = useDispatch()

    return (
        <li key={id}>
            <input type="checkbox" checked={isDone} onChange={() => dispatch(updateTodo(id))} />
            <strong style={{ textDecoration: isDone ? 'line-through' : 'none' }}>{task}</strong>
            <span>{format(new Date(createdDate), 'yyyy.MM.dd')}</span>
            <button onClick={() => dispatch(deleteTodo(id))}>삭제</button>
        </li>
    )
}

export default TodoItem

최적화하기

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

- 메모제이션

  • 메모제이션은 함수의 반환값을 캐시하여 동일한 인자로 함수를 호출할 때 이전에 계산한 값을 반환하는 기법입니다.
  • React에서는 useMemo 훅을 사용하여 메모제이션을 할 수 있습니다.

- useMemo

useMemo(() => 연산 결과, [의존성 배열])

최적화 전

// src/components/TodoList.js
(...)

function TodoList() {
    (...)

    const filteredTodo = () => {
        // todo 상태가 배열인지 확인하고 배열 메서드를 사용하기
        if (Array.isArray(todo)) {
            return todo.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()));
        } else {
            return [];
        }
    };

    // lookBack 함수가 빈번하게 호출됩니다.
    const lookBack = () => {
        console.log('lookBack')
        const total = todo.length
        const done = todo.filter((item) => item.isDone).length
        const left = total - done

        return { total, done, left }
    }

    return (
        <div>
            <h3>할 일 목록 📃</h3>
            <input type='text' placeholder="검색어를 입력하세요" onChange={onChangeSearch} value={search} />

            <div>
                {filteredTodo().map((item) => (
                    <TodoItem key={item.id} {...item} />
                ))}
            </div>

            <div>
                {lookBack().total}개 중에 {lookBack().done}개 완료, {lookBack().left}개 남음
            </div>
        </div>
    );
}

export default TodoList;

최적화 후

// src/components/TodoList.js
import React, { useMemo } from 'react';
(...)

function TodoList() {
    (...)

    // useMemo를 이용하여 filteredTodo 함수의 결과를 기억합니다.
    // useMemo를 사용함으로써 filteredTodo는  함수가 아니라 해당 값을 계산한 결과인 배열을 반환합니다.
    const filteredTodo = useMemo(() => {
        if (Array.isArray(todo)) {
            return todo.filter((item) => item.task.toLowerCase().includes(search.toLowerCase()));
        } else {
            return [];
        }
    }, [todo, search]); // todo와 search가 변경될 때만 함수를 호출합니다.

    // useMemo를 이용하여 lookBack 함수의 결과를 기억합니다.
    // useMemo를 사용함으로써 lookBack는 함수가 아니라 해당 값을 계산한 결과인 객체를 반환합니다.
    const lookBack = useMemo(() => {
        console.log('lookBack')
        const total = todo.length
        const done = todo.filter((item) => item.isDone).length
        const left = total - done

        return { total, done, left }
    }, [todo]); // todo가 변경될 때만 함수를 호출합니다.

    return (
        <div>
            <h3>할 일 목록 📃</h3>
            <input type='text' placeholder="검색어를 입력하세요" onChange={onChangeSearch} value={search} />

            <div>
                {filteredTodo().map((item) => (
                    <TodoItem key={item.id} {...item} />
                ))}
            </div>

            <div>
                {lookBack.total}개 중에 {lookBack.done}개 완료, {lookBack.left}개 남음
            </div>
        </div>
    );
}

export default TodoList;

- useCallback

  • useCallback은 함수를 메모제이션하여 동일한 함수를 재사용할 수 있습니다.
  • React에서는 useCallback 훅을 사용하여 함수를 메모제이션할 수 있습니다.

useCallback(() => 함수, [의존성 배열])

// src/components/TodoEditor.js
(...)

export default function TodoEditor() {
    const [task, setTask] = useState('');
    const dispatch = useDispatch();

    // useCallback을 이용하여 onSubmit 함수를 메모제이션합니다.
    const onSubmit = useCallback(() => {
        if (!task) return;
        dispatch(addTodo({
            id: Date.now(),
            isDone: false,
            task,
            createdDate: new Date().getTime(),
        }));
        setTask('');
    }, [task, dispatch]); // task와 dispatch가 변경될 때만 함수를 호출합니다.

    // useCallback을 이용하여 onKeyDown 함수를 메모제이션합니다.
    const onKeyDown = useCallback((e) => {
        if (e.key === 'Enter') {
            onSubmit();
        }
    }, [onSubmit]); // onSubmit이 변경될 때만 함수를 호출합니다.

    return (
        (<div>
            <h3>새로운 Todo 작성하기 ✏</h3>
            <div>
                <input
                    type='text'
                    ref={(inputRef) => inputRef && inputRef.focus()}
                    placeholder="할 일을 추가로 입력해주세요."
                    onChange={(e) => setTask(e.target.value)}
                    onKeyDown={onKeyDown}
                    value={task}
                />
                <button onClick={onSubmit}>할 일 추가</button>
            </div>
        </div>)
    );
}

- React.memo

React.memo(컴포넌트)

  • React.memo는 컴포넌트를 메모제이션하여 동일한 컴포넌트를 재사용할 수 있습니다.
  • React.memo를 사용하면 컴포넌트의 props가 변경되지 않으면 렌더링을 하지 않습니다.
// src/components/TodoItem.js
(...)

// React.memo로 TodoItem 컴포넌트를 감싸기
export default React.memo(TodoItem);
티스토리 친구하기