본문 바로가기
Front End/State management

[Redux] todoMVC에 Redux 적용하기

by 옐 FE 2022. 2. 14.

스터디를 하며 알게 된 사이트. 새로 접하는 프레임워크에 익숙해지기 위해 개발자들이 많이 만들어보는 것이 todo 앱이 아닐까 싶다. 이 웹사이트에서는 기본적으로 여러 프레임워크로 만들 수 있는 소스를 깃허브에서 제공하고 있으니 많은 도움이 될 것이다. 스터디에서 하드트레이닝을 할 때는 React + TypeScript + State management의 조합으로 여러가지를 해보았다. 이 중에서 Redux toolkit, jotai를 써보았기에 각각의 조합에 대한 과정을 기록해보려고 한다!

 

 


 

 

[React + TypeScript + Redux]

 

 

 

프론트엔드 개발자라면 한 번쯤은 들어봤을법한 상태관리 라이브러리 'Redux'. 지난 포스팅에서도 언급했었지만, 이 라이브러리를 사용하는 기본적인 원리를 이해하기 위해서 같은 강의를 3번 반복하고나서야 조금씩 그 원리에 대해 들어오기 시작했다. 면접을 볼 때 이 Redux에 관한 얘기를 들은 적이 있는데 React에서 Class 기반으로 컴포넌트를 만들 때에 더 이해하기 쉽지 않았을까 라는 얘기를 하셨는데, 그건 아마도 React에서 상태를 객체로 인식하고 사용하기 때문이 아닐까 싶다.

 

기술인터뷰를 준비하면서 나올 면접리스트 질문을 뽑고 그에 대한 답변을 찾아봤는데 '왜 state를 직접 바꾸지 않고 setState를 사용해야 하나요?' 이 질문에 대한 답변으로 'React에서 직접적으로 state를 변경해서 렌더링할 경우 업데이트한 값이 변경되어 렌더링되지 않습니다. 이는 state의 저장방식이 객체이기 때문인데 값이 변경되었다는 걸 인지하기 위해서 React는 객체로 저장된 state를 비교 연산합니다. 비교하는 판단의 근거가 객체 메모리의 참조값이기 때문에 직접 state를 변경할 경우 변경이 안된 것으로 판단하고 업데이트하여 렌더링하지 않습니다. 그렇기 때문에 새로운 객체를 만들어 값을 반환하는 setState 함수를 써야하는 것입니다.'라고 준비를 했다.

 

리액트에서 상태 변경을 setState 함수로 해야하는 이유와 리덕스에서 Action을 통해 값을 변경하는 객체를 store에 전달해주는 이유가 같다고 생각되었다. 리덕스에서 상태를 변경하는 유일한 방법은 Action 객체를 보내서 이 객체를 통해 일어날 변경을 명시한다. 그리고 이 Action이 상태가 어떻게 변경할지 명시하기 위해 Reducer를 작성하고, 이 Reducer는 기존의 stateaction을 받아서 새로운 state를 만들어내는 역할을 수행한다.

 

 

해당 프로젝트를 클론코딩하면서 task를 추가하고, 삭제하고, 수정하는 등과 같은 CRUD를 구현했지만 이 포스팅에서는 task를 추가하는 방법과 삭제하는 방법을 Redux를 이용해서 하는 방법을 포스팅해본다.

 

npm install react-redux @reduxjs/toolkit

 

// 프로젝트에서 사용한 버전

 "dependencies": {
    "@reduxjs/toolkit": "^1.7.1",
    "react-redux": "^7.2.6",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }

기본 Redux보다는 Redux toolkit에서 제공하는 기능들이 사용하기에 조금 더 편리하기에 패키지를 다운 받는다. 

 

 

 

프로젝트의 전반적인 구성

├── public
│   └── index.html
├── src
│   ├── components
│   │   ├── App.tsx
│   │   ├── Footer.tsx
│   │   ├── Header.tsx
│   │   ├── Main.tsx
│   │   ├── TodoItem.tsx
│   │   └── interface.d.ts
│   │ 
│   ├── modules
│   │   ├── actions.ts
│   │   ├── reducers.ts
│   │   └── store.ts
│   │ 
│   ├── index.tsx
│   └── style.css
│
├── .gitignore
├── tsconfig.json
├── package-lock.json
└── package.json

 

 

 

