티스토리 뷰

프로그래밍/React

[Redux 기초 공부]

우징어🦑 2022. 8. 4. 19:58

Introduction

결론부터 말하자면, 아래와 같은 이유들로 상태관리 라이브러리에 대해 반드시 공부해야겠다고 생각했다.

1. 여러 개의 component에 props를 반복 전달하는 것이 복잡하고 귀찮기 때문
2. context API보다 전역 관리 변수를 효율적으로 관리할 수 있다.
3. (이건 공부하다가 알게 된 것인데) subscribe로 변경사항이 생긴 컴포넌트만 리렌더링할 수 있다.

 

- Redux가 필요한 이유

내가 생각하기에 Redux가 필요한 이유를 가장 간단하게 잘 설명하는 영상이다.

 

React Hooks로 충분히 커버가 가능하다고 생각했던 나의 어리석음을 바로잡아 주었던 글.

React Hooks와 Redux에 대한 개념을 바로잡기에 좋은 글이다.

 

[번역] React Hooks가 Redux를 대체할 수 있냐고 물어보지 마세요

이 글은 Max González가 작성한 Stop Asking if React Hooks Replace Redux를 번역한 글입니다. 글을 그대로 직역하기 보다는 좀 더 전달이 명확할 것 같다고 생각한 뉘앙스를 첨가하여 번역을 진행했습니다.

delivan.dev

 

위 글을 읽고 나서 React Hooks와 Redux를 비교하는 것은 무의미한 물음이라는 걸 깨달았다.

 

다만 지금껏 사용한 Context API로도 전역 변수를 관리하기에 충분하다고 생각하고 있어서 Context API 와 Redux를 비교하고 싶었는데 마침 좋은 글을 발견했다.

 

Context API가 존재하지만 여전히 사람들이 redux와 전역 상태관리 라이브러리를 쓰는 이유

context api는 글로벌 상태관리 라이브러리를 대체할 수 없고, 여전히 많은 리액트 개발자들이 redux, mobx 등을 사용하고 있다.

yrnana.dev

위 글에서 Context API의 단점으로 꼽는 것이,

 

- Context.Provider는 value로 저장된 값이 변경되면 useContext(Context)를 사용하는 컴포넌트도 같이 렌더링을 한다. 
- 컨텍스트를 추가할 때마다 프로바이더로 매번 감싸줘야하기 때문에 Provider hell을 야기할 수 있다.

 

이렇게 인데, 특히 두번째 이유는 너무 공감이 되었다. 첫번째 이유 또한 비효율성을 야기하기 때문에 서비스의 효율성을 위해 필요한 경우 상태 관리 라이브러리를 사용해야 된다고 생각했다.

 

 

 

#1. Pure Redux - counter

Vanilla JS 를 이용한 간단한 counter 기능으로 Redux 기본 기능 익히기

Redux는 React에서만 사용할 수 있는 라이브러리가 아니다.

우선 개념을 확실히 잡기 위해 바닐라 JS에서 practice를 했다.

 

버튼을 통해 count를 +1 -1 조정하는 기능이다.

 

HTML

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    
    <title>Vanilla Redux</title>
  </head>
  <body>
    <button id="add">Add</button>
    <span>0</span>
    <button id="minus">Minus</button>
  </body>
</html>

 

JS

import { createStore } from "redux";

const add = document.getElementById("add");
const minus = document.getElementById("minus");
const number = document.querySelector("span");

const ADD = "ADD";
const MINUS = "MINUS";
const countModifier = (count = 0, action) => {
  switch (action.type) {
    case ADD:
      return count + 1
    case MINUS:
      return count - 1
    default:
      return count;
  }
};

const countStore = createStore(countModifier);

const onChange = () => {
  number.innerText = countStore.getState()
}

countStore.subscribe(onChange);


const handleAdd = () => {
  countStore.dispatch({ type: ADD })
}

const handleMinus = () => {
  countStore.dispatch({ type: MINUS })
}

add.addEventListener("click", handleAdd)
minus.addEventListener("click", handleMinus)

 

const countStore = createStore(countModifier);

createStore를 이용하여 state를 선언해준다. createStore의 인자로는 reducer를 넣어준다.

