[Redux-saga, JS]_TodoList

hanseungjune·2023년 3월 12일
1

비전공자의 IT준비

목록 보기
57/68
post-thumbnail

📌 현재 상황

일단 여러 에러들을 확인했고, toggle에 대한 saga와 reducer를 작성하지 않았기 때문에 발생했던 것들이 대부분이었다.

에러 명단

  • 체크박스를 클릭해도, db에서는 바뀌지 않는다.
    -> (toggle reducer, toggle saga 가 없었음)
  • 수정할 때, completed 가 없어짐(db)
    -> (edit saga에서 api를 요청할 때, <id, title, completed> 인자 모두 넘겨줘야 했었음
  • 삭제할 때, db는 없어지는데 화면에는 남아있음
    -> action.payload 자체가 id인데, action.payload.id 라고 payload에 남겨놔서 에러가 발생했던 거였다. action.payload 라고만 고치면 된다.

📌 먼저 회고 부터 하기

일단 saga를 사용하면서 생각했던 부분은 다음과 같다.

  • 이벤트를 실행할때, 어떤 순서로 진행되고 어디가 끝인지 알아야 한다.
    • 클릭이벤트 -> handle 함수 -> 특정 액션 생성함수 -> 해당 타입을 가지고 있는 saga 함수 실행 -> saga 실행 중간에 api -> put으로 액션 실행 -> 성공 시 해당 리듀서로 ( 보통 action.payload는 해당 전체 데이터이다. ) -> 화면 및 db 확인

이러한 부분을 고려하면서 코드를 작성한다면, 괜찮지 않을까 생각한다.
(사실 확답을 할수는 없다 ㅎ)

📌 작성 순서

redux-saga 작성 순서는 action -> saga -> reducer 느낌으로 작성 해보려고 한다.

css 는 항----상 마지막에!

⭐ index.js(setting)

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux'; 
import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { composeWithDevTools } from 'redux-devtools-extension';
import App from './App';
import rootReducer from './reducers/rootReducer';
import rootSaga from './sagas/todoSaga';

const sagaMiddleware = createSagaMiddleware();

const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(sagaMiddleware))
)

sagaMiddleware.run(rootSaga);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

index.js는 항상 나의 세팅이라고 생각한다.

핵심 키워드 : <Provider store={store}>, store = createStore, rootReducer, applyMiddleware, createSagaMiddleware, sagaMiddleware.run(rootSaga)

⭐ App.js(setting)

import React from "react";
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import TodoList from "./components/TodoList";
import Home from "./Home";

function App() {
  return (
    <Router>
      <nav>
        <ul>
          <li>
            <Link to='/'>Home</Link>
          </li>
          <li>
            <Link to='/todos'>Todos</Link>
          </li>
        </ul>  
      </nav>  

      <Routes>
        <Route path="/" element={<Home/>}/>
        <Route path="/todos" element={<TodoList/>}/>
      </Routes>
    </Router>
  );
}

export default App;

라우터 설정을 위해서 없는 컴포넌트를 미리 걸어 놓는 등의 작업을 해놓는것도 나쁘지 않다고 생각한다.

⭐ rootReducer(setting)

import { combineReducers } from 'redux';
import todoReducer from '../actions/todoAction';

const rootReducer = combineReducers({
  todos: todoReducer
});

export default rootReducer;

본인은 rootReducer 파일 역시 세팅의 일부라고 생각하는 편이다.
그래서 어떤 리듀서를 사용할것인지 미리 적어두고 주석을 해제하는 식으로 하는것도 좋다고 생각한다.

⭐ toDoApi

import axios from 'axios';

const API_URL = 'http://localhost:3001';

export const getTodosApi = async () => {
  const response = await axios({
    method: 'GET',
    url: `${API_URL}/todos`
  })
  return response.data;
}

export const addTodoApi = async (title) => {
  const response = await axios({
    method: 'POST',
    url: `${API_URL}/todos`,
    data: { title, completed: false }
  })
  return response.data;
};

export const editTodoApi = async (id, title, completed) => {
  const response = await axios({
    method: 'PUT',
    url: `${API_URL}/todos/${id}`,
    data: { title, completed }
  })
  return response.data;
};

export const deleteTodoApi = async (id) => {
  try {
    const response = await axios({
      method: 'DELETE',
      url: `${API_URL}/todos/${id}`
    })
    return response.data;
  } catch (error) {
    console.log(error)
  }
};

export const toggleTodoApi = async (todo) => {
  const { id, completed, title } = todo;
  try {
    const response = await axios({
      method: 'PUT',
      url: `${API_URL}/todos/${id}`,
      data: { title, completed }
    })
    return response.data
  } catch (error) {
    console.log(error)
  }
}

API 요청을 하는 함수를 저런식으로 미리 만들어 놓으면 나중에 API 요청할때 편할 것이라고 생각한다. 그래서 API부터 먼저 최대한 만들어 놓을 수 있으면 그렇게 하는게 좋다. ( 물론 json-server 설치하는게 최고 )

⭐ Components

import React from "react";

const TodoList = () => {
  return (
    <div>
      <h1>ToDo List</h1>
      <input type="text"/>
      <button></button>
      <ul>
    	/* 여기에 map을 통해서 리스트를 자동으로 가져오게 함 */
        <li>
          <input type="checkbox"/>
          <span>제목</span>
          <div>
            <span>미완료</span>
            <button>수정</button>
            <button>삭제</button>
          </div>
        </li>
      </ul>
    </div>
  );
};

export default TodoList;

