[Next.js] Redux + TypeScript 로 TodoApp CRUD 구현하기

최자은·2023년 3월 8일
1

리덕스

목록 보기
3/4

앞선 포스트의 next.js + redux + typescript 세팅을 해보면서 redux를 좀 더 자연스럽게 익히고 연습하기 위해 간단한 todolist 프로젝트로 CRUD 기능을 구현해보았다. redux를 연습하는 것이 주 목적이었으므로 style은 tailwind css로 최소한만 적용했다.

1. ⚙️ 세팅

Next.js + Redux + TypeScript 세팅

  • 지난 포스팅에서 진행한 프로젝트 시작 전 환경 세팅

2. 📂 폴더 구조

components
	/InputForm.tsx
    /TodoList.tsx
    /TodoItem.tsx
pages
	/_app.tsx
    /index.tsx
store
	/action
    	- todos.ts
    /reducer
    	- main.ts
        - todoReducer.ts

3. 메인 페이지 구성

// pages/index.tsx

import Head from 'next/head'
import Image from 'next/image'
import { Inter } from 'next/font/google'
import styles from '@/styles/Home.module.css'
import InputForm from '@/components/InputForm'
import TodoList from '@/components/TodoList'

export default function Home() {
  return (
    <>
      <div className="flex flex-col items-center justify-center min-h-screen bg-purple-500">
        <h1 className="text-3xl bg-slate-200 w-2/5 text-center pt-4">My Todo</h1>
        <InputForm />
        <TodoList />
      </div>
    </>
  )
}

create read update delete

4. Reducer 기본 구조

import { ActionsType, ADD_TODO, DELETE_TODO, ISCOMPLETE_TODO, UPDATE_TODO } from "../actions/todos";

interface todoType {
    title: string,
    isComplete: boolean,
}

interface InitialStateType {
    todos: todoType[]
}

const initialState: InitialStateType = {
    todos: [],
}

export default function TodoReducer(state = initialState, action: ActionsType) {
    switch (action.type) {
        case ADD_TODO:
        	return 
        case DELETE_TODO:
	        return 
        case ISCOMPLETE_TODO:
	        return 
        case UPDATE_TODO:
    		return    
        default:
            return state
    }
}

5. Read 읽기 기능

  • TodoList
    - useSelector 훅을 사용해서 state를 조회한 후 가져온 todos 배열들을 map 메서드를 사용해서 나열한다.
import { useSelector } from "react-redux"
import TodoItem from "./TodoItem"

const TodoList = () => {
    const todos = useSelector(state => state.TodoReducer.todos)
    
    return (
        <div className="w-2/5 px-5 pb-4 gap-4 bg-slate-200 flex flex-col justify-around ">{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}</div>
    )
}

export default TodoList

6. Create 생성 기능

  • 액션 타입 선언
export const ADD_TODO = "ADD_TODO"
  • 액션 생성 함수
// 새로운 todo가 생성 될 때마다 주어지는 임시 id 값
let id = 1 

// 브라우저로부터 받아올 객체 형태의 todo 타입 정의
interface AddTodoType {
    title: string,
    isComplete: boolean
}

export const addTodo = (todo: AddTodoType) => {
    return {
        type: ADD_TODO,
        payload: {
            todo: {
                id: id++,
                title: todo.title,
                isComplete: todo.isComplete,
            }
        }
    }
}
  • reducer 정의
export default function TodoReducer(state = initialState, action: ActionsType) {
    switch (action.type) {
        // 1. 생성
        case ADD_TODO:
            return {
                todos: [...state.todos, action.payload.todo]
            }
        default:
            return state
    }
}
  • InputForm 컴포넌트
// components/InputForm.tsx

import { addTodo } from "@/store/actions/todos"
import { useState } from "react"
import { useDispatch } from "react-redux"