const countModifier = (count = 0, action) => {
  switch (action.type) {
    case ADD:
      return count + 1
    case MINUS:
      return count - 1
    default:
      return count;
  }
};

reducer : state를 modify하는 function

countStore.subscribe(onChange);

const onChange = () => {
  number.innerText = countStore.getState()
}

subscribe: state가 변경될 때마다 함수를 호출한다.

여기서는 count 숫자를 변경해주고 HTML에 찍어주는 함수를 호출하여 count가 바뀔 때마다 화면에 찍힌다.

getState() : state의 값을 get해온다.

const handleAdd = () => {
  countStore.dispatch({ type: ADD })
}

const handleMinus = () => {
  countStore.dispatch({ type: MINUS })
}

add.addEventListener("click", handleAdd)
minus.addEventListener("click", handleMinus)

dispatch를 통해 state를 변경한다.

const ADD = "ADD";
const MINUS = "MINUS";

dispatch 할 때 오타를 방지하기 위함이다.

dispatch({ type: "ADD" }) 이와 같이 문자열로 보내면 dispatch({ type: "ADDD" }) 이렇게 오타를 내도 에러가 나지 않는다. 

이를 방지하기 위한 코드.

 

 

 

#2. Pure Redux - To Do List

Vanilla JS 를 이용한 To Do List

 

* Don't mutate State *

객체를 절대 직접 수정하지 말아라

array를 ADD / DELETE할 때 객체를 직접 수정하지 말고, 기존 객체로부터 수정사항을 반영한 새로운 객체를 return 해야 한다.

[ADD] push -> spread 문법을 이용한 object 추가
[DELETE] splice -> filter

 

HTML

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    
    <title>Vanilla Redux</title>
  </head>
  <body>
    <h1>To Do List</h1>
    <form>
        <input type="text" placeholder="wrtie to do" />
        <button>Add</button>
        <ul></ul>
    </form>
  </body>
</html>

input에 todo 내용 입력 후 button을 누르면 아래 ul태그 아래에 li 태그들이 추가되는 형식

 

JS

import { createStore } from "redux";

// TO DO List

const form = document.querySelector("form");
const input = document.querySelector("input");
const ul = document.querySelector("ul");


const ADD_TODO = "ADD_TODO";
const DELETE_TODO = "DELETE_TODO";

const addToDo = text => {
  return {
    type: ADD_TODO,
    text
  }
}

const deleteToDo = id => {
  return {
    type: DELETE_TODO,
    id
  }
}

const reducer = (state = [], action) => {
  console.log(action);
  switch (action.type) {
    case ADD_TODO:
      const newToDoObj = { text: action.text, id: Date.now() };
      // return [...state, { text: action.text }]; // 새로 추가되는 todo가 뒤에 위치
      return [newToDoObj, ...state]; // 새로 추가되는 todo가 앞에 위치
    case DELETE_TODO:
      const cleaned = state.filter(toDo => toDo.id !== parseInt(action.id));
      return cleaned;
    default:
      return state;
  }
};


const store = createStore(reducer);

const paintToDos = () => {
  const toDos = store.getState();
  ul.innerHTML = '';
  toDos.forEach(toDo => {
    const li = document.createElement("li");
    const btn = document.createElement("button");
    btn.innerText = "DEL";
    btn.addEventListener("click", dispatchDeleteTodo);
    li.id = toDo.id;
    li.innerText = toDo.text;
    li.appendChild(btn);
    ul.appendChild(li);
  })
}

store.subscribe(paintToDos);

const dispatchAddTodo = text => {
  // store.dispatch({ type: ADD_TODO, text });
  store.dispatch(addToDo(text));
}

const dispatchDeleteTodo = (e) => {
  e.preventDefault();
  const id = parseInt(e.target.parentNode.id);
  // store.dispatch({ type: DELETE_TODO, id });
  store.dispatch(deleteToDo(id));
}

const onSubmit = e => {
  e.preventDefault();
  const toDo = input.value;
  input.value = '';
  dispatchAddTodo(toDo);
}

form.addEventListener("submit", onSubmit);
const addToDo = text => {
  return {
    type: ADD_TODO,
    text
  }
}

const deleteToDo = id => {
  return {
    type: DELETE_TODO,
    id
  }
}

only return action. 보통 reducer 위에 위치한다.

