01.14 리덕스를 이용한 리액트 애플리케이션 생성

React.js

01.14. 리덕스를 이용한 리액트 애플리케이션 생성

리액트 애플리케이션에서 상태 관리를 할 때 소규모 프로젝트에서는 컴포넌트가 가진 state 기능을 사용하는 것으로 충분하지 모르지만, 규모가 커진다면 관리가 불편하다. 상태 관리 라이브러리를 사용하지 않고 state만 사용한다면 다음 문제점이 발생한다.
  • 상태 객체가 너무 복잡하고 크다.
  • 최상위 컴포넌트에서 상태 관리를 하는 메서드를 너무 많이 만들어 코드가 복잡하다.
  • 하위 컴포넌트에 props를 전달하려면 여러 컴포넌트를 거쳐야한다.

01.14.1. 작업 환결설정

-- create-react-app 로 프로젝트 생성
$ create-react-app redux-sample
yarn을 사용하여 redux와 react-redux를 설치
$ cd redux-sample
$ yarn add redux react-redux
$ yarn eject
react-redux는 리액트 컴포넌트에서 리덕스를 더욱 간편하게 사용할 수 있게 하는 라이브러리이다.

01.14.2. 프로젝트 초기 설정

src 디렉터리 내부에 필요없는 파일 제거
  • App.css
  • App.js
  • App.test.js
  • logo.svg
src 디렉터리 하위에 디렉터리 생성
  • actions: 액션 타입과 액션 생성자 파일을 저장
  • components: 컴포넌트의 뷰가 어떻게 생길지만 담당하는 프리젠테이셔널(presentational) 컴포넌트 저장
  • containers: 스토어에 있는 상태를 props로 받아 오는 컨테이너(container) 컴포넌트들을 저장
  • reducers: 스토어의 기본 상태 값과 상태의 업데이트를 담당하는 리듀서 파일들을 저장
  • lib: 일부 컴포넌트에서 함께 사용되는 파일을 저장

01.14.3. 프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트

프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트는 리덕스를 사용하는 프로젝트에서 자주 사용하는 구조로 멍청한(dumb) 컴포넌트와 똑똑한(smart) 컴포넌트라고도 알려져있다.

01.14.3.1. 프리젠테이셔널 컴포넌트

오직 뷰만 담당하는 프리젠테이셔널 컴포넌트. 안에 DOM 엘리먼트와 스타일이 있으며, 프리젠테이셔널 컴포넌트나 컨테이너 컴포넌트가 있을 수도 있다. 하지만 리덕스 스토어에 직접 접근할 권한은 없으며, 오직 props로만 데이터를 가져올 수 있다. 또 대부분은 state가 없다. 있다고 해도 데이터와 관련된 것이 아니라 UI와 관련된 것이어야 한다.
주로 함수형 컴포넌트로 작성하며, state가 있어야 하거나 최적화를 하려고 라이프사이클 메서드가 필요할 때는 클래스형 컴포넌트로 작성된다.

01.14.3.2. 컨테이너 컴포넌트

컨테이너 컴포넌트는 프리젠테이션 컴포넌트와 컨테이너 컴포넌트들의 관리를 담당한다. 내부에 DOM 엘리멘트를 직접적으로 사용할 때는 없고, 감싸는 용도일 때만 사용한다. 또 스타일도 가지고 있지 않아야 한다. 스타일은 모두 프리젠테이셔널 컴포넌트에서 정의해야한다. 상태를 가지고 있을 때가 많으며, 리덕스에 직접 접근할 수 있다.

※ 컴포넌트를 이렇게 두 카테고리로 나누면 사용자가 이용할 유저 인터페이스와 상태를 다루는 데이터가 분리되어 프로젝트를 이해하기 쉽고, 컴포넌트 재사용률이 높다.

※ 컨테이너 컴포넌트라고 해서 무조건 내부에 컴포넌트가 여러 개 있어야 하는 것은 아니다. 또 프리젠테이셔널 컴포넌트 내부에 컨테이너 컴포넌트를 넣어도 된다. 리덕스를 사용한다고 해서 이 구조를 무조건 따를 필요는 없다. 이는 리덕스 창시자 댄 아브라모프가 더 나은 설계를 하려고 공유한 구조이지만, 무조건 따라야 할 규칙은 아니다. 따라하면 유용한 팁일 수 있지만, 개발 흐름에 어울리지 않을 수도 있다.

