본문 바로가기

Front/React

[React] 리덕스를 사용하여 리액트 애플리케이션 상태 관리하기

  • 리액트 애플리케이션에서 리덕스를 사용하면, 상태 업데이트에 관한 로직을 모듈로 따로 분리하여 컴포넌트 파일과 별개로 관리할 수 있으므로 코드를 유지 보수 하는데 도움이 된다.
  • 여러 컴포넌트에서 동일한 상태를 공유해야할 때 매우 유용
  • 실제 업데이트가 필요한 컴포넌트만 리렌더링되도록 쉽게 최적화해 줄 수 있다.
  • 리액트 애플리케이션에서 리덕스를 사용할 때는 store인스턴스를 직접 사용하기보다는 주로 react-redux라는 라이브러리에서 제공하는 유틸 함수(connect)와 컴포넌트(Provider)를 사용하여 리덕스 관련 작업을 처리

 

1. 작업 환경 설정

프로젝트 생성 미치 라이브러리 설치

$ yarn create react-app react-redux-tutorial

$ cd react-redux-tutorial

$ yarn add redux react-redux

 

Prerrier를 적용하고 싶다면 디렉터리에 다음과 같이 .prerrierrc 파일을 작성하세요.

{
    "singleQuote": true,
    "semi": true,
    "useTabs": false,
    "tabWidth": 2,
    "trailingComma": "all",
    "printWidth": 80
}

 

2. UI 준비하기

  • 리액트 프로젝트에서 리덕스르 사용할 때 가장 많이 사용하는 패턴 : 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트의 분리
    • 프리젠테이셔널 컴포넌트 : 주로 상태관리가 이루어지지 않고, 그저 props를 받아 와서 화면에 UI를 보여 주기만 하는 것
    • 컨테이너 컴포넌트 : 리덕스와 연동되어 있는 컴포넌트로, 리덕스로부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치하기도 한다.
    • 장점 : 코드의 재사용성 높아짐, 관심사의 분리가 이루어져 UI를 작성할 때 좀 더 집중할 수 있다.

2.1. 카운터 컴포넌트 만들기

[components/Counter.js]

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.js]

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

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

export default App;

 

[결과]

 

2.2. 할 일 목록 컴포넌트 만들기

 

[components/Todos.js]

import React from 'react';

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

const Todos = ({
    input, // 인풋에 입력되는 텍스트
    todos, // 할 일 목록이 들어 있는 객체
    onChangeInput, 
    onInsert,
    onToggle,
    onRemove,
}) => {
    const onSunmit = e => {
        e.preventDefault();
    };
    return (
        <div>
            <form onSubmit={onSunmit}>
                <input />
                <button type='submit'>등록</button>
            </form>
            <div>
                <TodoItem />
                <TodoItem />
                <TodoItem />
                <TodoItem />
                <TodoItem />
            </div>
        </div>
    );
};

export default Todos;

 

[App.js]

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;

 

[결과]

 

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

3.1.1. 액션 타입 정의하기

[modules/counter.js]

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
  • 액션타입 정의
    • 액션타입 : 대문자
    • 문자열 내용 : 모듈이름 / 액션이름

 

3.1.2. 액션 생성 함수 만들기

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

export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
  • export라는 키워드 => 추후 이 함수를 다른 파일에 불러와 사용할 수 있다.

 

 3.1.3. 초기 생성 및 리듀서 함수 만들기

[modules/counter.js]

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 값 설정
  • 리듀서 함수에는 현재 상태를 참조하여 새로운 객체를 생성해서 반환하는 코드를 작성
  • export 와 export default의 차이점
    • export - 여러 개를 내보낼 수 있다.
    • export default - 단 한 개만 내보낼 수 있다.
  • 불러오는 방식도 다음과 같이 다르다.
import counter from './counter';
import { increase, decrease } from '.counter';
// 한꺼번에 불러오고 싶을 때
import counter, { increase, decrease } from './counter';

 

3.2.todos 모듈 만들기

3.2.1. 액션 타입 정의하기

  • 가장 먼저 해야할 일은 액션 타입 정의

[modules/todos.js]

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

 

3.2.2. 액션 생성 함수 만들기

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

[modules/todos.js]

const CHANGE_INPUT = 'todos/CHANGE_IMPUT'; // 인풋 값을 변경함
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; //insert가 호출될 때마다 1씩 더해집니다.
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는 todo 객체가 돌고 있게 될 고윳값
    • 액션 생성 함수는 호출될 때마다 id값에 1씩 더해준다.

3.2.3. 초기 상태 및 리듀서 함수 만들기

  • 모듈의 초기 상태와 리듀서 함수를 작성
  • 객체에 한 개 이상의 값이 들어가므로 불변성을 유지해야 해서 조금 까다로워짐
    • spread(...) 연산자 활용
  • 배열에 변화를 줄 때는 배열 내장 함수를 사용하여 구현하면 됨