store.dispatch({ type: ADD_TODO, text }); ---> store.dispatch(addToDo(text));
store.dispatch({ type: DELETE_TODO, id }); ---> store.dispatch(deleteToDo(id));

위와 같이 깔끔한 코드로 작성이 가능해진다.

const reducer = (state = [], action) => {
  switch (action.type) {
    case ADD_TODO:
      const newToDoObj = { text: action.text, id: Date.now() };
      // return [...state, { text: action.text }]; // 새로 추가되는 todo가 뒤에 위치
      return [newToDoObj, ...state]; // 새로 추가되는 todo가 앞에 위치
    case DELETE_TODO:
      const cleaned = state.filter(toDo => toDo.id !== parseInt(action.id));
      return cleaned;
    default:
      return state;
  }
};

reducer 부분이다.

spread 문법인 ...state 를 이용하여 객체를 직접 수정하지 않고 ADD를 할 수 있다. 위치에 따라 새로운 원소가 추가되는 위치를 조정할 수 있다.

const dispatchAddTodo = text => {
  store.dispatch(addToDo(text));
}

const dispatchDeleteTodo = (e) => {
  e.preventDefault();
  const id = parseInt(e.target.parentNode.id);
  store.dispatch(deleteToDo(id));
}

const onSubmit = e => {
  e.preventDefault();
  const toDo = input.value;
  input.value = '';
  dispatchAddTodo(toDo);
}

dispatch 부분이다. 

const paintToDos = () => {
  const toDos = store.getState();
  ul.innerHTML = '';
  toDos.forEach(toDo => {
    const li = document.createElement("li");
    const btn = document.createElement("button");
    btn.innerText = "DEL";
    btn.addEventListener("click", dispatchDeleteTodo);
    li.id = toDo.id;
    li.innerText = toDo.text;
    li.appendChild(btn);
    ul.appendChild(li);
  })
}

store.subscribe(paintToDos);

subscribe 함수를 통해 state가 변경될 때마다 ul과 li 부분을 리렌더링해준다.

변경사항이 생길 때 해당 컴포넌트만 리렌더링된다.

 

 


이제 React에 적용할 차례다. React에서 Redux를 이용하여 To Do List를 구현해보았다.

#3. REACT REDUX - TO DO LIST

파일 구조 (축약)

redux-practice
├─ public
│  ├─ index.html
│  ├─ manifest.json
└─ src
   ├─ components
   │  └─ App.js
   ├─ index.js
   ├─ routes
   │  ├─ Detail.js
   │  ├─ Home.js
   │  └─ ToDo.js
   └─ store.js

 

App.js

import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Detail from "../routes/Detail";
import Home from "../routes/Home";
function App() {
    return (
        <Router>
            <Routes>
                <Route path="/" element={<Home />} />
                <Route path="/:id" element={<Detail />} />
            </Routes>
        </Router>
    );
}

export default App;

 

store.js

import { createStore } from "redux";

const ADD = "ADD";
const DELETE = "DELETE";

const addToDo = (text) => {
    return {
        type: ADD,
        text
    }
}

const deleteToDo = (id) => {
    return {
        type: DELETE,
        id
    }
}


const reducer = (state=JSON.parse(localStorage.getItem("toDos")) || [], action) => {
    switch(action.type) {
        case ADD:
            const addToDos = [{text: action.text, id: Date.now(0)}, ...state]
            localStorage.setItem("toDos", JSON.stringify(addToDos))
            return addToDos
        case DELETE:
            const deleteToDos = state.filter(toDo => toDo.id !== action.id);
            localStorage.setItem("toDos", JSON.stringify(deleteToDos));
            return deleteToDos
        default:
            return state
        }       
}

const store = createStore(reducer);

export const actionCreators = {
    addToDo,
    deleteToDo,
}

export default store;

state, reducer, dispatch를 정의하고 다른 컴포넌트들에서 여기에 있는 걸 갖다 쓴다.

#2에서 구현한 To Do List와 거의 같다.

 

다만 여기서는 새로고침해도 데이터가 유지되도록 Local Storage를 추가하였다. 

처음 reducer에서 state의 초기값을

JSON.parse(localStorage.getItem("toDos")) || []

으로 설정하여 Local Storage에 있는 toDos를 꺼내오도록 설정하였다.

