[말로 풀어쓴 React] 리덕스를 사용하여 리액트 애플리케이션 상태 관리하기3

DongGu·2021년 2월 14일
0

말로 풀어쓴 리액트

목록 보기
11/11

목차

17.1 작업환경 설정

17.2 UI 준비하기

  • 17.2.1 카운터 컴포넌트 만들기
  • 17.2.2 할 일 목록 컴포넌트 만들기

17.3 리덕스 관련 코드 작성하기

  • 17.3.1 counter 모듈 작성하기
    • 17.3.1.1 액션 타입 정의하기
    • 17.3.1.2 액션 생성 함수 만들기
    • 17.3.1.3 초기 상태 및 리듀서 함수 만들기
  • 17.3.2 todos 모듈 만들기
    • 17.3.2.1 액션 타입 정의하기
    • 17.3.2.2 액션 생성 함수 만들기
  • 17.3.3 루트 리듀서 만들기

17.4 리액트 애플리케이션에 리덕스 적용하기

  • 17.4.1 스토어 만들기
  • 17.4.2 Provider 컴포넌트를 사용해 프로젝트에 리덕스 적용하기
  • 17.4.3 Redux DevTools 설치 및 적용

17.5 컨테이너 컴포넌트 만들기

  • 17.5.1 CounterContainer 만들기
    • 17.5.1.1 임시함수를 통해 connect로 컴포넌트와 리덕스 연결하기
    • 17.5.1.2 액션 생성 함수를 불러와 액션 객체를 만들고 디스패치해온 것의 connect 연결
    • 17.5.1.3 mapStateToProps, mapDispatchToProps의 익명함수화
    • 17.5.1.4 리덕스의 bindActionCreators 사용
  • 17.5.2 TodosContainer 만들기
    • 17.5.2.1 Todos 컴포넌트를 위한 컨테이너인 TodosContainer 작성
    • 17.5.2.2 App 컴포넌트 내 TodosContainer 적용
    • 17.5.2.3 Todos컴포넌트에서 받아온 props 사용하기

17.6 리덕스 더 편하게 사용하기

  • 17.6.1 redux-actions
    • 17.6.1.1 counter 모듈에 적용하기
    • 17.6.1.2 todos 모듈에 적용하기
  • 17.6.2 immer

17.7 Hooks를 사용해 컨테이너 컴포넌트 만들기

  • 17.7.1 useSelector로 상태 조회하기
  • 17.7.2 useDispatch를 사용하여 액션 디스패치하기
  • 17.7.3 useStore를 사용하여 리덕스 스토어 사용하기
  • 17.7.4 TodosContainer를 Hooks로 전환하기
  • 17.7.5 useActions 유틸 Hook을 만들어서 사용하기
  • 17.7.6 connect 함수와 주요 차이점

17.6 리덕스 더 편하게 사용하기

액션 생성 함수, 리듀서를 작성 할 때 redux-actions라는 라이브러리와 이전에 배웠던 immer 라이브러리를 활용하면 리덕스를 훨씬 편하게 쓸 수 있다.

17.6.1 redux-actions

redux-actions를 사용하면 액션 생성 함수를 더 짧은 코드로 작성할 수 있다.
리듀서를 작성할 때도 switch/case문이 아닌 handleActions라는 함수를 사용하여 각 액션마다 업데이트 함수를 설정하는 형식으로 작성해줄 수 있다.

  • 17.6.1.1 counter 모듈에 적용하기
    counter 모듈에 작성된 액션 생성 함수를 createAction이란 함수를 사용해 만들었다.
// modules/counter.js
import {createAction} from 'redux-action';

const INCREASE = 'counter/INCREASE';
const DECREASE = 'conter/DECREASE';

export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

(...)

createAction을 사용하면 매번 객체를 직접 만들어줄 필요 없이 더욱 간단하게 액션 생성함수를 선언할 수 있다.

이번에는 리듀서 함수도 더 간단하고 가독성있게 작성해보겠다. handleActions라는 함수를 사용한다.

// modules/counter.js
import { createAction, handleActions } from "redux-actions";

const INCREASE = "counter/INCREASE";
const DECREASE = "counter/DECREASE";

export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

const initialState = {
  number: 0,
};

const counter = handleActions(
  {
    [INCREASE]: (state, action) => ({ number: state.number + 1 }),
    [DECREASE]: (state, action) => ({ number: state.number - 1 }),
  },
  initialState
);