[modules/todos.js]

const CHANGE_INPUT = 'todos/CHANGE_IMPUT'; // 인풋 값을 변경함
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; //insert가 호출될 때마다 1씩 더해집니다.
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 = {
    imput: '',
    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;

 

3.3. 루트 리듀서 만들기

[modules/index.js]

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

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

export default rootReducer;
  • 이번 프로젝트에서 리듀서를 여러 개 만들었다. 나중에 createStore함수를 사용하여 스토어를 만들 때는 리듀서를 하나만 사용해야한다. 
    • 그렇기 때문에 기존에 만들었던 리듀서를 하나로 합쳐줘야 한다.
    • 이 작업은 리덕스에서 제공하는 combineReducers라는 유틸 함수를 사용하면 쉽게 처리할 수 있다.
  • 파일이름을 index.js로 설정해 주면 나중에 불러올 때 디렉터리 이름까지만 입력하여 불러올 수 있다.
    • import rootReducers from './modules';

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

4.1. 스토어 만들기

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

const store = createStore(rootReducer);

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

reportWebVitals();

 

4.2. Provider 컴포넌트 사용하여 프로젝트에 리덕스 적용하기

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

[src/index.js]

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 reportWebVitals from './reportWebVitals';
import rootReducer from './modules';

const store = createStore(rootReducer);

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

reportWebVitals();

 

 

4.3. Redux DevTools의 설치 및 적용

  • Redux DevTools는 리덕스 개발자 도구이며, 크롬 확장 프로그램으로 설치하여 사용할 수 있다.
  • 크롬웹스토어(https://chrome.google.com/webstore/)에서 Redux DevTools를 검색하여 설치해준다.

코드를 더 깔끔하게 사용하기 위해 패키지도 설치

$ 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 reportWebVitals from './reportWebVitals';
import rootReducer from './modules';

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

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

reportWebVitals();

 

크롬 개발자 도구 실행  후 Redux 탭 열기

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

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

5.1. CounterContainer 만들기

[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)(연동할 컴포넌트)
  • mapStateProps - 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수
  • mapDispatchToProps - 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수
  • 이렇게 connect 함수를 호출하고 나면 또 다른 함수를 반환한다.
    • 반환된 함수에 컴포넌트를 파라미터로 넣어 주면 리덕스와 연동된 컴포넌트가 만들어진다.
  • 위 코드를 더 쉽게 풀면 다음과 같은 형태이다.
const makeContainer = connect(mapStateToProps, mapDispatchToProps)
makeContainer(타깃 컴포넌트)
  • 이제 CounterContainer 컴포넌트에서 connect를 사용해보자

[containers/CountContainer.js]

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와 mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달된다.
  • mapStateToProps는 state를 파라미터로 받아 오며, 이 값은 현재 스토러가 지니고 있는 상태를 가리킨다.
  • mapDispatchToProps에서는 진행 절차를 설명하기 위해 임시로 console.log를 사용한다.
  • App에서 Counter를 CounterContainer로 교체하겠다.

[App.js]

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대신 액션 생성 함수를 불러와서 액션 객체를 만들고 디스패치 해주겠다.

 

[containers.CounterContainer.js]

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 함수 내부에 익명 함수로 선언해도 문제가 되지 않는다.
    • 어떻게 보면 코드가 더 깔끔해지기도 한다.

[containers/CounterContainer.js]

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.count.number,
    }),
    dispatch => ({
        increase: () => dispatch(increase()),
        decrease: () => dispatch(decrease()),
    }),
)(CounterContainer);
  • 위 코드에서는 액션 생성 함수를 호출하여 디스패치하는 코드가 한 줄이기 때문에 불필요한 코드 블록을 생략해 주었다.
  • 컴포넌트에서 액션을 디스패치하기 위해 각 액션 생성 함수를 호출하고 dispatch로 감싸는 작업이 조금 번거로울 수도 있다.
    • 특히 액션 생성 함수의 개수가 많아진다면 더더욱 그럴 것이다.
  • 이와 같은 경우 리덕스에서 제공하는 bindActionCreators 유틸함수를 사용하면 간편하다.

[containers/CounterContainer.js]

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

 

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

 

[containers/CounterContainer.js]

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 작업을 대신해준다.

 

5.2. TodosContainer 만들기

  • CounterContainer를 만들때 배웠던 connect 함수를 사용하고, mapDispatchToProps를 짧고 간단하게 쓰는 방법을 적용해서 코드를 작성하겠다.

[containers/TodosContainer.js]

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컴포넌트로 교체해보자.

[App.js]

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

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

export default App;

 

그 다음 Todos컴포넌트에서 받아 온 props를 사용하도록 구현해보자

 

[components/Todos.js]

import React from 'react';

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

const Todos = ({
    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;

 

[결과]