그리고 ADD, DELETE가 발생할 때마다 setItem을 통해 Local Storage에 반영해주었다.

 

또 다른 파일에서 action함수들을 갖다 쓰기 편하게 actionCreators 함수 모음을 export하고 있다.

 

Home.js

import React, { useState, useEffect } from "react";
import { connect } from "react-redux";
import { actionCreators } from "../store";
import ToDo from './ToDo';

function Home({ toDos, addToDo }) {
    const [text, setText] = useState("");

    function onChange(e) {
        setText(e.target.value);
    }
    
    function onSubmit(e) {
        e.preventDefault();
        addToDo(text)
        setText("");
    }
    
    return (
        <>
        <h1>To Do</h1>
        <form onSubmit={onSubmit}>
            <input type="text" value={text} onChange={onChange}/>
            <button>Add</button>
        </form>
        <ul>
            {toDos.map(toDo => <ToDo {...toDo} key={toDo.id} />)}
        </ul>
        </>
    )
}

function mapStateToProps(state, ownProps) {
    console.log(state, ownProps);
    return { toDos: state };
}

function mapDispatchToProps(dispatch) {
    console.log(dispatch);
    return { 
        addToDo: (text) => dispatch(actionCreators.addToDo(text))
     };
}

export default connect(mapStateToProps, mapDispatchToProps) (Home);

Home.js에서 store.js 에 있는 변수를 갖다 쓰기 위해 connect와 mapStateToProps, mapDispatchToProps 를 사용하였다.

 

참고자료 : https://react-redux.js.org/using-react-redux/connect-mapdispatch

 

Connect: Dispatching Actions with mapDispatchToProps | React Redux

Usage > mapDispatch: options for dispatching actions with connect

react-redux.js.org

 

사실 요즘은 redux에도 hooks가 나와서 useSelector, useDispatch를 이용하여 훨씬 더 간결한 코드를 작성 가능하다고 하는데, 유행 지난 코드라고 하더라도 일단 익혀놔야 코드 돌아가는 느낌을 파악할 수 있기 때문에 공부해보았다.

추후에 redux hooks를 이용한 코드도 짜봐야겠다.

 

 

ToDo.js

import React from "react";
import { connect } from "react-redux";
import { actionCreators } from "../store";
import { Link } from "react-router-dom";

function ToDo({ text, onBtnClick, id }) {
    return (
    <li>
        <Link to={`/${id}`}>
        {text} 
        </Link>
        <button onClick={onBtnClick}>DEL</button>
    </li>
    )
}

function mapDispatchToProps(dispatch, ownProps) {
    return {
        onBtnClick: () => dispatch(actionCreators.deleteToDo(ownProps.id))
    }
}

export default connect(null, mapDispatchToProps) (ToDo);

입력한 todo 각 요소를 한줄씩 렌더링한다.

여기서 text는 Home으로부터 직접 전달받기 때문에 redux 상태관리를 쓰지 않는다.

DEL 버튼을 누르면 actionCreators.deleteToDo를 호출하여 삭제한다.

 

todo 하나를 선택하면 /:id 의 Detail 로 이동한다.

 

Detail.js

import { connect } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { actionCreators } from "../store";

const Detail = ({ toDos, onBtnClick }) => {
    const id = useParams().id;
    const toDo = toDos.find((toDo) => toDo.id === parseInt(id));
    const navigate = useNavigate();

    const handleDel = (id) => {
        onBtnClick(id);
        navigate("/");
    };

    return (
        <>
            {toDo?.text}
            Created at: {toDo?.id}
            <button onClick={() => handleDel(id)}>DEL</button>
        </>
    );
};

function mapStateToProps(state) {
    return {
        toDos: state,
    };
}

function mapDispatchToProps(dispatch, ownProps) {
    return {
        onBtnClick: (id) => dispatch(actionCreators.deleteToDo(parseInt(id))),
    };
}

export default connect(mapStateToProps, mapDispatchToProps)(Detail);
const id = useParams().id;
const toDo = toDos.find((toDo) => toDo.id === parseInt(id));

useParams()를 이용해 이동한 주소로부터 todo의 id를 얻고, find 함수를 통해 state에서 해당 id의 내용을 toDo에 담는다.

function mapStateToProps(state) {
    return {
        toDos: state,
    };
}

