리액트를 다루는 기술 ( 리덕스를 사용하여 리액트 애플리케이션 상태 관리하기 )

리액트 애플리케이션에서 리덕스를 사용의 장점

  • 상태 업데이트에 관한 로직을 모듈로 따로 분리하여 컴포넌트 파일과 별개로 관리
  • 코드를 유지 보수하는 데 이점
  • 여러 컴포넌트에서 동일한 상태를 공유해야 할 때 유용
  • 실제 업데이트가 필요한 컴포넌트만 리렌더링되도록 쉽게 최적화

사용은 주로 react-redux라는 라이브러리에서 제공하는 유틸 함수( connect )와 컴포넌트( Provider )를 사용하여 리덕스 관련 작업을 처리한다.

실습 흐름

  1. 프로젝트 준비
  2. 프레젠테이셔널 컴포넌트 작성
  3. 리덕스 관련 코드 작성
  4. 컨테이너 컴포넌트 작성
  5. 더 편하게 사용하는 방법 알아보기
  6. connect 대신 Hooks 사용하기

17-1. 작업 환경 설정

yarn create react-app react-redux-tutorial
cd react-redux-tutorial
yarn add redux react-redux

17-2. UI 준비하기

리액트 프로젝트에서 리덕스를 사용할 때 가장 많이 사용하는 패턴은 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 것이다.

프레젠테이셔널 컴포넌트 : 주로 상태관리가 이루어지지 않고, 그저 props를 받아 와서 화면에 UI를 보여 주기만 하는 컴포넌트

컨테이너 컴포넌트 : 리덕스와 연동되어 있는 컴포넌트로, 리덕스로 부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치하기도 한다.

이 패턴은 리덕스를 사용하는 데 필수 사항은 아니지만, 이 패턴을 사용하면 코드의 재 사용성도 높아지고, 관심사의 분리가 이루어져 UI를 작성할 때 좀 더 집중할 수 있다.

UI 관련된 프레젠테이셔널 컴포넌트는 src/components 경로 , 리덕스 연동된 컨테이너 컴포넌트는 src/containers 컴포넌트에 작성

17-2-1. 카운터 컴포넌트 만들기

숫자를 더하고 뺄 수 있는 카운터 컴포넌트 만들기 compoents 디렉토리를 생성한 뒤 그 안에 Counter 컴포넌트 작성

import React from 'react';

const Counter = ({ number, onIncrease, onDecrease }) => {
  return (
    <div>
      <h1>{number}</h1>
      <div>
        <button onClick={onIncrease}>+1</button>
        <button onClick={onDecrease}>-1</button>
      </div>
    </div>
  );
};

export default Counter;

App에 렌더링

import React from 'react';
import Counter from './components/Counter';

const App = () => {
  return (
    <div>
      <Counter number={0} />
    </div>
  );
};

export default App;

17-2-2. 할 일 목록 컴포넌트 만들기

/src/components/Todos.js

import React from 'react';

const TodoItem = ({ todo, onToggle, onRemove }) => {
  return (
    <div>
      <input type="checkbox" />
      &nbsp;&nbsp;
      <span>예제 텍스트</span>
      &nbsp;&nbsp;
      <button>삭제</button>
    </div>
  );
};

const Todos = ({
  input, // input에 입력되는 텍스트
  todos, // 할 일 목록이 들어 있는 객체
  onChangeInput,
  onInsert,
  onToggle,
  onRemove,
}) => {
  const onSubmit = (e) => {
    e.preventDefault();
  };

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input />
        <button type="submit">등록</button>
      </form>
      <div>
        <TodoItem />
        <TodoItem />
        <TodoItem />
        <TodoItem />
        <TodoItem />
      </div>
    </div>
  );
};

export default Todos;

App 컴포넌트어서 카운터 아래에 렌더링

import React from 'react';
import Counter from './components/Counter';
import Todos from './components/Todos';

const App = () => {
  return (
    <div>
      <Counter number={0} />
      <hr />
      <Todos />
    </div>
  );
};

export default App;

17-3. 리덕스 관련 코드 작성하기

17-3-1. coutner 모듈 작성하기

Ducks 패턴을 사용하여 액션 타입, 액션 생성 함수, 리듀서를 작성한 코드를 '모듈'이라고 한다. 먼저 counter 모듈을 작성한다.

