리액트 Summary - 4-3. Redux with React-thumbnail

리액트 Summary - 4-3. Redux with React

Redux란 무엇일까? 2편
277

Redux with React

이전 글 'Redux without React'를 보고 왔다면, 내용이 그리 어렵게 느껴지진 않을 것이다. Redux의 3-3 구조는 그대로인 상태에서 React와 연관된 라이브러리와의 결합을 위해 약간의 문법적인 변화만 있을 뿐이기 때문이다.

준비과정

reduxreact-redux를 설치해주자.

$ npm init -y
$ npm i redux react-redux

Patterns(Presentational Component & Container Component) and Structure

React와 함께 Redux를 사용하게 되면 Presentational ComponentContainer Component를 구분하여 사용하게 된다(물론 이는 하나의 pattern이라서 다른 pattern을 사용할 수도 있다). Presentational Component는 Redux에 담긴 state를 받아와 UI를 만드는 역할을 한다. Container Component는 Redux와 직접 소통하며 state나 action을 통해 presentational component와 Redux 사이의 중재자 역할을 하게 된다. 뒤쪽에 가서 코드를 보면 한 번에 이해가 갈 테니, 지금은 개념만 잡으면 된다.

그리고 여기서 예시로 쓰게 될 코드는 Ducks pattern을 따른다. Ducks pattern이란 한 파일에 action과 reducer에 관한 모든 코드를 작성하는 pattern이다. 이전에도 그랬듯 한 파일에 작성한 뒤 기능별로 코드를 나누어서 보면 되기 때문에 코드를 한 눈에도 볼 수 있고 나누어서도 볼 수 있는 Ducks pattern이 설명에도 좋은 것 같다. actions, constants, reducers로 폴더를 나누고 각각 action type에 관한 코드, action 생성 함수에 관한 코드, reducer에 관한 코드를 작성하는 pattern도 유명하니 원하는 사람은 따로 찾아보자.

하여, 우리가 살펴볼 파일은 총 여섯 개다.

Store

Action & Reducer

Component

Redux code

(src/modules/lists.js)

const CHANGE_TEXT = 'lists/CHANGE_TEXT';
const CREATE = 'lists/CREATE';
const TOGGLE = 'lists/TOGGLE';
const REMOVE = 'lists/REMOVE';

// action part
export const changeText = text => ({
  type: CHANGE_TEXT,
  text
});

let id = 4;
export const create = text => ({
  type: CREATE,
  list: {
    id: id++,
    desc: text,
    done: false
  }
});
export const toggle = id => ({
  type: TOGGLE,
  id
});
export const remove = id => ({
  type: REMOVE,
  id
});

// reducer part
const initialState = {
  text: '',
  lists: [
    {id: 1, desc: 'Trip To Latin America', done: true},
    {id: 2, desc: 'Trip To Egypt', done: false},
    {id: 3, desc: 'Learn Sign Language', done: false},
  ]
};

const lists = (state = initialState, action) => {
  switch(action.type){
    case CHANGE_TEXT:
      return {
        ...state,
        text: action.text
      }
    case CREATE:
      return {
        ...state,
        lists: state.lists.concat(action.list)
      }
    case TOGGLE:
      return {
        ...state,
        lists: state.lists.map(list => list.id === action.id ? {...list, done: !list.done} : list )
      }
    case REMOVE:
      return {
        ...state,
        lists: state.lists.filter(list => list.id !== action.id)
      }
    default:
      return state
  }
}

export default lists;

예시로는 버킷 리스트 CRUD를 만들어보았다. 책에 나온 예시와 비슷하고, CRUD를 모두 다룰 수 있다는 점이 설명하기에 좋아 새로 작성해보았다.

1. Action

우리에게 익숙하듯(익숙하지 않다면 이전 글을 보고 오는 것도 좋다) type을 지정해주었고, 어떤 data를 reducer에 전달해줄 것인지에 대해 명시해놓은 파트다. Reducer는 해당 data를 가지고 각 type에 맞는 state 변경 작업을 수행하게 된다는 걸 알고 보면 그리 어렵지 않은 코드임을 알 수 있다.