상태변경을 전달해줄 Action 객체를 만든다.

// src/modules/actions.ts 

import { v4 as uuid } from 'uuid';

const addTask = (task: string) => {
  if (task === '') return;

  const newTask = { task, completed: false, id: uuid() };

  return {
    type: 'ADD_TASK',
    payload: newTask
  };
};

const deleteTask = (taskId: string) => {
  return {
    type: 'DELETE_TASK',
    payload: { id: taskId }
  };
};

export {
  addTask,
  deleteTask,
};
  • todo 앱에서 해당 task를 식별하기 위해서 고유한 id가 필요하기 때문에 uuid 라는 라이브러리를 설치해서 사용했다.
  • addTask는 task를 받아서 새로운 newTask 객체에 전달된다. 하지만 이 때 task에 아무런 내용이 적혀있지 않으면 값을 반환하지 않도록 if 조건문으로 가드를 만들어주었다. 
  • deleteTask는 task마다 가지고 있는 고유의 id를 받아서 이를 반환한다.

Action에서는 두 가지의 값만을 return 하는 것이 원칙이다. 이 객체가 무엇을 뜻하는지 명시하는 'type'과 이 타입일 때에 무엇을 전달할 것인지 그 값을 넣는 'payload'. 만든 Action 객체를 다른 컴포넌트에서 쓸 수 있도록 export 한다.

 

 

 

그리고 이 Action을 통해 전달받은 state를 어떻게 변경할 것인지 명시하고 새로운 객체를 반환할 Reducer를 작성한다.

// src/modules/reducers.ts

import { combineReducers } from '@reduxjs/toolkit';

const initialState: ITodo[] = [
  {
    task: 'Be happy',
    id: '1234',
    completed: false
  }
];

const todoListReducer = (
  state = initialState,
  action:
     {
        type: 'ADD_TASK' | 'DELETE_TASK';
        payload: ITodo;
      }
) => {
  switch (action.type) {
    case 'ADD_TASK':
      return [action.payload, ...state];

    case 'DELETE_TASK':
      return state.filter(todo => todo.id !== action.payload.id);

    default:
      return state;
  }
};

export default combineReducers({
  todoList: todoListReducer
});
  • combineReducers는 사용할 Reducer가 여러 개 일때, 이를 단일 store에 전달해주기 위해 사용한다. 그래서 key:value의 값으로 전달해서 사용할 todoListReducer를 key인 todoList로 다른 컴포넌트에서 확인할 수 있다.
  • 기본 값 todo의 state 형태를 reducer가 받는 매개변수 state에 정해준다. 빈 배열을 전달할 수도 있겠지만, 기본적으로 어떤 값을 가지고 있는지 보여주면 좋을 것 같아서 init state를 만들어서 전달했다.
  • Reducer에서는 매개변수로 state와 action을 받는다. 그래서 if...else 구문을 사용할 수도 있지만, switch 구문을 통해서 reducer가 받은 action의 타입이 해당 case와 일치할 때 그 case에 맞는 특정한 값을 새로운 객체를 만들어서 반환한다.
  • Reducer가 받은 type이 어느 case와도 일치하지 않으면 매개변수로 받은 state를 변경하지 않고 반환한다.

 

Action의 type이 'ADD_TASK'일 때

case 'ADD_TASK':
  return [action.payload, ...state];

... 스프레드 연산자를 이용해서 새로운 배열을 반환하도록 했다. 여기서 action.payload는 action 객체에서 type이 'ADD_TASK'일 때 payload로 설정한 newTask 객체가 해당한다. newTask를 추가하고, 원래 가지고 있던 state를 늘여놓는다. 여기에서 action.payload를 먼저 넣은 이유는, 애플리케이션에서 보일 때, 나중에 만들어진 task가 원래 가지고 있던 상태 위에 얹어졌으면 해서 이렇게 반환을 했다.

 

 

Action의 type이 'DELETE_TASK'일 때