const InputForm = () => {
    const dispatch = useDispatch()
    const [text, setText] = useState('')

    const handleChange = (e) => {
        setText(e.target.value)
    }

    const handleSubmit = (e) => {
        e.preventDefault()
        const todo = {
            title: text,
            isComplete: false,
        }

        dispatch(addTodo(todo))
        console.log(todo);
        
        setText('')
    }

    return (
        <div className="w-2/5">
            <form onSubmit={handleSubmit} className="flex flex-row gap-4 bg-slate-200 p-5">
                <input type="text" value={text} onChange={handleChange} placeholder="Enter here..." className=" pl-4 p-3 border-2 border-black flex-1" />
                <button className="border-2 border-black px-3 py-2 bg-slate-500 rounded-lg text-white flex-0">추가</button>
            </form>
        </div>
    )
}

export default InputForm

7. Delete 삭제 기능

  • 액션 타입 선언
export const DELETE_TODO = "DELETE_TODO"
  • 액션 생성 함수
    - 컴포넌트에서 삭제하려고 하는 아이템의 id를 받아와서 reducer에서 처리할 것이다.
export const deleteTodo = (id: number) => {
    return {
        type: DELETE_TODO,
        payload: {
            id,
        }
    }
}
  • reducer 정의
export default function TodoReducer(state = initialState, action: ActionsType) {
    switch (action.type) {
        case ADD_TODO:
            return {
                todos: [...state.todos, action.payload.todo]
            }
        // 2. 삭제 
        case DELETE_TODO:
            return {
                todos: [...state.todos.filter((todo) => todo.id !== action.payload.id)]
            }
        default:
            return state
    }
}
  • TodoItem 컴포넌트
// components/TodoItem.tsx

import { deleteTodo } from "@/store/actions/todos"
import { useDispatch } from "react-redux"
import { MdOutlineCheckBoxOutlineBlank, MdCheckBox } from "react-icons/md"

const TodoItem = ({ todo }) => {
    const dispatch = useDispatch()
    
    const handleDelete = () => {
      	// 액션을 dispatch하여 리듀서 삭제 함수를 호출함
        dispatch(deleteTodo(todo.id))
    }

    return (
        <div className = "flex flex-row justify-between items-center">
            <span className="font-semibold ml-3">{todo.title}</span>
            <button onClick={handleDelete} className="border-2 border-black px-3 py-1 ml-1 bg-slate-500 text-white rounded-lg">삭제</button>
        </div>
    )
}

export default TodoItem

8. Update 수정 기능

  • 수정할 내용
    • todo의 title
    • 각 todo 아이템의 완료여부를 나타내는 체크박스
  • 액션 타입 선언
export const ISCOMPLETE_TODO = "ISCOMPLETE_TODO"
export const UPDATE_TODO = "UPDATE_TODO"
  • 액션 생성 함수
interface EditTodoType {
    id: number,
    title: string,
}

export const isCompleteTodo = (id: number) => {
    return {
        type: ISCOMPLETE_TODO,
        payload: {
            id, // id 값으로 todo를 조회한 후 isComplete 값을 변경할 예정
        }
    }
}

export const updateTodo = (todo: EditTodoType) => {
    return {
        type: UPDATE_TODO,
        payload: {
            todo: {
                id: todo.id, // 수정할 todo를 찾기위해 id값 필요 
                title: todo.editTitle // 수정할 title 값을 받아옴
            }
        }
    }
}

export type ActionsType =
    ReturnType<typeof addTodo> |
    ReturnType<typeof deleteTodo> |
    ReturnType<typeof isCompleteTodo> |
    ReturnType<typeof updateTodo>
  • reducer 정의