export default counter;

handleActions함수의 첫 번째 파라미터에는 각 액션에 대한 업데이트 함수를 넣어주고, 두 번째 파라미터에는 초기 상태를 넣어준다.

참고) handleActions 함수를 적용하지 않았을 때

function counter(state = initialState, action) {
  switch (action.type) {
    case INCREASE:
      return {
        number: state.number + 1,
      };
    case DECREASE:
      return {
        number: state.number - 1,
      };
    default:
      return state;
  }
}

17.6.1.2 todos 모듈에 적용하기

똑같은 작업을 todos 모듈에도 적용해볼 것이다. 먼저 액션 생성 함수를 교체할 건데, 차이점이 있다. 바로 각 액션 생성 함수에서 파라미터를 필요로 한다는 점이다.

createAction으로 액션을 만들면 액션에 필요한 추가 데이터는 payload라는 이름을 사용한다. 예시는 다음과 같다.

const MY_ACTION = 'sample/MY_ACTION';
const myAction = createAction(MY_ACTION);
const action = myAction('hello world');
/* 결과:
	{type: MY_ACTION, payload: 'hello world'}
*/

액션 생성 함수에서 받아온 파라미터를 그대로 payload에 넣는 것이 아니라 변형을 주어 넣고 싶다면, createAction의 두 번째 함수에 payload를 정의하는 함수를 따로 선언해서 넣어주면 된다.

const MY_ACTION = 'sample/MY_ACTION';
const myAction = createAction(MY_ACTION, text => `${text}!`);
const action = myAction('hello world!');
/* 결과
	{type: MY_ACTION, payload: 'hello world!'}
*/

todos 모듈의 액션 생성함수를 다음과 같이 새로 작성했다.

// modules/todos.js
import { createAction } from "redux-actions";

// 인풋 값을 변경함
const CHANGE_INPUT = "todos/CHANGE_INPUT";
// 새로운 todo를 등록함
const INSERT = "todos/INSERT";
// todo를 체크/체크 해제함
const TOGGLE = "todos/TOGGLE";
// todo를 제거함
const REMOVE = "todos/REMOVE";

export const changeInput = creaetAction(CHANGE_INPUT, (input) => input);

let id = 3; // insert가 호출될 때마다 1씩 더해진다.

export const insert = createAction(INSERT, (text) => ({
  id: id++,
  text,
  done: false,
}));

export const toggle = createAction(TOGGLE, (id) => id);
export const remove = createAction(REMOVE, (id) => id);

insert의 경우 todo 객체를 액션 객체 안에 넣어 줘야 하기 때문에, 두 번째 파라미터에 text를 넣으면 todo 객체가 반환되는 함수를 넣어줬다.

나머지 함수에는 text => text 혹은 id => id와 같은 형태로 파라미터를 그대로 반환하는 함수를 넣었다. 이 작업이 필수는 아니다. 생략해도 똑같이 작동하지만, 여기서 이 함수를 넣어줌으로써 코드를 봤을 때 이 액션 생성 함수의 파라미터로 어떤 값이 필요한지 쉽게 파악할 수 있다.

액션 생성 함수를 다 작성했으면 handleActions로 리듀서를 재작성해보겠다. createAction으로 만든 액션 생성 함수는 파라미터로 받아온 값을 객체 안에 넣을 때 원하는 이름으로 넣는 것이 아니라 action.id, action.todo와 같이 action.payload라는 이름을 공통적으로 넣어주게 된다. 기존의 업데이트 로직에서도 모두 action.payoad 값을 조회하여 업데이트하도록 구현해줘야 한다.

액션 생성 함수는 액션에 필요한 추가 데이터를 모두 payload라는 이름으로 사용하기 때문에 action.id, action.todo를 조회하는 대신, 모두 공통적으로 action.payload 값을 조회하도록 리듀서를 구현해야 한다.

// modules/todos.js
import { createAction, handleActions } from "redux-actions";

(...)
 
const todos = handleActions({
  [CHANGE_INPUT]: (state, action) => ({ ...state, input: action.payload }),
  [INSERT]: (state, action) => ({
    ...state,
    todos: state.todos.concat(action.payload),
  }),
  [TOGGLE]: (state, action) => ({
    ...state,
    todos: state.todo.map((todo) =>
      todo.id === action.payload ? { ...todo, done: !todo.done } : todo
    ),
  }),
  [REMOVE]: (state, action) => ({
    ...state,
    todos: state.todos.filter((todo) => todo.id !== action.payload),
  }),
  initialState,
});