case 'DELETE_TASK':
  return state.filter(todo => todo.id !== action.payload.id);

Array.prototype.filter의 method는 값마다 돌면서 설정한 값과 일치하지 않는 값을 새로운 배열에 만들어서 반환한다. 그래서 여기에서는 'DELETE_TASK'일 때, 가지고 있는 state의 각각의 todo를 돌면서 해당 todo의 아이디와 action.payload로 만든 id가 일치하지 않는 값만을 다시 새로운 배열에 넣어 반환한다. 즉, 일치하는 id만 제외하고 새로운 상태 배열을 반환하는 것이다.

 

 

만든 Reducer를 바탕으로 Redux가 가지고 있는 단일 store와 연결시킨다.

// src/modules/store.ts

import { configureStore } from '@reduxjs/toolkit';
import todoListReducer from 'modules/reducers';

const store = configureStore({ reducer: todoListReducer });

export type RootState = ReturnType<typeof store.getState>;
export default store;
  • Redux toolkit에서 configureStore를 이용해 다음과 같이 구성한 Reducer를 넘겨주고 이를 애플리케이션에서 이용할 수 있도록 export 한다.

 

그리고 다음과 같이 Provider를 이용해 App 컴포넌트를 감싼다.

// src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from 'modules/store';
import App from 'components/App';

import 'style.css';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.querySelector('#app')
);

React와 Redux를 연결해주는 Provider로 App 컴포넌트를 감싸고 App 컴포넌트 포함해서 하위 컴포넌트들은 이제 store에 접근해서 action 객체를 통하여 상태를 변경하고 어떻게 변경할 것인지 reducer를 통해 새로운 객체를 업데이트할 준비가 완료되었다. 

 

 

 

이를 컴포넌트에 적용해서 state를 변경할 수 있는데, 새로운 task를 추가해보자.

// src/components/Header.tsx

import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { addTask } from 'modules/actions';

const Header = () => {
  const dispatch = useDispatch();
  const [task, setTask] = useState('');
  
  const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setTask(event.target.value);
  };

  const onInputKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'Enter') {
      dispatch(addTask(task));
      setTask('');
    }
  };
  
  return (
    <header className="header">
      <h1>todos</h1>
      <input
        className="new-todo"
        placeholder="What needs to be done?"
        value={task}
        onChange={onInputChange}
        onKeyUp={onInputKeyUp}
      />
    </header>
  );
};
export default Header;
  • react-redux에서 제공하는 useDispatch를 이용해서 action 객체를 전달해줘야 store에서 업데이트가 된다. useDispatch 함수를 객체 dispatch에 전달하고, 이를 action 객체를 인자로 받는다.
  • input에서 onKeyUp 이벤트가 발생했을 때, 그리고 key 중에서도 'Enter'가 눌렸다가 올라올 때 dispatch에 addTask 객체에서 input의 value인 task를 받아 업데이트해서 reducer에서는 newTask = { task, completed: false, id: uuid() }를 반환한다.

 

store에 저장되어 있는 task를 지울 때는,

// src/components/TodoItem.tsx

import React from 'react';
import { useDispatch } from 'react-redux';
import { deleteTask } from 'modules/actions';

const TodoItem = ({ id, task, completed }: ITodo) => {
  const dispatch = useDispatch();
  .
  .
  .
  const onDeleteClick = () => {
    if (confirm('삭제된 아이템은 복구되지 않습니다. 정말 삭제하시겠습니까?')) {
      dispatch(deleteTask(id));
    }
  };
  
  return (
    <li>
      <div className="view">
        .
        .
        <label>{task}</label>
        <button className="destroy" onClick={onDeleteClick}></button>
      </div>
		.
        .
    </li>
  );
};

export default TodoItem;
  • 앞에서 했던 task를 추가했을 때처럼 react-redux에서 useDispatch를 불러와서 변수 dispatch에 전달하고, 이를 action인 deleteTask를 인자로 받는다. action creator인 deleteTask특정한 id를 받아야 하기 때문에 부모 컴포넌트에서 전달받은 id를 deleteTask에 전달한다.  

댓글