01.14.4. 기본 Component 생성

containers 디렉터리에 App 컴포넌트를 생성한다.
// src/containers/App.js
import React, { Component } from 'react';

class App extends Component {
    render() {
        return (
            <div>Counter</div>
        );
    }
}

export default App;
index.js 파일에 생성한 App 컴포넌트를 반영한다.
// src/index.js 파일 수정
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './containers/App'; // 생성한 App 컴포넌트 수정
import * as serviceWorker from './serviceWorker';

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

serviceWorker.unregister();
프로젝트 실행
$ yarn start
화면에 Counter 텍스트만 표시가 되면 성공적으로 프로젝트가 실행된 것이다.

01.14.5. Counter 컴포넌트 생성

첫 프리젠테이셔널 컴포넌트인 카운터 컴포넌트를 생성할 것이다. 이 컴포넌트는 숫자와 색상 값, 더하기, 빼기, 색상 변경 함수를 props로 전달 받는다.
import React from 'react';
import PropTypes from 'prop-types';
import './Counter.css';

const Counter = ({number, color, onIncrement, onDecrement, onSetColor}) => {
    return (
        <div 
            className='Counter' 
            onClick={onIncrement} 
            onContextMenu={(e)=>{e.preventDefault(); onDecrement();}}
            onDoubleClick={onSetColor}
            style={{backgroundColor:color}}
        >
        {number}
        </div>
    );
};

Counter.PropTypes = {
     number      : PropTypes.number
    ,color       : PropTypes.string
    ,onIncrement : PropTypes.func
    ,onDecrement : PropTypes.func
    ,onSetColor  : PropTypes.func
};

Counter.PropTypes = {
     number      : 0
    ,color       : 'black'
    ,onIncrement : () => console.log('onIncrement not defined')
    ,onDecrement : () => console.log('onDecrement not defined')
    ,onSetColor  : () => console.log('onSetColor not defined')
};

export default Counter;
onContextMenu는 마우스 오른쪽 클릭시 메뉴가 열리는 이벤트를 의미한다. 이 함수가 실행될 때 e.preventDefault() 함수를 호출하면 메뉴가 열리는 것을 방지한다. 컴포넌트 코드의 아래쪽에서는 props 기본 값을 설정해 주었다. 카운터의 기본 숫자는 0, 색상은 검색, 함수가 전달되지 않았을 때는 console에 log를 출력하도록 설정했다.
/* src/components/Counter.css */
.Counter {
    /* 레이아웃 */
    width: 10rem;
    height: 10rem;
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 1rem;
    /* 색상 */
    color: white;
    /* 폰트 */
    font-size: 3rem;
    /* 기타 */
    border-radius: 100%;
    cursor: pointer;
    user-select: none;
    transition: background-color 0.75s;
}

01.14.6. Action Types 준비

actions 디렉터리에 ActionTypes.js 라는 디렉터리를 만들어서 상수들을 선언한다.
/* Action 종류들을 선언한다. 
   앞에 export 를 붙이면 나중에 이것들을 불러올 때,
   import * as types from './ActionTypes'; 를 할 수 있다.
 */
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const SET_COLOR = 'SET_COLOR';
액션을 선언할 때는 이처럼 대문자로 선언하면 된다.
※ export const 를 이용해 상수를 선언하면서 내보내기를 하였다.

01.14.7. 액션 생성 함수 만들기

액션을 만들 때마다 객체를 바로 생성하기는 번거로우므로 액션을 만들어 내는 함수를 만든다. 

/* action 객체를 만드는 액션 생성 함수들을 선언한다.(action creators)
   여기에서 () => ({}) 는 function () {return {}} 와 동일한 의미이다.
 */
import * as types from './ActionTypes';

export const increment = () => ({
    type: types.INCREMENT
});

export const decrement = () => ({
    type: types.DECREMENT
});