export default todos;

모든 추가 데이터 값을 action.payload로 사용하기 때문에 나중에 리듀서 코드를 다시 볼 때 헷갈릴 수도 있다. 객체 비구조화 할당 문법으로 action값의 payload 이름을 새로 설정해주면 action.payload가 정확히 어떤 값을 의미하는지 더 쉽게 파악할 수 있다.

// modules/todos.js
const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) => ({ ...state, input }),
    [INSERT]: (state, { payload: todo }) => ({
      ...state,
      todos: state.todos.concat(todo),
    }),
    [TOGGLE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      ),
    }),
    [REMOVE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.filter((todo) => todo.id !== id),
    }),
  },
  initialState
);
export default todos;

17.6.2 immer
리듀서에서 상태를 업데이트할 때는 불변성을 지켜야 하기 때문에 앞에서는 spread 연산자(...)와 배열의 내장함수를 활용했다. 그러나 모듈의 상태가 복잡해질수록 불변성을 지키기 까다로워진다. 따라서 모듈을 설계할 때 객체의 깊이가 너무 깊어지지 않도록 주의해야 한다.

깊은 객체와 깊지 않은 객체를 비교해보자.

const deepObject = {
  modal: {
    open: false,
    content: {
      title: '알림',
      body: '성공적으로 처리되었습니다.',
      buttons: {
        confirm: '확인',
        cancel: '취소',
      },
    },
  },
  waiting: false,
  sttings: {
    theme: 'dark',
    zoomlevel: 5,
  },
};

const shallowObjects = {
  modal: {
    open: false,
    title: '알림',
    body: '성공적으로 처리되었습니다.',
    confirm: '확인',
    cancel: '취소',
  },
  waiting: false,
  theme: 'dark',
  zoomlevel: 5
}

객체의 깊이가 깊지 않을수록 추후 불변성을 지키면서 업데이트하기 수월하다. 객체의 구조가 복잡해지거나 객체로 이루어진 배열을 다룰 경우, immer를 사용하면 편하게 불변성을 유지할 수 있다.
counter 모듈처럼 간단한 리듀서에 immer를 적용하면 더 복잡해지기 때문에 todos 모듈에만 적용했다.

// modules/todos.js
import {createAction, handleActions} from 'redux-actions';
import produce from 'immer';

const todos = handleActions({
  [CHANGE_INPUT]: (state, { payload: input }) =>
    produce(state, (draft) => {
      draft.input = input;
    }),
  [INSERT]: (state, { payload: todo }) =>
    produce(state, (draft) => {
      draft.todos.push(todo);
    }),
  [TOGGLE]: (state, { payload: id }) =>
    produce(state, (draft) => {
      const todo = draft.todos.find((todo) => todo.id === id);
      todo.done = !todo.done;
    }),
  [REMOVE]: (state, { payload: id }) =>
    produce(
      state,
      (draft) => {
        const index = draft.todos.findIndex((todo) => todo.id === id);
        dreaft.todos.splice(index, 1);
      },
      initialState
    ),
});

immer를 사용해서 더 복잡해지는 경우는 굳이 immer를 사용안해도 된다.

17.7 Hooks를 사용해 컨테이너 컴포넌트 만들기

리덕스 스토어와 연동된 컨테이너 컴포넌트를 만들 떄 connect 함수를 사용하는 대신, react-redux에서 제공하는 Hooks를 사용할 수도 있다.

17.7.1 useSelector로 상태 조회하기

useSelector Hook을 사용하면 connect함수를 사용하지 않고도 리덕스 상태를 조회할 수 있다. useSelector의 사용법은 다음과 같다.
const 결과=useSelector(상태 선택 함수);
여기서 상태 선택 함수는 mapStateToProps 형태와 같다. 이제 CounterContainer에서 connect 함수 대신 useSelector를 사용하여 counter.number 값을 조회함으로써 Counter에게 props를 넘길 것이다.

// containers/CounterContainer.js
// useSelector를 이용한 CounterContainer
import React from "react";
import { useSelector } from "react-redux";
import { increase, decrease } from "../modules/counter";
import Counter from "../components/Counter";

const CounterContainer = () => {
  const number = useSelector((state) => state.counter.number);
  return <Counter number={number} />;
};