const CHANGE_TEXT = 'lists/CHANGE_TEXT';
const CREATE = 'lists/CREATE';
const TOGGLE = 'lists/TOGGLE';
const REMOVE = 'lists/REMOVE';

// action part
export const changeText = text => ({
  type: CHANGE_TEXT,
  text
});

let id = 4;
export const create = text => ({
  type: CREATE,
  list: {
    id: id++,
    desc: text,
    done: false
  }
});
export const toggle = id => ({
  type: TOGGLE,
  id
});
export const remove = id => ({
  type: REMOVE,
  id
});

2. Reducer

상술한 것과 마찬가지로 reducer는 action 객체의 type과 추가적으로 주어진 data를 활용해 어떻게 state를 변경할지 알려주고 있다. initialState로 앱이 실행된 직후의 상태를 reducer에 전달해준다. 실제 프로젝트에서는 initialState가 DB에 저장된 데이터들이 될 확률이 높다.

// reducer part
const initialState = {
  text: '',
  lists: [
    {id: 1, desc: 'Trip To Latin America', done: true},
    {id: 2, desc: 'Trip To Egypt', done: false},
    {id: 3, desc: 'Learn Sign Language', done: false},
  ]
};

const lists = (state = initialState, action) => {
  switch(action.type){
    case CHANGE_TEXT:
      return {
        ...state,
        text: action.text
      }
    case CREATE:
      return {
        ...state,
        lists: state.lists.concat(action.list)
      }
    case TOGGLE:
      return {
        ...state,
        lists: state.lists.map(list => list.id === action.id ? {...list, done: !list.done} : list )
      }
    case REMOVE:
      return {
        ...state,
        lists: state.lists.filter(list => list.id !== action.id)
      }
    default:
      return state
  }
}

export default lists;

3. Store

(src/index.js)

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore } from 'redux';
import rootReducer from './modules';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';

const store = createStore(
  rootReducer, composeWithDevTools()
);

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

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

createStore를 하고, 그 안에 rootReducercomposeWithDevTools()를 넣어준다. composeWithDevTools()는 Chrome Extension인 Redux DevTools를 사용하기 위한 메서드다.

rootReducer는 여러 reducer를 하나로 합쳐주기 위해서(createStore 함수를 이용하면 reducer는 하나여야 한다고 한다) 사용하는 reducer 코드다. 곧 아래에서 보게 될 것이다.

react-redux의 Provider<App />을 감싸준다. 우리가 만든 store를 store attribute에 담아서 App이 store를 인식할 수 있도록 해준다.

이어서 src/App.js의 코드를 보자.

(src/App.js)

import ListContainer from './containers/ListContainer';

const App = () => {
  return (
    <div>
      <ListContainer />
    </div>
  );
}

export default App;

별 다를 게 없다. 다만 이전에 말했던 것처럼 Presentational Component와 Container Component를 나누었기 때문에, 우선 App component가 접촉하게 되는 component는 container component가 되었다.

(src/modules/index.js)

import { combineReducers } from 'redux';
import lists from './lists';

const rootReducer = combineReducers({
  lists,
});

export default rootReducer;

Redux의 combineReducers를 가지고 reducer들을 합쳐주고 있다. 지금은 reducer가 lists 하나 밖에 없어서 혼자 있지만, 다른 reducer도 있다면 combineReducers 안에 같이 적어주면 된다. 이를테면 아래와 같은 모습이 될 것이다.

const rootReducer = combineReducers({
  lists, users, timer, counter
});

4. Container Component

(src/containers/ListContainer.js)

import Lists from '../components/Lists';
import { connect } from 'react-redux';
import { changeText, create, toggle, remove } from '../modules/lists';

const ListContainer = ({ text, lists, changeText, create, toggle, remove }) => {
  return (
    <Lists text={text} lists={lists} onChangeText={changeText} onCreate={create} onToggle={toggle} onRemove={remove} />
  );
}

export default connect(
  ({ lists }) => ({
    text: lists.text,
    lists: lists.lists
  }),
  {
    changeText, create, toggle, remove
  }
)(ListContainer);