// 파라미터를 갖는 액션 생성자
export const setColor = (color) => ({
    type: types.SET_COLOR,
    color
});

01.14.8. 리듀서 생성

리듀서는 액션의 type에 따라 변화를 일으키는 함수이다. 리듀서를 작성할 때는 최초 변화를 일으키기 전 가지고 있어야 할 초기 상태를 정의해야한다.
// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태를 정의한다.
const initialState = {
    color: 'black',
    number: 0
};
리듀서 함수는 state와 action을 파라미터로 가지는 함수이며, 그 함수 내부에서 switch문으로 action.type에 따라 상태에 다른 변화를 일으킨다.
// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태를 정의한다.
const initialState = {
    color: 'black',
    number: 0
};

/* 리듀서 함수를 정의한다. 
   리듀서는 state와 action을 파라미터로 받는다.
   state가 undefined일 때 (스토어가 생성될 때) state 기본 값을 initialState로 사용한다.
   action.type에 따라 다른 작업을 하고, 새 상태를 만들어서 반환한다.
   이때 주의할 점은 state를 직접 수정하면 안되고,
   기존 상태 값에 원하는 값을 덮어쓴 새로운 객체를 만들어 반환해야 한다.
 */
function counter(state = initialState, action) {
    switch(action.type) {
        case types.INCREMENT:
            return {
                ...state,
                number: state.number + 1
            };
        case types.DECREMENT:
            return {
                ...state,
                number: state.number - 1
            };
        case types.SET_COLOR:
            return {
                ...state,
                color: action.color
            };
        default:
            return state;
    }
}

export default counter;
state를 직접 수정하면 절대 안된다. 기존 state 값에 새 상태를 엎어쓴 상태 객체를 만드는 방식으로 처리해야 한다는 것에 주의해야한다.

01.14.9. 스토어 생성

스토어는 리덕스에서 가장 핵심적인 인스턴스이다.
스토어 내부에 현재 상태가 내장되어 있고, 상태를 업데이트할 때마다 구독 중인 함수들을 호출한다.
리덕스에서 createStore를 불러와 해당 함수에 생성한 리듀서를 파라미터로 넣어 스토어를 생성한다.
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';

// 리덕스 관련
import {createStore} from 'redux';
import reducers from './reducers';

// 스토어 생성
const store = createStore(reducers);

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();

01.14.10. Provider 컴포넌트로 리액트 앱에 store 연동

Provider는 react-redux 라이브러리에 내장된 리액트 애플리케이션에 손쉽게 스토어를 연동할 수 있도록 도와주는 컴포넌트이다. 
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';

// 리덕스 관련
import {createStore} from 'redux';
import reducers from './reducers';
import {Provider} from 'react-redux';

// 스토어 생성
const store = createStore(reducers);

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();
리액트 컴포넌트에서 스토어를 사용할 준비는 완료되었다.

01.14.11. CounterContainer 컴포넌트 생성

컨테이너 컴포넌트에는 스토어가 연동되어 있다. react-redux 라이브러리의 connect 함수를 사용하여 컴포넌트를 스토어에 연결시킨다. connect 함수에는 파라미터가 세 개 들어간다.

connect([mapStateToProps],[mapDispatchToProps],[mergeProps]);
각 파라미터는 optional이기 때문에 불필요하다면 생략해도 된다. 이 파라미터들은 함수 형태이며, 컴포넌트에서 사용할 props를 반환한다.

  • mapStateToProps: store.getState() 결과 값인 state를 파라미터로 받아 컴포넌트의 props로 사용할 객체를 반환한다.
  • mapDispatchToProps: dispatch를 파라미터로 받아 액션을 디스패치하는 함수들을 객체 안에 넣어서 반환한다.
  • mergeProps: state와 dispatch가 동시에 필요한 함수를 props로 전달해야 할 때 사용하는데, 일반적으로 잘 사용하지 않는다.
connect 함수를 호출하고 나면 또 다른 함수를 반환한다. 이때 반환하는 함수의 파라미터로 리덕스에 연결시킬 컴포넌트를 넣으면 mapStateToProps와 mapDispatchToProps에서 정의한 값들을 props로 받아오는 새 컴포넌트를 만든다.