export default CounterContainer;

아래는 useSelector가 아닌 connect 함수를 이용한 방법이다.

// containers/CounterContainer.js
// connect 함수를 이용한 CounterContainer
import React from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} />
  );
};

export default connect(
  (state) => ({
    number: state.counter.number,
  }),
  (dispatch) =>
    bindActionCreators(
      {
        increase,
        decrease,
      },
      dispatch
    )
)(CounterContainer);

17.7.2 useDispatch를 사용하여 액션 디스패치하기

useDispatch는 컴포넌트 내부에서 스토어의 내장함수인 dispatch를 사용할 수 있게 해준다. 컨테이너 컴포넌트에서 액션을 디스패치해야 한다면, useDispatch를 사용하면 된다. 사용법은 다음과 같다.

const dispatch = useDispatch();
dispatch({type:'SAMPLE_ACTION'});

CounterContainer에서도 useDispatch를 사용하여 INCREASE, DECREASE 액션을 발생시킬 것이다.

// container/CounterContainer.js
import React from 'react';
import {useSelector, useDispatch} from 'react-redux';
import Counter from '../components/Counter';
import {increase, decrease} from '../modules/counter';

const CounterContainer = () => {
  const number = useSelector(state => state.counter.number);
  const dispatch = useDispatch();
  return (
    <Counter
    	number={number}
		onIncrease={()=>dispatch(increase())}
        onDecrease={()=>dispatch(decrease())}
    />
   );
};

export default CounterContainer;

위 코드는 숫자가 바뀌어서 컴포넌트가 리렌더링될 때마다 onIncrease 함수와 onDecrease 함수가 새롭게 만들어지고 있다. 컴포넌트 성능을 최적화해야 될때 useCallback으로 액션을 디스패치하는 함수를 감싸주는 것이 좋다. 아래와 같이 작성하면 된다.

// containers/CounterContainer.js
import React, {useCallback} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import Counter from '../components/Counter';
import {increase, decrease} from '../modules/counter';

const CounterContainer = () =>{
  const number = useSelector(state => state.counter.number);
  const dispatch = useDispatch();
  const onIncrease = useCallback(()=>dispatch(increase()), [dispatch]);
  const onDecrease = useCallback(()=>dispatch(decrease()), [dispatch]);
  return (
    <Counter number={number} onIncrease={onIncrease} onDecreae={onDecrease} />
  );
};
export default CounterContainer;

useDispatch를 사용할 때 useCallback과 사용하면 좋다.

17.7.3 useStore를 사용하여 리덕스 스토어 사용하기

useStore Hooks를 사용하면 컴포넌트 내부에서 리덕스 스토어 객체를 직접 사용할 수 있다. 사용법은 다음과 같다.

const store = useStore();
store.dispatch({type:"SAMPLE_ACTION"});
store.get();

useStore는 컴포넌트에서 정말 어쩌다가 스토어에 직접 접근해야 하는 상황에만 사용해야 한다. useStore를 사용해야 하는 상황은 흔치 않다.

17.7.4 TodosContainer를 Hooks로 전환하기

이제 TodosContainer를 connect 함수 대신에 useSelector과 useDispatch Hooks를 사용하는 형태로 전환해볼 것이다.

// containers/TodosContainer.js
import React, {useCallback} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {changeInput, insert, toggle, remove} from '../modules/todos';
import Todos from '../components/Todos';

const TodosContainer = () => {
  const {input, todos} = useSelector(({todos}) => ({
    input: todos.input,
    todos: todos.todos
  }));
  const dispatch = useDispatch();
  const onChangeInput = useCallback(input => dispatch(changeInput(input)), [dispatch]);
  const onInsert = useCallback(text => dispatch(insert(text)), [dispatch]);
  const onToggle = useCallback(id => dispatch(toggle(id)), [dispatch]);
  const onRemove = useCallback(id => dispatch(remove(id)), [dispatch]);
  
  retunr (
    <Todos
    	input={input}
		todos={todos}
		onChangeInput={onChangeInput}
		onInsert={onInsert}
		onToggle={onToggle}
		onRemove={onRemove}
	/>
  );
};
export default TodosContainer;

useSelector를 사용할 때 비구조화 할당 문법을 활용했다. useDispatch를 사용할 땐 각 액션을 디스패치하는 함수를 만들었다.