function mapDispatchToProps(dispatch, ownProps) {
    return {
        onBtnClick: (id) => dispatch(actionCreators.deleteToDo(parseInt(id))),
    };
}

export default connect(mapStateToProps, mapDispatchToProps)(Detail);

마찬가지로 mapStateToProps(state)를 통해 state를 받아와 toDos에 저장하고,

mapDispatchToProps(dispatch, ownProps)를 통해 DEL 버튼을 누를 시 DELETE를 수행한다.

 return (
        <>
            {toDo?.text}
            Created at: {toDo?.id}
            <button onClick={() => handleDel(id)}>DEL</button>
        </>
    );

toDo? 에서 ?는 toDo가 존재하지 않을 경우 에러를 방지해준다.

 

#4 Redux Toolkit

위에서 작성한 코드들을 Toolkit으로 간략화할 수 있다.

이걸 처음부터 쓰지 않은 이유 또한 처음 배우는 입장이기에 Redux를 사용한 풀 코드를 작성해보기 위함이다.

https://redux-toolkit.js.org/

 

Redux Toolkit | Redux Toolkit

The official, opinionated, batteries-included toolset for efficient Redux development

redux-toolkit.js.org

 

store.js

// ** 주석 표시한 곳들이 변경한 코드이다.

createAction

import { createStore } from "redux";
import { createAction } from "@reduxjs/toolkit"

const addToDo = createAction("ADD");  // **
const deleteToDo = createAction("DELETE");  // **

console.log('addToDo()', addToDo(), 'deleteToDo()', deleteToDo())  // **
const reducer = (state=JSON.parse(localStorage.getItem("toDos")) || [], action) => {
    switch(action.type) {
        case addToDo.type:
            console.log('action:', action)  // **
            const addToDos = [{text: action.payload, id: Date.now(0)}, ...state]  // **
            localStorage.setItem("toDos", JSON.stringify(addToDos))
            return addToDos
        case deleteToDo.type:
            const deleteToDos = state.filter(toDo => toDo.id !== action.payload);  // **
            localStorage.setItem("toDos", JSON.stringify(deleteToDos));
            return deleteToDos
        default:
            return state
        }       
}

const store = createStore(reducer);

export const actionCreators = {
    addToDo,
    deleteToDo,
}

export default store;

action은 type, payload를 가지고 있다. 더이상 action은 text, id를 가지고 있지 않다. 

대신에 payload에 해당 데이터들이 들어있다. 어떤 데이터든지 action에게 보내고 싶은 데이터가 payload로 보내진다.

 

 

console.log에 찍힌 내용을 보면 쉽게 알 수 있다.

createReducer

const reducer = createReducer([], {
    [addToDo]: (state, action) => { 
        // state.push({text: action.payload, id: Date.now()});
        state.unshift({text: action.payload, id: Date.now()});
    },
    [deleteToDo]: (state, action) => {
        return state.filter(toDo => toDo.id !== action.payload);
    }
})

- switch, case를 사용할 필요가 없어 reducer 부분을 훨씬 간결하게 만들어준다.

- 특히 state를 mutate 하기 좋게 만들어짐 *** (기존처럼 새로운 state를 만들지 않아도 됨)

 

왜 그럴까? 왜 mutate하는 코드를 작성해도 될까?

Redux-Toolkit은 [immer](https://github.com/immerjs/immer)아래에서 작동하기 때문에 push를 작성해도 redux-Toolkit이 뒤에서 알아서 처리해줌

 

mutate하는 코드를 작성하여 뒤에서 자동으로 추가되게 할 때는 return을 작성하면 안된다!

 

위와 같이 state.push로 작성하게 되면 to do list 추가 되는 순서가 반대로 작동하여

unshift로 바꾸어주면 원래대로 잘 작동한다.

 

configureStore

createStore를 통해 state를 정의하면 vscode에서는 위와 같은 취소선과 createStore 대신 redux toolkit의 configureStore를 사용하라는 문구가 뜬다.

 

그래서 이렇게 사용해보면, 

const store = configureStore({ reducer });

Chrome 확장 프로그램에 Redux Dev Tool https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=ko 을 통해 redux로 관리하는 변수들의 state, chart, raw data 등을 살필 수 있다.