// src/containers/CounterContainer.js
import Counter from '../components/Counter';
import * as actions from '../actions';
import {connect} from 'react-redux';

// 13가지 색상 중 랜덤으로 선택하는 함수
export function getRandomColor() {
    const colors = [
        '#F0F8FF',
        '#FAEBD7',
        '#00FFFF',
        '#7FFFD4',
        '#F0FFFF',
        '#F5F5DC',
        '#FFE4C4',
        '#000000',
        '#FFEBCD',
        '#0000FF',
        '#8A2BE2',
        '#A52A2A',
        '#DEB887'
    ];

    // 0~12 랜덤 숫자
    const random = Math.floor(Math.random() * 13);

    // 랜덤 색상 변환
    return colors[random];
}

// store 안의 state 값을 props로 연결한다.
const mapStateToProps = (state) => ({
    color: state.color,
    number: state.number
});

/* 액션 생성 함수를 사용하여 액션을 생성하고, 
   해당 액션을 dispatch 하는 함수를 만든 후 이를 props로 연결한다.
 */
const mapDispatchToProps = (dispatch) => ({
    onIncrement: () => dispatch(actions.increment()),
    onDecrement: () => dispatch(actions.decrement()),
    onSetColor: () => {
        const color = getRandomColor();
        dispatch(actions.setColor(color));
    }
});

// Counter 컴포넌트의 Container 컴포넌트
// Counter 컴포넌트를 애플리케이션의 데이터 레이어와 묶는 역할을 한다.
const CounterContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(Counter);

export default CounterContainer;
이렇게 하면 mapStateToProps의 color 값, number 값과 mapDispatchToProps의 onIncrement값, onDecrement 값, onSetColor 값이 Counter 컴포넌트의 props로 들어간다. 이렇게 리덕스와 연동된 컴포넌트를 CounterContainer 안에 담아 이를 내보낸 후, App 컴포넌트에서 CounterContainer 컴포넌트를 불러와 렌더링 한다.

// src/containers/App.js
import React, {Component} from 'react';
import CounterContainer from './CounterContainer';

class App extends Component {
    render() {
        return (
            <div>
                <CounterContainer/>
            </div>
        );
    }
}

export default App;

웹 브라우저에 있는 동그라미를 마우스로 왼쪽 클릭, 오른쪽 클릭, 더블클릭을 한다. 상태가 변한다면 리덕스를 이용한 리액트 애플리케이션을 성공적으로 생성한 것이다.









01.14.12. 서브 리듀서 생성

현재 만든 리듀서는 색생과 숫자를 한 객체 안에 넣어서 관리했다. 이 리듀서를 서브 리듀서 두 개로 나누어 파일을 따로 분리시킨 후 combineReducers로 다시 합쳐 루트 리듀서를 만들어 본다.
// src/reducers/color.js
import * as types from '../actions/ActionTypes';

const initialState = {
    color: 'black'
};

const color = (state = initialState, actions) => {
    switch(actions.type) {
        case types.SET_COLOR:
            return {
                color: actions.color
            };
        default:
            return state;
    }
}

export default color;

// src/reducers/number.js
import * as types from '../actions/ActionTypes';

const initialState = {
    number: 0
};

const number = (state = initialState, actions) => {
    switch(actions.type) {
        case types.INCREMENT:
            return {
                number: state.number + 1
            };
        case types.DECREMENT:
            return {
                number: state.number - 1
            };
        default:
            return state;
    }
};

export default number;
src/reducers/index.js 에서 색상과 숫자를 color.js, number.js 2개 파일로 분리 시켰다.
src/reducers/index.js 에서 combineReducers 를 이용해 두개의 리듀서를 합쳐준다.
// src/reducers/index.js
import number from './number';
import color from './color';

import {combineReducers} from 'redux';

/* 서브 리듀서들을 하나로 합친다.
   combineReducers를 실행하고 나면, 
   나중에 store 형태를 파라미터로 전달한 객체 모양대로 만든다.
 */
const reducers = combineReducers({
    numberData: number,
    colorData: color
});