17-3-1-1. 액션 타입 정의하기

modules 디렉터리를 생성하고 그 안에 counter.js 파일을 작성

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

가장 먼저 해야 할 작업은 액션 타입을 정의하는 것. 액션 타입은 대문자로 정의하고 , 문자열 내용은 '모듈 이름/액션 이름' 과 같은 형태로 작성한다. 문자열 안에 모듈 이름을 넣음으로써, 나중에 프로젝트가 커졌을 때 액션의 이름이 충돌되지 않게 해 준다.

17-3-1-2. 액션 생성 함수 만들기

액션 타입을 정의한 다음에는 액션 생성 함수를 만들 준다.

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

export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

더 필요하거나 추가할 값이 없다. 간단한데 여기서 주의해야 할 점은 앞부분에 export라는 키워드가 들어간다는 것이다. 이 함수를 다른 파일에서 불러와 사용할 수 있다.

17-3-1-3. 초기 상태 및 리듀서 함수 만들기

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

export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

const initialState = { number: 0 };

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;
  }
}

export default counter;

초기 상태에는 number 값을 설정

const initialState = { number: 0 };

리듀서 함수에는 현재 상태를 참조하여 새로운 객체를 생성해서 반환하는 코드를 작성

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;
  }
}

여기서 export로 내보내는 거랑 export default로 내보내는 차이점은

  • export : 여러개를 내보낼 수 있고
  • export default : 단 한 개만 내보낼 수 있다.

불러오는 방식도 다르다.

import counter from './counter'
import { increase, decrease } from './counter'
// 한꺼번에 불러오고 싶을 때
import counter, { increase, decrease } from './counter'

17-3-2. todos 모듈 만들기

modules/todos.js 파일 생성

17-3-2-1. 액션 타입 정의

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

17-3-2-2. 액션 생성 함수 만들기

좀 전과 달리 이번에는 액션 생성 함수에서 파라미터가 필요하다. 전달받은 파라미터는 액션 객체 안에 추가 필드로 들어간다.

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

export const changeInput = (input) => ({ type: CHANGE_INPUT, input });
let id = 3;
export const insert = (text) => ({
  type: INSERT,
  todo: { id: id++, text, done: false },
});
export const toggle = (id) => ({ type: TOGGLE, id });
export const remove = (id) => ({ type: REMOVE, id });

insert 함수는 액션 객체를 만들 때 파라미터 외에 사전에 이미 선언되어 있는 id라는 값에도 의존한다. 이 액션 생성 함수는 호출될 때마다 id 값에 1씩 더해 준다. id 값은 각 todo 객체가 들고 있게 될 고유 값이다.

17-3-2-3. 초기 상태 및 리듀서 함수 만들기

모듈의 초기 상태와 리듀서 함수를 작성할텐데 업데이트 방식이 조금 까다롭다고 한다. 객체에 한 개 이상의 값이 들어가므로 불변성을 유지해 주어야 하기 때문. spread 연산자를 활용해서 작성, 배열에 변화를 줄 때는 배열 내장 함수를 사용하여 구현하면 됨

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

export const changeInput = (input) => ({ type: CHANGE_INPUT, input });
let id = 3;
export const insert = (text) => ({
  type: INSERT,
  todo: { id: id++, text, done: false },
});
export const toggle = (id) => ({ type: TOGGLE, id });
export const remove = (id) => ({ type: REMOVE, id });

const initialState = {
  input: '',
  todos: [
    {
      id: 1,
      text: '리덕스 기초 배우기',
      done: true,
    },
    {
      id: 2,
      text: '리액트와 리덕스 사용하기',
      done: false,
    },
  ],
};

function todos(state = initialState, action) {
  switch (action.type) {
    case CHANGE_INPUT:
      return { ...state, input: action.input };
    case INSERT:
      return { ...state, todos: state.todos.concat(action.todo) };
    case TOGGLE:
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, done: !todo.done } : todo,
        ),
      };
    case REMOVE:
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
}

export default todos;

17-3-3. 루트 리듀서 만들기

이번 프로젝트에서 리듀서를 여러 개 만들었는데, 나중에 createStore 함수를 사용하여 스토어를 만들 때는 리듀서를 하나만 사용해야 한다. 그렇기 때문에 리듀서를 하나로 합쳐 주어야 하는데, 리덕스에서 제공하는 combineReducers라는 유틸 함수를 사용하면 쉽게 처리할 수 있다.