ListContainer는 한 편으로는 App Component와, 다른 한 편으로는 Presentational Component와 소통하고 있다. 그래서 ListContainer는 store로부터 받아낸 state인 textlists, 그리고 reducer에서 가져온 changeText, create, toggle, remove를 presentational component인 <Lists />에 전달해주고 있다.

이 정도만 보고 넘어가도 되지만, react-redux가 제공하는 connect가 받아가는 인자들에 대해서 명확하게 알아보자.

export default connect(
  ({ lists }) => ({
    text: lists.text,
    lists: lists.lists
  }),
  {
    changeText, create, toggle, remove
  }
)(ListContainer);

두 인자가 전달되고 있다. 원래 가장 기본적으로 쓰이는 문법이 따로 있다. 그걸 활용해서 이 부분을 다시 적어보자.

const mapStateToProps = state => ({
  text: state.lists.text,
  lists: state.lists.lists
});
const mapDispatchToProps = dispatch => ({
  changeText: () => dispatch(changeText()),
  create: () => dispatch(create()),
  toggle: () => dispatch(toggle()),
  remove: () => dispatch(remove()),
});

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

이 두 함수(mapStateToProps, mapDispatchToProps)가 return하는 객체의 내부 값들은 component의 props에 전달이 되는 것을 위의 코드로 알 수 있다. mapStateToProps는 parameter로 store의 현재 state를 가져온다.

mapDispatchToProps는 우리에게 익숙한 store의 메서드 dispatch를 parameter로 받는다.

결국 mapStateToPropsmapDispatchToProps는 위에 있는 <Lists />에 props로 store 내부 값들을 전달해주기 위해서 사용되는 메서드들이다. 이름이나 형태에는 크게 상관없이, return하는 객체 내부에 전달할 state와 메서드가 들어있으면 된다.

그리고 우리가 썼던 더 간결한 코드는 mapStateToProps에는 Destructuring assignment(비구조화 할당)를, mapDispatchToProps에는 약간의 Syntax sugar를 첨가해 간단하게 바꾼 것이 위의 모습일 뿐이다. 아직 익숙하지 않다면 바로 위의 기본적인 코드 pattern처럼 적어나가며 익숙해지는 것도 좋은 방법이다.

5. Presentational Component

(src/Lists.js)

const ListItem = ({ list, onToggle, onRemove }) => {
  return (
    <div>
      <input type="checkbox" onClick={() => onToggle(list.id)} checked={list.done} readOnly={true} />
      <span style={{ textDecoration: list.done ? 'line-through' : 'none' }}>{list.desc}</span>
      <button onClick={() => onRemove(list.id)}>DELETE</button>
    </div>
  )
}

const Lists = ({
  text,
  lists,
  onChangeText,
  onCreate,
  onToggle,
  onRemove,
}) => {

  const onSubmit = e => {
    e.preventDefault();
    onCreate(text);
    onChangeText('');
  };
  const onChange = e => {
    onChangeText(e.target.value);
  }

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input value={text} onChange={onChange} />
        <button type="submit">SUBMIT</button>
      </form>
      <div>
        {lists.map(list => (
          <ListItem 
            key={list.id}
            list={list} onToggle={onToggle} onRemove={onRemove}
          />
        ))}
      </div>
    </div>
  )
}

export default Lists;

모든 실무가 이루어지는 곳은 끝단이다. 여기서 UI에 보여질 때 필요한 메서드들이 만들어지고 그 형태가 표현된다. 코드 자체가 길고(혹은 길어서) 복잡해보이지만, <ListItem /> component를 같은 파일에 정의한 것뿐이고, container component에서 전달받은 props들을 가지고 UI를 구성한 것이 전부다.

마무리

참고한 책에는 redux-actions, immer, useSelector, useDispatch, useStore 등이 소개되어 있으나, 위를 기본 베이스로 하여 필요한 경우에 알아보면 되는 수준의 내용들인 것 같아 굳이 더 적지 않았다.


참고 서적 :
김민준, 2019, 길벗, 『리액트를 다루는 기술, 개정판』