export default function TodoReducer(state = initialState, action: ActionsType) {
    switch (action.type) {
        case ADD_TODO:
            return {
                todos: [...state.todos, action.payload.todo]
            }
        case DELETE_TODO:
            return {
                todos: [...state.todos.filter((todo) => todo.id !== action.payload.id)]
            }
        // 수정 - isComplete 완료여부
        case ISCOMPLETE_TODO:
            return {
                todos: [...state.todos.map((todo) => todo.id === action.payload.id ? { ...todo, isComplete: !todo.isComplete } : todo)]
            }
        // 수정 - title 내용
        case UPDATE_TODO:
            return {
                todos: [...state.todos.map((todo) => todo.id === action.payload.todo.id ? {...todo, title: action.payload.todo.title} : todo)]
            }
        
        default:
            return state
    }
}
  • TodoItem 컴포넌트
// components/TodoItem.tsx

import { deleteTodo, isCompleteTodo, updateTodo } from "@/store/actions/todos"
import { useDispatch } from "react-redux"
import { MdOutlineCheckBoxOutlineBlank, MdCheckBox } from "react-icons/md"
import { useEffect, useRef, useState } from "react"

const TodoItem = ({ todo }) => {
    const dispatch = useDispatch()
    const [edited, setEdited] = useState(false)
    const [newText, setNewText] = useState(todo.title)
    
    // useRef()에 타입 지정 없이 null을 넣게 되면 리액트 라이프 사이클 특성상 error가 발생함 
    // useEffect를 사용하여 ref 값을 사용
    const editInputRef = useRef<any>(null)

    useEffect(() => {
       // focus 값을 찾을 수 없다고 나오는 경우 
       // if(editInputRef.current){} 로 current가 있는지 확인 후 focus 사용 가능
        if (edited && editInputRef.current) {
            editInputRef.current.focus();
        }
    }, [edited]);
    
    const handleDelete = () => {
        dispatch(deleteTodo(todo.id))
    }

    const handleCheck = () => {
        dispatch(isCompleteTodo(todo.id))
    }

    const handleEdit = () => {
        setEdited(true)
    }

    const onChangeEditInput = (e) => {
        setNewText(e.target.value);
    };

    const onClickSubmitButton = () => {
        const nextTodoList = {
            id: todo.id,
            editTitle: newText
        }
        dispatch(updateTodo(nextTodoList))
        setEdited(false)
    }

    return (
        <div className = "flex flex-row justify-between items-center" >
            <span className="flex flex-row items-center">
                <span>{todo.isComplete ? <MdCheckBox onClick={handleCheck} size="30" /> : <MdOutlineCheckBoxOutlineBlank onClick={handleCheck} size="30" />}</span>
                {edited ? <input type="text" value={newText} onChange={onChangeEditInput} ref={editInputRef} /> : <span className="font-semibold ml-3">{todo.title}</span>}
            </span>

            <span>
                {edited ?
                    <button onClick={onClickSubmitButton} className="border-2 border-black px-3 py-1 bg-slate-500 text-white rounded-lg">확인</button>
                    :
                    <button onClick={handleEdit} className="border-2 border-black px-3 py-1 bg-slate-500 text-white rounded-lg">수정</button>
                }
                <button onClick={handleDelete} className="border-2 border-black px-3 py-1 ml-1 bg-slate-500 text-white rounded-lg">삭제</button>
            </span>
        </div >
    )
}

export default TodoItem
  • 결과
    - 구현하려는 기능이 모두 정상적으로 작동하는 것을 확인할 수 있었다.

9. 후기

  • 리덕스의 사용을 계속 연습하면서 리액트만 썼을 때와의 차이를 확실히 실감할 수 있었다.
  • 리액트만 사용할 때처럼 부모에서 자식 컴포넌트에 props를 번거롭게 내려주지 않아도 되서 작업이 훨씬 간결했고 깔끔했다.
  • 아직 미들웨어 사용을 제대로 못해봤는데, 다음번에는 미들웨어와, db까지 적용해서 다시 만들어 볼 예정이다.
  • 타입스크립트도 아직은 어렵게 느껴지는데, 계속해서 연습해봐야겠다.
profile
모든 과정을 기록하며 꾸준히 성장하고 실수를 반복하지 말자 !

0개의 댓글