modules/index.js 파일생성

import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';

const rootReducer = combineReducers({ counter, todos });

export default rootReducer;

파일 명을 index.js로 설정하면 나중에 불러올 때 디렉토리 이름까지만 입력하여 불러올 수 있다.

import rootReducer from './modules'

17-4. 리액트 애플리케이션에 리덕스 적용하기

리액트 애플리케이션에 리덕스를 적용할 차례인데 스토어를 만들고 리액트 애플리케이션에 리덕스를 적용하는 작업은 src/index.js에서 이루어짐

17-4-1. 스토어 만들기

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import './index.css';
import App from './App';
import rootReducer from './modules';

const store = createStore(rootReducer);

ReactDOM.render(<App />, document.getElementById('root'));

17-4-2. Provider 컴포넌트를 사용하여 프로젝트에 리덕스 적용

리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 react-redux에서 제공하는 Provider 컴포넌트로 감싸준다. 이 컴포넌트를 사용할 때는 store를 props로 전달해 주어야한다.

import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import './index.css';
import App from './App';
import rootReducer from './modules';

const store = createStore(rootReducer);

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

17-4-3. Redux DevTools의 설치 및 적용

https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd/related?hl=ko-KR

설치한다.

설치하고 리덕스 스토어를 만드는 과정에서 예시와 같이 적용해 줄 수 있다.

예시

const store = createStore(
	rootReduce, /* preloadedState, */
	window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
)

하지만 패키지를 설치하여 적용하면 코드가 훨씬 깔끔해진다.

yarn add redux-devtools-extension

src/index.js

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

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

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

크롬 개발자 도구에서 redux 탭을 누르면 잘 나타난 것을 볼 수 있다.

17-5. 컨테이너 컴포넌트 만들기

이제 컴포넌트에서 리덕스 스토어에 접근하여 원하는 상태를 받아 오고, 또 액션도 디스패치 해 줄 차례이다. 리덕스 스토어와 연동된 컴포넌트를 컨테이너 컴포넌트라고 부른다.

17-5-1. CounterContainer 만들기

src/containers/CounterContainer.js

import React from 'react';
import Counter from '../components/Counter';

const CounterContainer = () => {
  return <Counter />;
};

export default CounterContainer;

위 컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야 한다.

이 함수는

connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)
  • mapStateToProps : 리덕스 스토어 안에 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수
  • mapDispatchToProps : 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수

connect 함수를 호출하고 나면 또 다른 함수를 반환한다. 반환된 함수에 컴포넌트를 파라미터로 넣어 주면 리덕스와 연동된 컴포넌트가 만들어진다.

위 코드를 쉽게 풀면

const makeContainer = connect(mapStateToProps, mapDispatchToProps)
makeContainer(타깃 컴포넌트)

이제 CounterContainer 컴포넌트에서 connect를 사용하면

import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';

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

const mapStateToProps = (state) => ({
  number: state.counter.number,
});

const mapDispatchToProps = (dispatch) => ({
  // 임시 함수
  increase: () => {
    console.log('increase');
  },
  decrease: () => {
    console.log('decrease');
  },
});

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

mapStateToProps 와 mapDispatchToProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달된다. mapStateToProps 는 state를 파라미터로 받아 오고, 이 값은 현재 스토어가 지니고 있는 상태를 가리킨다. mapDispatchToProps 의 경우 store의 내장 함수 dispatch를 파라미터로 받아온다. 현재 mapDispatchToProps에서는 진행 절차를 설명하기 위해 임시로 console.log를 사용했다.

App에서 Counter를 CounterContainer로 교체

import React from 'react';
import Todos from './components/Todos';
import CounterContainer from './containers/CounterContainer';

const App = () => {
  return (
    <div>
      <CounterContainer />
      <hr />
      <Todos />
    </div>
  );
};

export default App;

이번엔 console.log 대신 액션 생성 함수를 불러와서 액션 객체를 만들고 디스패치한다.

import React from 'react';
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} />
  );
};

const mapStateToProps = (state) => ({
  number: state.counter.number,
});