일단 기본적인 컴포넌트 구조만 짜둔다. map을 사용해서 컴포넌트를 재사용하여 나타내는 부분은 간단하게 주석으로 써둔다.

⭐ Component Event → Action → Saga → Reducer

🎁 getTodos

🎈 components/TodoList.js

import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { addTodo } from "../actions/todoAction";

const TodoList = () => {
  const dispatch = useDispatch();
  const todos = useSelector((state) => state.todos.todos);
  
  // Effect Hook을 이용하여 컴포넌트가 마운트될 때에만 실행되는
  // 비동기 함수 getTodos()를 호출하도록 한다.
  useEffect(() => {
    dispatch(getTodos());
  }, [dispatch])

  return (
    <div>
      <h1>ToDo List</h1>
      <input type="text"/>
      <button></button>
      <ul>
        {/* todos 배열의 각 요소에 대해 map() 메소드를 호출하여
             <li> 요소를 생성하고, key prop을 지정한다. */}
    	{todos.map((todo) => (
          <li
            key={todo.id}
          >
            <span>{todo.title}</span>
            <div>
              {/* completed 속성이 true이면 'Completed',
                  false이면 'InComplete'를 표시한다. */}
              <span completed={todo.completed}>
                {todo.completed ? 'Completed' : 'InComplete'}
              </span>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

🎈 actions/todoAction.js

// 액션 타입 상수 선언
export const GET_TODOS = 'GET_TODOS';
export const GET_TODOS_SUCCESS = 'GET_TODOS_SUCCESS';

// getTodos 액션 생성 함수, type 필드만 가진 액션 객체 반환
export const getTodos = () => ({
  type: GET_TODOS
});

// getTodosSuccess 액션 생성 함수, payload에 todos 배열을 포함한 액션 객체 반환
export const getTodosSuccess = (todos) => ({
  type: GET_TODOS_SUCCESS,
  payload: todos,
})

// 초기 상태값 선언
const initialState = {
  todos: [],
}

// todoReducer 함수 정의, state와 action 객체를 받아 새로운 state 반환
export default function todoReducer(state=initialState, action) {
  switch (action.type) {
    // GET_TODOS_SUCCESS 액션이 들어오면 state.todos 필드를 action.payload.todos로 변경하고,
    // 나머지 필드는 그대로 유지한 새로운 객체를 반환한다.
    case GET_TODOS_SUCCESS:
      return { ...state, todos: action.payload.todos }
    // 다른 액션 타입일 경우 이전 상태를 그대로 반환한다.
    default:
      return state;
  }
}

🎈 sagas/todoSaga.js

// Import 필요한 모듈
import { takeLatest, call, put } from 'redux-saga/effects';
import { 
  GET_TODOS, // GET_TODOS action 타입
  GET_TODOS_SUCCESS, // GET_TODOS_SUCCESS action 타입
 } from '../actions/todoAction'; // todoAction에서 export한 액션 타입들 import

import { 
  getTodosApi, // getTodosApi() 함수 import
} from '../api/todoApi'; // todoApi에서 export한 getTodosApi() 함수 import

// getTodos() 함수 정의
function* getTodos() {
  try {
    const todos = yield call(getTodosApi); // 비동기 API 호출
    yield put({ type: GET_TODOS_SUCCESS, payload: { todos }}); // 성공적으로 데이터를 받아온 경우 GET_TODOS_SUCCESS action을 dispatch 함
  } catch (error) {
    console.log(error); // API 호출이 실패한 경우 error를 console에 출력
  }
}

// rootSaga() 함수 정의
export default function* rootSaga() {
  yield takeLatest(GET_TODOS, getTodos); // GET_TODOS action이 발생할 때마다 getTodos() 함수를 호출
}

🎁 AddTodo

🎈 components/TodoList.js

import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { addTodo } from "../actions/todoAction"; // todoAction에서 export한 addTodo() 액션 생성 함수 import

const TodoList = () => {
  const dispatch = useDispatch(); // useDispatch hook을 이용하여 Redux store에 dispatch할 수 있는 함수를 받아옴
  const [inputValue, setInputValue] = useState(""); // useState hook을 이용하여 input 값 상태 관리
  const todos = useSelector((state) => state.todos.todos); // useSelector hook을 이용하여 Redux store의 todos state 값을 가져옴
  
  const handleAddTodo = () => {
    if (!inputValue.trim()) { // inputValue가 공백 문자열인 경우 추가하지 않음
      return;
    }
    if (editId) { // editId가 존재하는 경우(edit 모드일 경우)
      dispatch(editTodo(editId, inputValue, completed)); // editTodo action을 dispatch하여 수정된 todo를 업데이트
      setEditId(null); // editId 값을 초기화
    } else { // edit 모드가 아닌 경우(add 모드일 경우)
      dispatch(addTodo(inputValue, completed)) // addTodo action을 dispatch하여 새로운 todo를 추가
    }
    setInputValue(''); // input 값을 초기화
  }

  useEffect(() => {
    dispatch(getTodos()); // 페이지 로드 시 useEffect hook이 실행되면서 getTodos action을 dispatch하여 초기 데이터를 가져옴
  }, [dispatch])

  return (
    <div>
      <h1>ToDo List</h1>
      <input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> {/* input 값이 변경되면 inputValue 상태를 업데이트 */}
      <button onClick={handleAddTodo}></button> {/* 추가 버튼을 누르면 handleAddTodo 함수를 호출하여 새로운 todo를 추가 */}
      <ul>
        {/* todos state 배열을 map 함수를 이용하여 리스트 형태로 렌더링 */}
    	{todos.map((todo) => (
          <li
            key={todo.id} // key 값으로 각 todo의 id를 사용하여 React가 컴포넌트를 효율적으로 렌더링할 수 있도록 함
          >
            <span>{todo.title}</span> {/* todo의 title 값을 렌더링 */}
            <div>
              <span completed={todo.completed}>
                {todo.completed ? 'Completed' : 'InComplete'} {/* todo의 completed 상태에 따라 Completed 또는 InComplete를 렌더링 */}
              </span>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

🎈 actions/todoAction.js

export const ADD_TODO = 'ADD_TODO'; // ADD_TODO action 타입 상수
export const ADD_TODO_SUCCESS = 'ADD_TODO_SUCCESS'; // ADD_TODO_SUCCESS action 타입 상수

export const addTodo = (title, completed) => ({ // addTodo action 생성 함수 정의
  type: ADD_TODO, // ADD_TODO action 타입
  payload: { title, completed } // 전달할 데이터(payload)
});

export const addTodoSuccess = (todo) => ({ // addTodoSuccess action 생성 함수 정의
  type: ADD_TODO_SUCCESS, // ADD_TODO_SUCCESS action 타입
  payload: { todo } // 전달할 데이터(payload)
});

const initialState = { // 초기 상태
  todos: [], // todos state는 빈 배열로 초기화
}

export default function todoReducer(state=initialState, action) { // todoReducer 함수 정의
  switch (action.type) {
    case ADD_TODO_SUCCESS: // ADD_TODO_SUCCESS action 발생 시
      return { ...state, todos: [ ...state.todos, action.payload.todo ]}; // 기존의 todos 배열에 새로운 todo를 추가하여 state를 업데이트하고 반환
    default:
      return state; // 기존의 state를 반환
  }
}

🎈 sagas/todoSaga.js

import { takeLatest, call, put } from 'redux-saga/effects';
import { 
  ADD_TODO, // ADD_TODO action 타입
  ADD_TODO_SUCCESS, // ADD_TODO_SUCCESS action 타입
 } from '../actions/todoAction'; // todoAction에서 export한 액션 타입들 import

import { 
  addTodoApi, // addTodoApi() 함수 import
} from '../api/todoApi'; // todoApi에서 export한 addTodoApi() 함수 import

// addTodo() 함수 정의
function* addTodo(action) {
  try {
    const todo = yield call(addTodoApi, action.payload.title); // 비동기 API 호출
    yield put({ type: ADD_TODO_SUCCESS, payload: { todo }}); // 성공적으로 데이터를 받아온 경우 ADD_TODO_SUCCESS action을 dispatch 함
  } catch (error) {
    console.log(error); // API 호출이 실패한 경우 error를 console에 출력
  }
}

// rootSaga() 함수 정의
export default function* rootSaga() {
  yield takeLatest(ADD_TODO, addTodo); // ADD_TODO action이 발생할 때마다 addTodo() 함수를 호출
}

🎁 EditTodo

🎈 components/TodoList.js

import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { editTodo } from "../actions/todoAction"; // editTodo() 액션 생성 함수 import

const TodoList = () => {
  const dispatch = useDispatch(); // useDispatch hook을 이용하여 Redux store에 dispatch할 수 있는 함수를 받아옴
  const [editId, setEditId] = useState(null); // 현재 edit 모드인 todo의 id를 저장하는 state
  const [completed, setCompleted] = useState(false); // 수정할 todo의 completed 상태를 저장하는 state
  const [inputValue, setInputValue] = useState(""); // input 값 상태 관리
  const todos = useSelector((state) => state.todos.todos); // useSelector hook을 이용하여 Redux store의 todos state 값을 가져옴
  
  const handleAddTodo = () => {
    if (!inputValue.trim()) { // inputValue가 공백 문자열인 경우 추가하지 않음
      return;
    }
    if (editId) { // editId가 존재하는 경우(edit 모드일 경우)
      dispatch(editTodo(editId, inputValue, completed)); // editTodo action을 dispatch하여 수정된 todo를 업데이트
      setEditId(null); // editId 값을 초기화
    } else { // edit 모드가 아닌 경우(add 모드일 경우)
      dispatch(addTodo(inputValue, completed)) // addTodo action을 dispatch하여 새로운 todo를 추가
    }
    setInputValue(''); // input 값을 초기화
  }

  const handleEditTodo = (id, title, completed) => { // todo 수정 모드로 변경하는 함수
    setEditId(id); // editId state를 변경하여 현재 edit 모드인 todo의 id를 저장
    setInputValue(title); // inputValue state를 변경하여 수정할 todo의 title 값을 저장
    setCompleted(completed || false); // completed state를 변경하여 수정할 todo의 completed 값을 저장
  }

  useEffect(() => {
    dispatch(getTodos()); // 페이지 로드 시 useEffect hook이 실행되면서 getTodos action을 dispatch하여 초기 데이터를 가져옴
  }, [dispatch])

  return (
    <div>
      <h1>ToDo List</h1>
      <input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> {/* input 값이 변경되면 inputValue 상태를 업데이트 */}
      <button onClick={handleAddTodo}></button> {/* 추가 버튼을 누르면 handleAddTodo 함수를 호출하여 새로운 todo를 추가 */}
      <ul>
        {/* todos state 배열을 map 함수를 이용하여 리스트 형태로 렌더링 */}
    	{todos.map((todo) => (
          <li
            key={todo.id} // key 값으로 각 todo의 id를 사용하여 React가 컴포넌트를 효율적으로 렌더링할 수 있도록 함
          >
            <span>{todo.title}</span> {/* todo의 title 값을 렌더링 */}
            <div>
              <span completed={todo.completed}>
                {todo.completed ? 'Completed' : 'InComplete'} {/* todo의 completed 상태에 따라 Completed 또는 InComplete를 렌더링 */}
			  </span>
              <button onClick={() => handleEditTodo(todo.id, todo.title, todo.completed)}>수정</button> {/* 수정 버튼을 누르면 handleEditTodo 함수를 호출하여 현재 todo를 수정 모드로 변경 */}
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

🎈 actions/todoAction.js

export const EDIT_TODO = 'EDIT_TODO';
export const EDIT_TODO_SUCCESS = 'EDIT_TODO_SUCCESS';

export const editTodo = (id, title, completed) => ({ // editTodo 액션 생성 함수
  type: EDIT_TODO,
  payload: { id, title, completed }
});

export const editTodoSuccess = (todo) => ({ // editTodoSuccess 액션 생성 함수
  type: EDIT_TODO_SUCCESS,
  payload: { todo },
});

const initialState = { // 초기 상태 정의
  todos: [],
}

export default function todoReducer(state=initialState, action) {
  switch (action.type) {
    case EDIT_TODO_SUCCESS:
      return { // todo 배열을 업데이트하기 위해 map 함수를 이용하여 기존 todo 배열을 순회하면서 해당 todo를 찾아 업데이트
        ...state,
        todos: state.todos.map((todo) => 
          todo.id === action.payload.todo.id ? action.payload.todo : todo,
        ),
      };
    default:
      return state;
  }
}

🎈 sagas/todoSaga.js

import { takeLatest, call, put } from 'redux-saga/effects';
import { 
  EDIT_TODO, 
  EDIT_TODO_SUCCESS, 
 } from '../actions/todoAction';

import { 
  editTodoApi, 
} from '../api/todoApi';

function* editTodo(action) { // editTodo saga 함수 정의
  try {
    const todo = yield call(editTodoApi, action.payload.id, action.payload.title, action.payload.completed); // editTodoApi 함수를 호출하여 수정된 todo 객체를 가져옴
    yield put({ type: EDIT_TODO_SUCCESS, payload: { todo }}); // EDIT_TODO_SUCCESS 액션을 dispatch하여 수정된 todo 객체를 store에 저장
  } catch (error) {
    console.log(error);
  }
}

export default function* rootSaga() { // rootSaga 함수 정의
  yield takeLatest(EDIT_TODO, editTodo); // EDIT_TODO 액션이 발생할 때마다 editTodo saga 함수가 실행되도록 설정
}

🎁 DeleteTodo

🎈 components/TodoList.js

import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { deleteTodo } from "../actions/todoAction";

const TodoList = () => {
  const dispatch = useDispatch(); // useDispatch hook을 이용하여 store의 dispatch 함수를 가져옴
  const todos = useSelector((state) => state.todos.todos); // useSelector hook을 이용하여 store의 todos 상태 값을 가져옴
  
  const handleDeleteTodo = (id) => { // todo 삭제 함수
    dispatch(deleteTodo(id)); // deleteTodo action을 dispatch하여 해당 todo 객체를 삭제
  }

  useEffect(() => { // todos 상태가 변경될 때마다 getTodos action을 dispatch하여 최신 todos 상태를 가져옴
    dispatch(getTodos());
  }, [dispatch])

  return (
    <div>
      <h1>ToDo List</h1>
      <input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> {/* input 값이 변경되면 inputValue 상태를 업데이트 */}
      <button onClick={handleAddTodo}></button> {/* 추가 버튼을 누르면 handleAddTodo 함수를 호출하여 새로운 todo를 추가 */}
      <ul>
        {/* todos state 배열을 map 함수를 이용하여 리스트 형태로 렌더링 */}
    	{todos.map((todo) => (
          <li
            key={todo.id} // key 값으로 각 todo의 id를 사용하여 React가 컴포넌트를 효율적으로 렌더링할 수 있도록 함
          >
            <span>{todo.title}</span> {/* todo의 title 값을 렌더링 */}
            <div>
              <span completed={todo.completed}>
                {todo.completed ? 'Completed' : 'InComplete'} {/* todo의 completed 상태에 따라 Completed 또는 InComplete를 렌더링 */}
			  </span>
              <button onClick={() => handleEditTodo(todo.id, todo.title, todo.completed)}>수정</button> {/* 수정 버튼을 누르면 handleEditTodo 함수를 호출하여 현재 todo를 수정 모드로 변경 */}
		      <button onClick={() => handleDeleteTodo(todo.id)}>삭제</button> {/* 삭제 버튼을 누르면 handleDeleteTodo 함수를 호출하여 현재 todo를 삭제 */}
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

🎈 actions/todoAction.js

export const DELETE_TODO = 'DELETE_TODO';
export const DELETE_TODO_SUCCESS = 'DELETE_TODO_SUCCESS';

export const deleteTodo = (id) => ({ // todo 삭제 action 생성자 함수
  type: DELETE_TODO,
  payload: id
});

export const deleteTodoSuccess = (id) => ({ // todo 삭제 성공 action 생성자 함수
  type: DELETE_TODO_SUCCESS,
  payload: id
})

const initialState = {
  todos: [],
}

export default function todoReducer(state=initialState, action) { // todoReducer 함수 정의
  switch (action.type) {
    case DELETE_TODO_SUCCESS: // DELETE_TODO_SUCCESS action 처리
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id), // 삭제된 todo 객체를 제외한 todos 배열을 반환
      };
    default:
      return state;
  }
}

🎈 sagas/todoSaga.js

import { takeLatest, call, put } from 'redux-saga/effects';
import { 
  DELETE_TODO,
  DELETE_TODO_SUCCESS,
 } from '../actions/todoAction';

import { 
  deleteTodoApi,
} from '../api/todoApi';

function* deleteTodo(action) { // deleteTodo saga 함수 정의
  try {
    yield call(deleteTodoApi, action.payload); // deleteTodoApi 함수 호출하여 해당 id를 가진 todo 객체 삭제
    yield put({ type: DELETE_TODO_SUCCESS, payload: { id: action.payload }}); // DELETE_TODO_SUCCESS action dispatch
  } catch (error) {
    console.log(error);
  }
}

export default function* rootSaga() { // rootSaga 함수 정의
  yield takeLatest(DELETE_TODO, deleteTodo); // DELETE_TODO action이 dispatch 될 때, deleteTodo saga 함수 호출
}

🎁 ToggleTodo

🎈 components/TodoList.js

import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { toggleTodo } from "../actions/todoAction";

const TodoList = () => {
  const dispatch = useDispatch(); // useDispatch 훅으로 dispatch 함수 사용
  const [editId, setEditId] = useState(null); // 수정할 todo의 id 값을 저장하는 상태
  const [completed, setCompleted] = useState(false); // 수정할 todo의 completed 값을 저장하는 상태
  const [inputValue, setInputValue] = useState(""); // 새로운 todo의 title 값을 저장하는 상태
  const todos = useSelector((state) => state.todos.todos); // todos state를 useSelector 훅을 이용하여 가져옴
  
  const handleToggleTodo = (todo) => { // todo의 completed 값을 토글하는 함수
    dispatch(toggleTodo(todo.id, !todo.completed, todo.title))
  }

  useEffect(() => { // 컴포넌트가 렌더링된 후에 todos 데이터를 불러오는 효과를 내기 위해 useEffect 훅 사용
    dispatch(getTodos());
  }, [dispatch])

  return (
    <div>
      <h1>ToDo List</h1>
      <input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> {/* 새로운 todo의 title 값을 저장하는 input */}
      <button onClick={handleAddTodo}>{editId ? '수정' : "추가"}</button> {/* 새로운 todo를 추가 또는 수정 버튼으로 변경 */}
      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
          >
            <input type="checkbox" checked={todo.completed} onChange={() => handleToggleTodo(todo)} /> {/* todo의 completed 값을 변경하는 checkbox */}
            <span>{todo.title}</span> {/* todo의 title 값을 렌더링 */}
            <div>
              <span completed={todo.completed}>
                {todo.completed ? 'Completed' : 'InComplete'} {/* todo의 completed 상태에 따라 Completed 또는 InComplete를 렌더링 */}
              </span>
              <button onClick={() => handleEditTodo(todo.id, todo.title, todo.completed)}>수정</button> {/* 수정 버튼을 누르면 handleEditTodo 함수를 호출하여 현재 todo를 수정 모드로 변경 */}
		      <button onClick={() => handleDeleteTodo(todo.id)}>삭제</button> {/* 삭제 버튼을 누르면 handleDeleteTodo 함수를 호출하여 현재 todo를 삭제 */}
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

🎈 actions/todoAction.js

export const TOGGLE_TODO = 'TOGGLE_TODO';
export const TOGGLE_TODO_SUCCESS = 'TOGGLE_TODO_SUCCESS';

export const toggleTodo = (id, completed, title) => ({  // toggleTodo action creator 함수 정의
  type: TOGGLE_TODO,  // action 객체의 type 값
  payload: { id, completed, title }  // action 객체의 payload 값
});

export const toggleTodoSuccess = (todo) => ({  // toggleTodoSuccess action creator 함수 정의
  type: TOGGLE_TODO_SUCCESS,  // action 객체의 type 값
  payload: { todo }  // action 객체의 payload 값
});

const initialState = {  // 초기 상태 정의
  todos: [],  // todos 배열
}

export default function todoReducer(state=initialState, action) {  // todoReducer 정의
  switch (action.type) {
    case TOGGLE_TODO_SUCCESS:  // TOGGLE_TODO_SUCCESS case
      return {
        ...state,  // 기존 state 객체를 전개 연산자를 이용하여 복사
        todos: state.todos.map((todo) => todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo),  // todos 배열을 전개 연산자를 이용하여 복사하고, map 함수를 이용하여 todo.id가 action.payload.id와 같으면 completed 값을 반전시켜서 새로운 todo 객체를 생성하고, 그렇지 않으면 기존 todo 객체를 반환하여 배열을 업데이트
      };
    default:
      return state;  // 기존 상태를 반환
  }
}

🎈 sagas/todoSaga.js

import { takeLatest, call, put } from 'redux-saga/effects';
import { 
  TOGGLE_TODO, // 할 일의 완료/미완료 상태를 전환하는 액션
  TOGGLE_TODO_SUCCESS, // 할 일의 완료/미완료 상태를 전환하는 액션의 성공 상태

 } from '../actions/todoAction';

import { 
  toggleTodoApi // 할 일의 완료/미완료 상태를 전환하는 API 함수
} from '../api/todoApi';

function* toggleTodo(action) {
  try {
    // API를 호출하여 todo 항목의 id, title, completed를 반환받음
    const { title, id, completed} = yield call(toggleTodoApi, action.payload);
    // TOGGLE_TODO_SUCCESS 액션을 dispatch 함
    yield put({ type: TOGGLE_TODO_SUCCESS, payload: { title, id, completed }})
  } catch (error) {
    console.log(error)
  }
}

// TOGGLE_TODO 액션을 감시하고, 액션이 발생하면 toggleTodo 함수를 실행함
export default function* rootSaga() {
  yield takeLatest(TOGGLE_TODO, toggleTodo);
}

⭐ Emotion.js

import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, getTodos, editTodo, deleteTodo, toggleTodo } from "../actions/todoAction";
import styled from '@emotion/styled';

const Container = styled.div`
  margin: 0 auto;
  max-width: 600px;
`;

const Title = styled.h1`
  font-size: 2rem;
  text-align: center;
  margin-top: 2rem;
  margin-bottom: 2rem;
`;

const Input = styled.input`
  font-size: 1rem;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 3px;
  width: 100%;
  margin-bottom: 1rem;
`;

const Button = styled.button`
  font-size: 1rem;
  padding: 0.5rem 1rem;
  background-color: #007bff;
  color: #fff;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  &:hover {
    background-color: #0062cc;
  }
`

const TodoItemContainer = styled.ul`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding-left: 1rem;
`

const TodoItem = styled.li`
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.5rem 1rem;
  border: 1px solid #ccc;
  border-radius: 3px;
  margin-bottom: 0.5rem;
`;

const TodoTitle = styled.span`
  font-size: 1rem;
`;

const TodoStatus = styled.span`
  font-size: 1rem;
  font-weight: bold;
  color: ${(props) => (props.completed ? '#28a745' : '#dc3545')};
`;

const EditButton = styled.button`
  font-size: 1rem;
  padding: 0.5rem 1rem;
  background-color: #ffc107;
  color: #212529;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  &:hover {
    background-color: #e0a800;
  }
`;

const DeleteButton = styled.button`
  font-size: 1rem;
  padding: 0.5rem 1rem;
  background-color: #dc3545;
  color: #fff;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  &:hover {
    background-color: #c82333;
  }
`;

const CheckBox = styled.input`
  margin-right: 8px;
`

const TodoList = () => {
  const dispatch = useDispatch();
  const [editId, setEditId] = useState(null);
  const [completed, setCompleted] = useState(false);
  const [inputValue, setInputValue] = useState("");
  const todos = useSelector((state) => state.todos.todos);
  
  const handleAddTodo = () => {
    if (!inputValue.trim()) {
      return;
    }
    if (editId) {
      dispatch(editTodo(editId, inputValue, completed));
      setEditId(null);
    } else {
      dispatch(addTodo(inputValue, completed))
    }
    setInputValue('');
  }

  const handleEditTodo = (id, title, completed) => {
    setEditId(id);
    setInputValue(title);
    setCompleted(completed || false);
  }

  const handleToggleTodo = (todo) => {
    dispatch(toggleTodo(todo.id, !todo.completed, todo.title))
  }

  const handleDeleteTodo = (id) => {
    dispatch(deleteTodo(id));
  }

  useEffect(() => {
    dispatch(getTodos());
  }, [dispatch])

  return (
    <Container>
      <Title>ToDo List</Title>
      <Input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
      <Button onClick={handleAddTodo}>{editId ? '수정' : "추가"}</Button>
      <TodoItemContainer>
        {todos.map((todo) => (
          <TodoItem
            key={todo.id}
          >
            <CheckBox type="checkbox" checked={todo.completed} onChange={() => handleToggleTodo(todo)} />
            <TodoTitle>{todo.title}</TodoTitle>
            <div>
              <TodoStatus completed={todo.completed}>
                {todo.completed ? 'Completed' : 'InComplete'}
              </TodoStatus>
              <EditButton onClick={() => handleEditTodo(todo.id, todo.title, todo.completed)}>수정</EditButton>
              <DeleteButton onClick={() => handleDeleteTodo(todo.id)}>삭제</DeleteButton>
            </div>
          </TodoItem>
        ))}
      </TodoItemContainer>
    </Container>
  );
};

export default TodoList;

📌 최종 결과 코드

⭐ components/TodoList.js

import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, getTodos, editTodo, deleteTodo, toggleTodo } from "../actions/todoAction";
import styled from '@emotion/styled';

// styled-components로 만든 컴포넌트들입니다.
const Container = styled.div`
  margin: 0 auto;
  max-width: 600px;
`;

const Title = styled.h1`
  font-size: 2rem;
  text-align: center;
  margin-top: 2rem;
  margin-bottom: 2rem;
`;

const Input = styled.input`
  font-size: 1rem;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 3px;
  width: 100%;
  margin-bottom: 1rem;
`;

const Button = styled.button`
  font-size: 1rem;
  padding: 0.5rem 1rem;
  background-color: #007bff;
  color: #fff;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  &:hover {
    background-color: #0062cc;
  }
`

const TodoItemContainer = styled.ul`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding-left: 1rem;
`

const TodoItem = styled.li`
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.5rem 1rem;
  border: 1px solid #ccc;
  border-radius: 3px;
  margin-bottom: 0.5rem;
`;

const TodoTitle = styled.span`
  font-size: 1rem;
`;

const TodoStatus = styled.span`
  font-size: 1rem;
  font-weight: bold;
  color: ${(props) => (props.completed ? '#28a745' : '#dc3545')};
`;

const EditButton = styled.button`
  font-size: 1rem;
  padding: 0.5rem 1rem;
  background-color: #ffc107;
  color: #212529;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  &:hover {
    background-color: #e0a800;
  }
`;

const DeleteButton = styled.button`
  font-size: 1rem;
  padding: 0.5rem 1rem;
  background-color: #dc3545;
  color: #fff;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  &:hover {
    background-color: #c82333;
  }
`;

const CheckBox = styled.input`
  margin-right: 8px;
`

const TodoList = () => {
  const dispatch = useDispatch();
  const [editId, setEditId] = useState(null); // 수정할 todo의 id를 저장할 상태
  const [completed, setCompleted] = useState(false); // 수정할 todo의 completed 상태를 저장할 상태
  const [inputValue, setInputValue] = useState(""); // 새로운 todo를 입력할 때 사용자의 입력 값을 저장하는 상태 변수

const todos = useSelector((state) => state.todos.todos); // store에서 todos 배열을 가져와서 사용하는 hook

const handleAddTodo = () => { // '추가' 버튼 클릭 시 실행되는 함수
  if (!inputValue.trim()) { // 입력 값이 없으면 함수를 종료
    return;
  }
  if (editId) { // editId 값이 있으면 수정 모드로 판단
    dispatch(editTodo(editId, inputValue, completed)); // editTodo action을 dispatch하고, 기존 todo의 id, 수정된 title과 completed 값을 전달
    setEditId(null); // editId를 초기화하여 추가 모드로 변경
  } else {
    dispatch(addTodo(inputValue, completed)) // addTodo action을 dispatch하고, 새로운 todo의 title과 completed 값을 전달
  }
  setInputValue(''); // 입력 값을 초기화
}

const handleEditTodo = (id, title, completed) => { // '수정' 버튼 클릭 시 실행되는 함수
  setEditId(id); // 현재 수정하고 있는 todo의 id 값을 상태 변수에 저장
  setInputValue(title); // 수정하고 있는 todo의 title 값을 input 상태 값에 설정하여 수정할 수 있도록 함
  setCompleted(completed || false); // 수정하고 있는 todo의 completed 값을 상태 변수에 저장하여 수정할 수 있도록 함
}

const handleToggleTodo = (todo) => { // todo의 completed 값을 변경하는 함수
  dispatch(toggleTodo(todo.id, !todo.completed, todo.title)) // toggleTodo action을 dispatch하고, todo의 id와 반전된 completed 값을 전달
}

const handleDeleteTodo = (id) => { // '삭제' 버튼 클릭 시 실행되는 함수
  dispatch(deleteTodo(id)); // deleteTodo action을 dispatch하고, todo의 id 값을 전달
}

useEffect(() => { // 컴포넌트가 렌더링될 때 실행되는 useEffect hook
  dispatch(getTodos()); // getTodos action을 dispatch하여 초기에 todos 값을 가져옴
}, [dispatch])

return (
    <Container>
      <Title>ToDo List</Title>
      <Input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
      <Button onClick={handleAddTodo}>{editId ? '수정' : "추가"}</Button>
      <TodoItemContainer>
        {todos.map((todo) => (
          <TodoItem
            key={todo.id}
          >
            <CheckBox type="checkbox" checked={todo.completed} onChange={() => handleToggleTodo(todo)} />
            <TodoTitle>{todo.title}</TodoTitle>
            <div>
              <TodoStatus completed={todo.completed}>
                {todo.completed ? 'Completed' : 'InComplete'}
              </TodoStatus>
              <EditButton onClick={() => handleEditTodo(todo.id, todo.title, todo.completed)}>수정</EditButton>
              <DeleteButton onClick={() => handleDeleteTodo(todo.id)}>삭제</DeleteButton>
            </div>
          </TodoItem>
        ))}
      </TodoItemContainer>
    </Container>
  );
};

export default TodoList;

⭐ actions/todoAction.js

// 액션 타입 상수 선언
export const GET_TODOS = 'GET_TODOS';
export const GET_TODOS_SUCCESS = 'GET_TODOS_SUCCESS';
export const ADD_TODO = 'ADD_TODO';
export const ADD_TODO_SUCCESS = 'ADD_TODO_SUCCESS';
export const EDIT_TODO = 'EDIT_TODO';
export const EDIT_TODO_SUCCESS = 'EDIT_TODO_SUCCESS';
export const DELETE_TODO = 'DELETE_TODO';
export const DELETE_TODO_SUCCESS = 'DELETE_TODO_SUCCESS';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const TOGGLE_TODO_SUCCESS = 'TOGGLE_TODO_SUCCESS';

// 액션 생성자 함수 정의
export const getTodos = () => ({
  type: GET_TODOS
});

export const getTodosSuccess = (todos) => ({
  type: GET_TODOS_SUCCESS,
  payload: todos,
})

export const addTodo = (title, completed) => ({
  type: ADD_TODO,
  payload: { title, completed }
});

export const addTodoSuccess = (todo) => ({
  type: ADD_TODO_SUCCESS,
  payload: { todo }
});

export const editTodo = (id, title, completed) => ({
  type: EDIT_TODO,
  payload: { id, title, completed }
});

export const editTodoSuccess = (todo) => (
  {
  type: EDIT_TODO_SUCCESS,
  payload: { todo },
});

export const deleteTodo = (id) => ({ 
  type: DELETE_TODO,
  payload: id
});

export const deleteTodoSuccess = (id) => ({
  type: DELETE_TODO_SUCCESS,
  payload: id
})

export const toggleTodo = (id, completed, title) => ({
  type: TOGGLE_TODO,
  payload: { id, completed, title } 
});

export const toggleTodoSuccess = (todo) => ({
  type: TOGGLE_TODO_SUCCESS,
  payload: { todo } 
});

// 초기 상태 정의
const initialState = {
  todos: [],
}

// 리듀서 함수 정의
export default function todoReducer(state=initialState, action) {
  switch (action.type) {
    case GET_TODOS_SUCCESS:
      return { ...state, todos: action.payload.todos } // 받아온 todo 리스트로 상태 업데이트
    case ADD_TODO_SUCCESS:
      return { ...state, todos: [ ...state.todos, action.payload.todo ]}; // 추가된 todo를 리스트에 추가하여 상태 업데이트
    case EDIT_TODO_SUCCESS:
      return {
        ...state,
        todos: state.todos.map((todo) => 
          todo.id === action.payload.todo.id ? action.payload.todo : todo, // 수정된 todo를 포함하여 리스트를 업데이트
        ),
      };
    case DELETE_TODO_SUCCESS:
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id), // 삭제된 todo를 제외한 리스트를 상태 업데이트
      };
    case TOGGLE_TODO_SUCCESS:
      return {
        ...state,
        todos: state.todos.map((todo) => todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo), // 토글된 todo를 포함하여 리스트를 업데이트
      };
    default:
      return state; // 이외의 액션 타입에 대해서는 초기 상태 반환
  }
}

⭐ sagas/todoSaga.js

import { takeLatest, call, put } from 'redux-saga/effects';
import { 
  GET_TODOS, 
  GET_TODOS_SUCCESS, 
  ADD_TODO, 
  ADD_TODO_SUCCESS, 
  EDIT_TODO, 
  EDIT_TODO_SUCCESS, 
  DELETE_TODO,
  DELETE_TODO_SUCCESS,
  TOGGLE_TODO,
  TOGGLE_TODO_SUCCESS,
 } from '../actions/todoAction';

import { 
  getTodosApi, 
  addTodoApi, 
  editTodoApi, 
  deleteTodoApi,
  toggleTodoApi
} from '../api/todoApi';

// GET_TODOS 액션에 대한 비동기 API 콜을 처리하는 제너레이터 함수
function* getTodos() {
  try {
    // getTodosApi 비동기 함수 호출
    const todos = yield call(getTodosApi);
    // GET_TODOS_SUCCESS 액션 디스패치
    yield put({ type: GET_TODOS_SUCCESS, payload: { todos }});
  } catch (error) {
    console.log(error);
  }
}

// ADD_TODO 액션에 대한 비동기 API 콜을 처리하는 제너레이터 함수
function* addTodo(action) {
  try {
    // addTodoApi 비동기 함수 호출
    const todo = yield call(addTodoApi, action.payload.title);
    // ADD_TODO_SUCCESS 액션 디스패치
    yield put({ type: ADD_TODO_SUCCESS, payload: { todo }});
  } catch (error) {
    console.log(error);
  }
}

// EDIT_TODO 액션에 대한 비동기 API 콜을 처리하는 제너레이터 함수
function* editTodo(action) {
  try {
    // editTodoApi 비동기 함수 호출
    const todo = yield call(editTodoApi, action.payload.id, action.payload.title, action.payload.completed);
    // EDIT_TODO_SUCCESS 액션 디스패치
    yield put({ type: EDIT_TODO_SUCCESS, payload: { todo }});
  } catch (error) {
    console.log(error);
  }
}

// DELETE_TODO 액션에 대한 비동기 API 콜을 처리하는 제너레이터 함수
function* deleteTodo(action) {
  try {
    // deleteTodoApi 비동기 함수 호출
    yield call(deleteTodoApi, action.payload);
    // DELETE_TODO_SUCCESS 액션 디스패치
    yield put({ type: DELETE_TODO_SUCCESS, payload: { id: action.payload }});
  } catch (error) {
    console.log(error);
  }
}

// TOGGLE_TODO 액션에 대한 비동기 API 콜을 처리하는 제너레이터 함수
function* toggleTodo(action) {
  try {
    // toggleTodoApi 비동기 함수 호출
    const { title, id, completed} = yield call(toggleTodoApi, action.payload);
    // TOGGLE_TODO_SUCCESS 액션 디스패치
    yield put({ type: TOGGLE_TODO_SUCCESS, payload: { title, id, completed }})
  } catch (error) {
    console.log(error)
  }
}

// rootSaga 제너레이터 함수, 각각의 액션에 대한 제너레이터 함수 실행
export default function* rootSaga() {
  yield takeLatest(GET_TODOS, getTodos);
  yield takeLatest(ADD_TODO, addTodo);
  yield takeLatest(EDIT_TODO, editTodo);
  yield takeLatest(DELETE_TODO, deleteTodo);
  yield takeLatest(TOGGLE_TODO, toggleTodo);
}

이렇게 Redux-saga로 TodoList를 만들어봤다. 사실 다음 프로젝트에서 타입스크립트 기반의 Redux-saga를 이용해서 로그인 및 회원가입 기능을 3일 만에 구현해야한다. 하지만 Redux-saga 의 구조 자체를 몰랐기 때문에 이렇게 작업을 했다.

이제는 typescript 기반의 Redux-saga를 도전해야할 차례이다. 한동안 프로젝트에 집중한다고 블로그를 게시할 수 있을지 모르겠다. 하지만 이게 다 나의 포트폴리오라고 생각하고 쓰려고 노력해야겠다!

profile
필요하다면 공부하는 개발자, 한승준

0개의 댓글