export default reducers;
combineReducers 를 호출할 때는 객체를 파라미터로 전달하는데, 이 객체 구조에 따라 합친 리듀서 상태 구조를 정의한다.
// src/containers/CounterContainer.js - mapStateToProps
// 코드 수정
const mapStateToProps = (state) => ({
    color: state.colorData.color,
    number: state.numberData.number
});
코드를 저장하고, 웹브라우저를 확인하면 동일하게 작동한다.

※ 리덕스 개발자 도구 사용
멀티 카운터를 만들기 전에 리덕스에서 액션을 디스패치할 때마다 기록을 확인하고, 이전의 상태로 돌아갈 수도 있게 하는 리덕스 개발자 도구를 설치한다.
크롬웹스토어(https://chrome.google.com/webstore?hl=ko)에서 Redux DevTools를 검색하여 크롬에 추가한다. 크롬을 종료 후 재시작하면 개발자 도구에서 Redux 탭이 활성화되어있다.
프로젝트에서 스토어를 생성할 때는 별도로 개발자 도구를 활성화하는 작업을 해야 작동한다.
// src/index.js - 코드 수정
const store = createStore(reducers, window.devToolsExtension && window.devToolsExtension());
이 도구를 사용하면 현재 리덕스 상태는 어떤지, 방금 디스패치한 액션은 무엇인지, 액션으로 어떤 값을 바꾸엇는지 확인할 수 있다.

01.14.13. 멀티 카운터 생성

카운터 개수를 늘려 조금 더 복잡한 상태를 관리한다.

01.14.13.1. 액션타입수정

카운터를 추가하는 CREATE 와 카운터를 제거하는 REMOVE를 액션타입에 추가한다.
// src/actions/ActionTypes.js
/* Action 종류들을 선언한다. 
   앞에 export 를 붙이면 나중에 이것들을 불러올 때,
   import * as types from './ActionTypes'; 를 할 수 있다.
 */
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const SET_COLOR = 'SET_COLOR';

export const CREATE = 'CREATE';
export const REMOVE = 'REMOVE';

01.14.13.2. 액션 생성 함수 수정

새로 생성한 액션 타입에 따라서 새 액션 생성 함수를 만든다. 기존 액션 생성 함수들도 앞으로 작동 방식이 달라지기 때문에 전체적으로 수정해야한다.
// src/actions/index.js
/* action 객체를 만드는 액션 생성 함수들을 선언한다.(action creators)
   여기에서 () => ({}) 는 function () {return {}} 와 동일한 의미이다.
 */
import * as types from './ActionTypes';

export const create = (color) => ({
    type: types.CREATE,
    color
});

export const remove = () => ({
    type: types.REMOVE
});

export const increment = (index) => ({
    type: types.INCREMENT,
    index
});

export const decrement = (index) => ({
    type: types.DECREMENT,
    index
});

// 파라미터를 갖는 액션 생성자
export const setColor = ({index,color}) => ({
    type: types.SET_COLOR,
    index,
    color
});

01.14.14. Reducers 수정

현재까지 만든 리듀서들과 작동 방식이 다르기 때문에 reducers 디렉터리 안에 있는 color.js , number.js를 삭제하고 index.js 파일 내용도 비우고 새로 작성한다.

01.14.14.1. 초기상태정의

// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태 정의
const initialState = {
    counters: [
        {
            color: 'black',
            number: 0
        }
    ]
}

01.14.14.2. 리듀서 함수에서 카운터 추가 및 삭제 구현

// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태 정의
const initialState = {
    counters: [
        {
            color: 'black',
            number: 0
        }
    ]
}

function counter(state = initialState, action) {
    // 레퍼런스 생성
    const {counters} = state;

    switch(action.type) {
        case types.CREATE:
            return {
                counters: [
                    ...counters,
                    {
                        color: action.color,
                        number: 0
                    }
                ]
            };
        case types.REMOVE:
            return {
                counters: counters.slice(0, counters.length - 1)
            };
        default:
            return state;
    }
}

export default counter;
배열을 업데이트하는 것은 setState로 컴포넌트의 state 안에 있는 배열을 다룰 때와 동일하다. 기존 배열에 배열 함수를 사용하여 값을 변경하면 안되고, 전개 연산자(...)를 사용하거나 slice함수로 배열을 잘라서 새로 생성해야한다.

01.14.14.3. 리듀서 함수에 증가, 감소, 색상 변경 구현

// src/reducers/index.js
(...)
function counter(state = initialState, action) {
    // 레퍼런스 생성
    const {counters} = state;

    switch(action.type) {
        (...)
        case types.INCREMENT:
            return {
                counters: [
                    ...counters.slice(0, action.index), // 선택한 인덱스의 전 아이템들
                    {
                        ...counters[action.index], // 기존 객체에
                        number: counters[action.index].number + 1 // 새 number 값 덮어쓰기

                    },
                    ...counters.slice(action.index + 1, counters.length), // 선택한 인덱스의 다음 아이템들
                ]
            };
        case types.DECREMENT:
            return {
                counters: [
                    ...counters.slice(0, action.index),
                    {
                        ...counters[action.index],
                        number: counters[action.index].number - 1
                    },
                    ...counters.slice(action.index + 1, counters.length)
                ]
            };
        case types.SET_COLOR:
            return {
                counters: [
                    ...counters.slice(0, action.index),
                    {
                        ...counters[action.index],
                        color: action.color
                    },
                    ...counters.slice(action.index + 1, counters.length)
                ]
            };
        default:
            return state;
    }
}

export default counter;
배열 내부 아이템들을 수정하는 자업이 그렇게 어렵지는 않지만, 간단한 작업을 하나 하자고 가끔 코드를 필요 이상으로 많이 작성하는 것은 사실이다.
멀티 카운터 후에 Immutable 라이브러리 사용할 것이다. Immutable 라이브러리를 사용하면 배열을 수정할 때나 여러 층으로 깊이 감싼 객체를 수정할 때 더욱 가독성이 높고 쉽게 구현도 할 수 있다.

01.14.15. 프리젠테이셔널 컴포넌트 생성

카운터 생성 및 제거를 담당하는 Buttons 컴포넌트와 카운터 여러 개를 렌더링할 CounterList를 만든다.

01.14.15.1. 생성, 제거 버튼 - Buttons 컴포넌트 생성

이 컴포넌트는 버튼 두 개를 내장하고 있으며, 새 카운터를 생성하는 onCreate 함수, 마지막 카운터를 제거할 onRemove 함수를 props로 전달받는다.
import React from 'react';
import PropTypes from 'prop-types';

import './Buttons.css';

const Buttons = ({onCreate, onRemove}) => {
    return (
        <div className="Buttons">
            <div className="btn add" onClick={onCreate}>생성</div>
            <div className="btn remove" onClick={onRemove}>제거</div>
        </div>
    );
}

Buttons.propTypes = {
    onCreate: PropTypes.func,
    onRemove: PropTypes.func
};

Buttons.propTypes = {
    onCreate: () => console.warn('onCreate not defined'),
    onRemove: () => console.warn('onRemove not defined')
};

export default Buttons;

/* src/components/Buttons.css */
.Buttons {
    display: flex;
}

.Buttons .btn {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 3rem;

    color: white;
    font-size: 1.5rem;
    cursor: pointer;
}

.Buttons .add {
    background: lightblue;
}

.Buttons .add:hover {
    background: lightskyblue
}

.Buttons .remove {
    background: lightpink;
}

.Buttons .remove:hover {
    background: lightcoral;
}

01.14.15.2. 여러 카운터를 렌더링: CounterList 컴포넌트 생성

여러 카운터를 렌더링할 CounterList 컴포넌트를 만든다. 이 컴포넌트는 카운터 객체들의 배열 counters 와 카운터 값을 조작하는 onIncrement, onDecrement, onSetColor 함수를 props로 전달받는다.
이 컴포넌트 내부에서 counters 배열을 Counter 컴포넌트의 배열로 map 할 것이다. key는 배열의 index로 설정하고,  index 값도 컴포넌트에 props로 전달한다. 그리고 color 값과 number 값을 일일이 설정하는 대신 {...counter} 를 JSX 태그 내부에 넣어주면 해당 값들을 풀어서 각 값을 한꺼번에 전달할 수 있다.
// src/components/CounterList.js
import React from 'react';
import Counter from './Counter';
import PropTypes from 'prop-types';

import './CounterList.css';

const CounterList = ({counters, onIncrement, onDecrement, onSetColor}) => {
    const counterList = counters.map(
        (counter, i) => (
            <Counter
                key={i}
                index={i}
                {...counter}
                onIncrement={onIncrement}
                onDecrement={onDecrement}
                onSetColor={onSetColor}/>
        )
    );

    return (
        <div className="CounterList">
            {counterList}
        </div>
    );
};

CounterList.propTypes = {
    counters: PropTypes.arrayOf(PropTypes.shape({
                  color: PropTypes.string, number: PropTypes.number
              })),
    onIncrement: PropTypes.func,
    onDecrement: PropTypes.func,
    onSetColor: PropTypes.func
};

CounterList.defaultProps = {
    counters: []
};

export default CounterList;

/* src/components/CounterList.css */
.CounterList {
    margin-top: 2rem;
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
}

01.14.15.2. Counter 컴포넌트 수정

CounterList 에서 전달받은 index 를 각 이벤트를 실행할 때 함수의 파라미터로 넣어서 실행할 수 있게 한다.
// src/components/Counter.js
import React from 'react';
import PropTypes from 'prop-types';
import './Counter.css';

const Counter = ({number, color, index, onIncrement, onDecrement, onSetColor}) => {
    return (
        <div
            className="Counter"
            onClick={() => onIncrement(index)}
            onContextMenu={(e) => {
                e.preventDefault();
                onDecrement(index);
            }}
            onDoubleClick={() => onSetColor(index)}
            style={{backgroundColor:color}}
        >
            {number}
        </div>
    );
};

Counter.propTypes = {
    index: PropTypes.number,
    number: PropTypes.number,
    color: PropTypes.string,
    onIncrement: PropTypes.func,
    onDecrement: PropTypes.func,
    onSetColor: PropTypes.func
};

Counter.propTypes = {
    index: 0,
    number: 0,
    color: 'black',
    onIncrement: () => console.warn('onIncrement not defined'),
    onDecrement: () => console.warn('onDecrement not defined'),
    onSetColor: () => console.warn('onSetColor not defined')
};

export default Counter;

프리젠테이셔널 컴포넌트를 생성해서 어떻게 컴포넌트를 보여 줄 것인지 준비가 되었다.

01.14.16. 컨테이너 컴포넌트

기존 컨테이너 컴포넌트인 CounterContainer는 삭제한다. Buttons는 따로 컨테이너 컴포넌트를 만들어 주지 않고, App 컴포넌트를 리덕스에 연결하여 액션 함수도 연결시키고, 해당 함수들을 Buttons 컴포넌트에 전달한다.
// src/containers/CounterListContainer.js
import CounterList from '../components/CounterList';
import * as actions from '../actions';
import {connect} from 'react-redux';

// 색상 랜덤 선택 함수
export function getRandomColor() {
    const colors = [
        '#FFFFF0',
        '#F0E68C',
        '#E6E6FA',
        '#FFF0F5',
        '#7CFC00',
        '#FFFACD',
        '#ADD8E6',
        '#F08080',
        '#E0FFFF',
        '#FAFAD2',
        '#D3D3D3',
        '#90EE90',
        '#FFB6C1',
        '#FFA07A',
        '#20B2AA',
        '#87CEFA',
        '#778899',
        '#B0C4DE',
        '#FFFFE0' 
    ];

    // 랜덤 숫자
    const random = Math.floor(Math.random() * 20);

    // 랜덤 색상 반환
    return colors[random];
}

// store 안의 state 값을 로 연결한다.
const mapStateToProps = (state) => ({counters: state.counters});

/* 액션 생성자를 사용하여 액션을 만들고,
   해당 액션을 dispatch 하는 함수를 만든 후 이를 props로  연결한다.
 */
const mapDispatchToProps = (dispatch) => ({
    onIncrement: (index) => dispatch(actions.increment(index)),
    onDecrement: (index) => dispatch(actions.decrement(index)),
    onSetColor: (index) => {
        const color = getRandomColor();
        dispatch(actions.SetColor({index, color}));
    }
})

 // 데이터와 함수들이 props로 붙은 컴포넌트 생성
 const CounterListContainer = connect(mapStateToProps, mapDispatchToProps)(CounterList);

 export default CounterListContainer;
생성한 CounterListContainer.js 에서 랜덤 색상 생성 함수는 다음에 수정할 App 컴포넌트에서도 사용한다. 코드가 중복되므로 해당 함수는 lib 디렉터리를 따로 만들어 저장한 후 불러와 사용한다.
// src/lib/getRandomColor.js
// 색상 랜덤 선택 함수
export default function getRandomColor() {
    const colors = [
        '#FFFFF0',
        '#F0E68C',
        '#E6E6FA',
        '#FFF0F5',
        '#7CFC00',
        '#FFFACD',
        '#ADD8E6',
        '#F08080',
        '#E0FFFF',
        '#FAFAD2',
        '#D3D3D3',
        '#90EE90',
        '#FFB6C1',
        '#FFA07A',
        '#20B2AA',
        '#87CEFA',
        '#778899',
        '#B0C4DE',
        '#FFFFE0' 
    ];

    // 랜덤 숫자
    const random = Math.floor(Math.random() * 20);

    // 랜덤 색상 반환
    return colors[random];
}
CounterListContainer 컴포넌트에서 랜덤 색상 생성 함수를 제거하고, 위쪽에 함수를 불러온다.
// src/containers/CounterListContainer.js
import CounterList from '../components/CounterList';
import * as actions from '../actions';
import {connect} from 'react-redux';
import getRandomColor from '../lib/getRandomColor';

// store 안의 state 값을 로 연결한다.
const mapStateToProps = (state) => ({counters: state.counters});

/* 액션 생성자를 사용하여 액션을 만들고,
   해당 액션을 dispatch 하는 함수를 만든 후 이를 props로  연결한다.
 */
const mapDispatchToProps = (dispatch) => ({
    onIncrement: (index) => dispatch(actions.increment(index)),
    onDecrement: (index) => dispatch(actions.decrement(index)),
    onSetColor: (index) => {
        const color = getRandomColor();
        dispatch(actions.SetColor({index, color}));
    }
})

 // 데이터와 함수들이 props로 붙은 컴포넌트 생성
 const CounterListContainer = connect(mapStateToProps, mapDispatchToProps)(CounterList);

 export default CounterListContainer;

01.14.17. App 컴포넌트 수정

App 컴포넌트를 리덕스에 연결한다. 이 컴포넌트에는 store 에서 필요한 값이 없으니 mapStateToProps는 null 로 설정하고, 버튼용 mapDispatchToProps 를 만든다.
이 컴포넌트에서 onCreate와 onRemove를 만들고, Buttons 컴포넌트의 props 로 전달한다.
// src/containers/App.js
import React, {Component} from 'react';
import Buttons from '../components/Buttons';
import CounterListContainer from './CounterListContainer';
import getRandomColor from '../lib/getRandomColor';

import {connect} from 'react-redux';
import * as actions from '../actions';

class App extends Component {
    render() {
        const {onCreate, onRemove} = this.props;
        return (
            <div className="App">
                <Buttons 
                    onCreate={onCreate}
                    onRemove={onRemove}
                />
                <CounterListContainer/>
            </div>
        );
    }
}

// 액션 생성 함수 준비
const mapToDispatch = (dispatch) => ({
    onCreate: () => dispatch(actions.create(getRandomColor())),
    onRemove: () => dispatch(actions.remove())
});

// 리덕스에 연결시키고 내보내기
export default connect(null, mapToDispatch)(App);

리덕스를 사용해서 리액트 프로젝트를 생성해보았다. 프로젝트를 만드는 과정이 복잡해졌다고 느낄 수 있으나 프로젝트에 필요한 상태가 복잡할 때를 대비하기 위해서 리덕스를 사용한다.






















댓글

이 블로그의 인기 게시물

01.11 리액트 컴포넌트 CSS 적용 ( CSS Module, Sass, styled-components )

01.9 React 컴포넌트 라이프사이클

01.7 React ref (DOM에 이름 달기)