const mapDispatchToProps = (dispatch) => ({
  // 임시 함수
  increase: () => {
    dispatch(increase());
  },
  decrease: () => {
    dispatch(decrease());
  },
});

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

잘 구현된다.

connect 함수를 사용할 때는 일반적으로 위 코드와 같이 mapStateToProps , mapDispatchToProps를 미리 선언해 놓고 사용한다. 하지만 connect 함수 내부에 익명 함수 형태로 선언해도 문제가 되지 않는다. 코드가 더 깔끔해지기도하는데

import React from 'react';
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) => ({
    increase: () => dispatch(increase()),
    decrease: () => dispatch(decrease()),
  }),
)(CounterContainer);

위와 같이 작성한다. 컴포넌트에서 액션을 디스패치하기 위해 각 액션 생성 함수를 호출하고 dispatch로 감싸는 작업이 조금 번거로울 수도 있다. 액션 생성 함수의 개수가 많아진다면 더더욱 그럴 것인데, 이와 같은 경우에는 리덕스에서 제공하는 bindActionCreators 유틸 함수를 사용하면 간편하다.

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);

이거보다 한 가지 더 편한 방법이 있다. mapStateToProps 에 해당하는 파라미터를 함수 형태가 아닌 액션 생성 함수로 이루어진 객체 형태로 넣어 주는 것

import React from 'react';
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,
  }),
  {
    increase,
    decrease,
  },
)(CounterContainer);

위와 같이 두 번째 파라미터를 아예 객체 형태로 넣어 주면 connect 함수가 내부적으로 bindActionCreators 작업을 대신 해준다.

17-5-2. TodosContainer 만들기

connect 함수를 사용하고, mapDispatchToProps를 짧고 간단하게 쓰는 방법을 적용

import React from 'react';
import { connect } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';

const TodosContainer = ({
  input,
  todos,
  changeInput,
  insert,
  toggle,
  remove,
}) => {
  return (
    <Todos
      input={input}
      todos={todos}
      onChangeInput={changeInput}
      onInsert={insert}
      onToggle={toggle}
      onRemove={remove}
    />
  );
};

export default connect(
  // 비구조화 할당을 통해 todos를 분리하며
  // state.todos.input 대신 todos.input을 사용
  ({ todos }) => ({
    input: todos.input,
    todos: todos.todos,
  }),
  {
    changeInput,
    insert,
    toggle,
    remove,
  },
)(TodosContainer);

이전에 todos 모듈에서 작성했던 액션 생성 함수와 상태 안에 있던 값을 컴포넌트의 props로 전달해 주었다.

App 컴포넌트에서 보여 주던 Todos 컴포넌트를 TodosContainer 컴포넌트로 교체

import React from 'react';
import CounterContainer from './containers/CounterContainer';
import TodosConatiner from './containers/TodosConatiner';

const App = () => {
  return (
    <div>
      <CounterContainer />
      <hr />
      <TodosConatiner />
    </div>
  );
};

export default App;

Todos 컴포넌트에서 받아 온 props를 사용하도록 구현

import React from 'react';

const TodoItem = ({ todo, onToggle, onRemove }) => {
  return (
    <div>
      <input
        type="checkbox"
        onClick={() => onToggle(todo.id)}
        checked={todo.done}
        readOnly={true}
      />
      &nbsp;&nbsp;
      <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
        {todo.text}
      </span>
      &nbsp;&nbsp;
      <button onClick={() => onRemove(todo.id)}>삭제</button>
    </div>
  );
};

const Todos = ({
  input, // input에 입력되는 텍스트
  todos, // 할 일 목록이 들어 있는 객체
  onChangeInput,
  onInsert,
  onToggle,
  onRemove,
}) => {
  const onSubmit = (e) => {
    e.preventDefault();
    onInsert(input);
    onChangeInput(''); // 등록 후 인풋 초기화
  };
  const onChange = (e) => onChangeInput(e.target.value);

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input value={input} onChange={onChange} />
        <button type="submit">등록</button>
      </form>
      <div>
        {todos.map((todo) => (
          <TodoItem
            todo={todo}
            key={todo.id}
            onToggle={onToggle}
            onRemove={onRemove}
          />
        ))}
      </div>
    </div>
  );
};

export default Todos;

양이 많아서 이번 챕터는 반 나눠서 진행한다.

좋은 웹페이지 즐겨찾기