위 코드의 경우 액션의 종류가 많은데, 어떤 값이 액션 생성 함수의 파라미터로 사용되어야 하는지 일일이 명시해줘야 해서 번거롭다. 이 부분은 우선 컴포넌트가 잘 작동되는 것을 보고 개선할 예정이다.

17.7.5 useActions 유틸 Hook을 만들어서 사용하기

useActions는 원래 react-redux에 내장된 상태로 릴리즈될 계획이었으나 리덕스 개발팀에서 꼭 필요하지 않다고 판단해서 제외된 Hook이다. 대신 공식 문서(https://react-redux.js.org/next/api/hooks#recipe-useactions)에서 그대로 복사하여 사용할 수 있도록 제공하고 있다. 이 Hook을 사용하면 여러 개의 액션을 사용해야 하는 코드를 훨씬 깔끔하게 정리해준다.

// lib/useActions.js
import {bindActionCreators} from 'redux';
import {useDispatch} from 'react-redux';
import {useMemo} from 'react';

export default function useActions(actions, deps){
  const dispatch=useDispatch();
  return useMemo(
    ()=>{
      if (Array.isArray(actions)){
        return actions.map(a=>bindActionCreators(a, dispatch));
      }
      return bindActionsCreators(actions, dispatch);
    },
    deps ? [dispatch, ...deps] : deps
  );
}

useActions Hook은 액션 생성 함수를 액션을 디스패치하는 함수로 변환해준다. 액션 생성 함수를 사용하여 액션 객체를 만들고, 이를 스토어에 디스패치하는 작업을 해주는 함수를 자동으로 만들어준다.

useActions는 두 가지 파라미터가 필요하다. 첫 번째 파라미터는 액션 생성 함수로 이루어진 배열이다. 두 번째 파라미터는 deps 배열이며, 이 배열 안에 들어있는 원소가 바뀌면 액션을 디스패치하는 함수를 새로 만들게 된다.

useActions를 TodoContainer에 적용해볼 것이다.

// containers/TodoContainer.js
import React from 'react';
import {useSelector} from 'react-redux';
import {changeInput, insert, toggle, remove} from '../modules/todos';
import Todos from '../components/Todos';
import useActions from '../lib/useActions';

const TodosContainer = () => {
  const {input, todos} = useSelector(({todos}) => ({
    input: todos.input,
    todos: todos.todos
  }));
  
  const [onChangeInput, onInsert, onToggle, onRemove] = useActions(
    [changeInput, insert, toggle, remove], 
    [] 
  );
  return (
    <Todos
    	input={input}
		todos={todos}
		onChangeInput={onChangeInput}
		onInsert={onInsert}
		onToggle={onToggle}
		onRemove={onRemove}
	/>
  );
};
export default TodosContainer;

17.7.6 connect 함수와 주요 차이점

컨테이너 컴포넌트를 만들 때 connect 함수를 사용해도 좋고, useSelector과 useDispatch를 사용해도 좋다. 리덕스 관련 Hook이 있다고해서 기존 connect 함수가 사라지는 것은 아니므로, 더 편한 것을 사용하면 된다.

Hooks를 사용하여 컨테이너 컴포넌트를 만들 때 잘 알아두어야 할 차이점이 있다. connect 함수를 사용하여 컨테이너 컴포넌트를 만들었을 경우, 해당 컨테이너 컴포넌트의 부모 컴포넌트가 리렌더링될 때 해당 컨테이너 컴포넌트의 props가 바뀌지 않았다면 리렌더링이 자동으로 방지되어 성능이 최적화된다.

반면 useSelector를 사용하여 리덕스 상태를 조회했을 때는 이 최적화 작업이 자동으로 이뤄지지 않으므로, 성능 최적화를 위해서 React.memo를 아래와 같이 컨테이너 컴포넌트에 사용해줘야 한다.

// containers/TodosContainer.js
import React from 'react';
import {useSelector} from 'react-redux';
import {changeInput, insert, toggle, remove} from '../modules/todos';
import Todos from '../components/Todos';
import useActions from '../lib/useActions';

const TodosContainer = () => {
  (...)
};
export default
React.memo(TodosContainer);

다만 지금과 같은 경우에는 TodosContainer의 부모 컴포넌트인 App 컴포넌트가 리렌더링되는 일이 없으므로 불필요한 성능 최적화 작업이다.

리액트를 다루는 기술(김민준, 길벗 출판사)을 참조했습니다.

profile
코딩하는 신방과생

0개의 댓글