Redux 를 통한 React 어플리케이션 상태 관리 :: 5장. 주소록에 Redux 끼얹기


이 튜토리얼은 5개의 포스트로 나뉘어진 이어지는 강좌입니다. 목차를 확인하시려면 여기를 참고하세요.

5장. 주소록에 Redux 끼얹기

이번 장에서는 이전에 우리가 만들었었던 주소록 프로젝트에 Redux 를 적용해보겠습니다. 기존의 코드에선 프로젝트의 모든 로직을 App 컴포넌트에서 관리했었습니다. 사실상, 이 프로젝트는 소규모 프로젝트이기 때문에 App 컴포넌트에서 관리하는것만으로도 충분하긴 합니다만, 이를 각 기능마다 분리해준다면 훨씬 코드가 간결해지고, 유지보수를 하는것도 더욱 쉬워집니다.

현재 App.js 의 코드의 길이가 320줄인데요, 이 안의 모든 로직을 분리시키고, 46줄 정도로 분리를 시켜보도록 하겠습니다.

이번 실습에서는, 지금까지 우리가 배운것들을 모두 응용해보겠습니다. 이 실습을 마치고나면 리덕스가 훨씬 친숙하게 느껴질거에요.

프로젝트 클론하기

이전에 만든 프로젝트 디렉토리에서 이번 튜토리얼을 진행하거나, git을 통하여 프로젝트를 clone하세요.
$ git clone https://github.com/vlpt-playground/react-contact.git
$ cd react-contact
$ yarn
$ yarn start

이 프로젝트는, 패스트캠퍼스 오프라인 강의에서 다룬 프로젝트입니다. 주소록을 만드는 과정을 다룬 문서는 아직 공개되지 않았습니다. 이 문서는 나중에 공개 될 예정입니다.

코드는 주석이 잘 달려있는 편이니 한번 훑어보시고 강의를 진행해주세요. 특히 App.js 파일을 주시하세요.

주소록을 다루는 문서는 5월 27일 이후에 공개됩니다.

이 강의에서 다루는건, 리액트 컴포넌트들을 만드는게 아니라, 기존 데이터 관리 로직을 리덕스쪽으로 넘기는것을 다루면서 구조를 만드는것을 다루는것이니 기존 프로젝트를 직접 만들어본적이 없어도 큰 문제없이 진행 할 수 있을거예요.

 

의존 모듈 설치하기

리덕스를 적용하기 위하여 필요한 패키지들을 설치하겠습니다.

yarn add redux redux-actions react-redux immutable react-immutable-proptypes

react-immutable-proptypes 은 immutable 에 호환되는 propTypes 입니다.

파일구조 설정하기

src 디렉토리에 다음 디렉토리를 생성하세요:

  • containers
  • modules

준비가 완료되었으니 본격적으로 시작해봅시다!

앞으로 작성할 코드는 Github 에서 열람 할 수 있습니다.

 

5-1. 모듈 만들기

모듈은, 4장에서 배운 Duck 구조에서 사용하는 액션, 액션생성자, 리듀서가 모두 들어가있는 파일입니다.

우리는 어플리케이션의 기능별로 모듈들을 분리시킬껀데요, 앞으로 만들 모듈들은 다음과 같습니다.

  • base: 뷰, 검색 인풋
  • modal: 모달
  • contacts : 주소록 데이터

그럼, 순서대로 만들어보도록 하겠습니다.

base 모듈 만들기

src/modules/base.js

import { createAction, handleActions } from 'redux-actions';
import { Map } from 'immutable';

const CHANGE_SEARCH = 'base/CHANGE_SEARCH';
const SET_VIEW = 'base/SET_VIEW';

export const changeSearch = createAction(CHANGE_SEARCH); // keyword
export const setView = createAction(SET_VIEW); // view

const initialState = Map({
    keyword: '',
    view: 'favorite' // favorite, list
});

export default handleActions({
    [CHANGE_SEARCH]: (state, action) => state.set('keyword', action.payload),
    [SET_VIEW]: (state, action) => state.set('view', action.payload)
}, initialState)

이 모듈에서는 검색 인풋과, 뷰를 관리합니다. 간단하지요?

src/modules/modal.js

import { createAction, handleActions } from 'redux-actions';
import { Map } from 'immutable';

const SHOW = 'modal/SHOW';
const HIDE = 'modal/HIDE';
const CHANGE = 'modal/CHANGE';

export const show = createAction(SHOW); // { mode, contact: {[id], name, phone, color} }
export const hide = createAction(HIDE);
export const change = createAction(CHANGE); // { name, value }

const initialState = Map({
    visible: false,
    mode: null, // create, modify
    contact: Map({
        id: null,
        name: '',
        phone: '',
        color: 'black'
    })
});

export default handleActions({
    [SHOW]: (state, action) => {
        const { mode, contact } = action.payload;

        return state.set('visible', true)
                    .set('mode', mode)
                    .set('contact', Map(contact))
    },
    [HIDE]: (state, action) => state.set('visible', false),
    [CHANGE]: (state, action) => {
        const { name, value } = action.payload;

        return state.setIn(['contact', name], value);
    }
}, initialState)

이 모듈에서는 모달을 띄우고, 숨기고, 그리고 그 안에있는 인풋들의 값을 수정하는 액션들을 관리합니다.

contacts 모듈 만들기

src/modules/contacts.js

import { Map, List } from 'immutable';
import { createAction, handleActions } from 'redux-actions';

const CREATE = 'contact/CREATE';
const MODIFY = 'contact/MODIFY';
const REMOVE = 'contact/REMOVE';
const TOGGLE_FAVORITE = 'contact/TOGGLE_FAVORITE';

export const create = createAction(CREATE); // { id, name, phone, color }
export const modify = createAction(MODIFY); // { id, contact: { name, phone } }
export const remove = createAction(REMOVE); // id
export const toggleFavorite = createAction(TOGGLE_FAVORITE); // id

const initialState = List([
    Map({
        "id": "SyKw5cyAl",
        "name": "김민준",
        "phone": "010-0000-0000",
        "color": "#40c057",
        "favorite": true
    }),
    Map({
        "id": "r1s_9c10l",
        "name": "아벳",
        "phone": "010-0000-0001",
        "color": "#12b886",
        "favorite": true
    }),
    Map({
        "id": "BJcFqc10l",
        "name": "베티",
        "phone": "010-0000-0002",
        "color": "#fd7e14",
        "favorite": false
    }),
    Map({
        "id": "BJUcqqk0l",
        "name": "찰리",
        "phone": "010-0000-0003",
        "color": "#15aabf",
        "favorite": false
    }),
    Map({
        "id": "rJHoq91Cl",
        "name": "데이비드",
        "phone": "010-0000-0004",
        "color": "#e64980",
        "favorite": false
    })
]);

export default handleActions({
    [CREATE]: (state, action) => {
        return state.push(Map(action.payload));
    },
    [MODIFY]: (state, action) => {
        const index = state.findIndex(contact => contact.get('id') === action.payload.id);

        return state.mergeIn([index], action.payload.contact);
    },
    [REMOVE]: (state, action) => {
        const index = state.findIndex(contact => contact.get('id') === action.payload);

        return state.delete(index);
    },
    [TOGGLE_FAVORITE]: (state, action) => {
        const index = state.findIndex(contact => contact.get('id') === action.payload);
        return state.update(index, contact => contact.set('favorite', !contact.get('favorite')));
    }
}, initialState)

이번 모듈의 상태는 Immutable List 형태로 되어 있습니다. CREATE 를 제외한 액션들은 주소록의 id 를 가지고 index 를 찾아야하니, findIndex 함수를 통하여 index 를 구하세요.

combineReducers 로 리듀서 합치기

여러개로 분리된 서브 리듀서들을 하나로 합치겠습니다.

src/modules/index.js

import { combineReducers } from 'redux';

import base from './base';
import contacts from './contacts';
import modal from './modal';

export default combineReducers({
    base,
    contacts,
    modal
});

1-10 섹션에서도 combineReducers 를 다뤄본적이 있었죠? 그때 다뤘던 예제에서는 분리할 필요가 없는 리듀서를 억지로 분리시킨 느낌이였는데, 이번에는, 기능별로 제대로 구분을 해서 분리를 하니, 리듀서 분리의 필요성이 느껴지지 않나요? 모든 액션을 한곳에서 관리하는것보다는, 이렇게 서브 리듀서들을 분류를 해서 관리하는것이 훨씬 편합니다.

스토어 생성하기

src/index.js 파일에서 방금 합친 리듀서를 불러와서 스토어를 생성해주세요. 이 과정에서, Redux DevTools 도 호환시켜주겠습니다.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

import { createStore } from 'redux';
import reducers from './modules';
import { Provider } from 'react-redux';
import './index.css';

// 스토어 생성
const store = createStore(reducers, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__());

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

 

 

5-2. App 컴포넌트 정리하기

앞으로 App 컴포넌트 안에 있던 대부분의 코드를 지우고 각각 다른 파일에 재작성을 할건데요, 그 전에, 필요없는 코드들을 정리하도록 하겠습니다.

src/App.js

import React, { Component } from 'react';
import Header from './components/Header';
import Container from './components/Container';

class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                {/* ViewSelectorContainer */}

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    {/* FavoriteListContainer */}
                </Container>
                <Container visible={view==='list'}>
                    {/* InputContainer */}
                    {/* ContactListContainer */}
                </Container>

                {/* ContactModalContainer */}
                {/* FloatingButtonContainer */}
            </div>
        );
    }
}



export default App;

대부분의 코드를 날리고, 일부 컴포넌트는 컨테이너 컴포넌트로 만들것이여서 주석으로 처리했습니다.

generateRandomColor 함수는 나중 다시 필요해지니 따로 복사해두세요.

앞으로 만들 컨테이너 목록을 한번 살펴보겠습니다:

  • ViewSelectorContainer
  • FavoriteListContainer
  • InputContainer
  • ContactListContainer
  • ContactModalContainer
  • FloatingButtonContainer

우리는 대부분의 컴포넌트를 컨테이너로 만들겁니다.

그리고, App 컴포넌트도 리덕스에 연결해주어 view 값을 전달 시키겠습니다.

실무에서는, 어떤것들을 컨테이너로 만들지 정하는것은 여러분들의 자유입니다.

방식은 크게 두가지로 나뉘어지는데, 지금 하는 것 처럼 주요 기능을 맡은 컴포넌트들을 컨테이너로 따로 만들어주는것과, 페이지 단위 (지금의 경우에는 App) 로 컨테이너로 만들어주는 것 입니다.

둘 다 장단점이 있는데, 컨테이너 컴포넌트를 여러개로 만들 때의 장점은 이를 분리하는 시간이 들어간다는점이고, 장점은 유지보수를 할 때 모두 기능별로 모듈화가 되어있기 때문에 관리하기 쉬워진다는 점 입니다.

페이지단위로 범위를 크게 잡아서 컨테이너로 만들 경우에는, 장점으로는 개발시간이 단축되지만, 단점으로는 일일히 props 로 전달을 해야하고, 코드가 길어집니다.

하지만, 정해진 규칙같은건 없으니, 여러가지를 시도해보시고 자신에게 맞는 방식을 찾는것이 중요합니다.

App 컴포넌트 리덕스에 연결하기

src/App.js

import React, { Component } from 'react';
import Header from './components/Header';
import Container from './components/Container';
import { connect } from 'react-redux'

class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                {/* ViewSelectorContainer */}

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    {/* FavoriteListContainer */}
                </Container>
                <Container visible={view==='list'}>
                    {/* InputContainer */}
                    {/* ContactListContainer */}
                </Container>

                {/* ContactModalContainer */}
                {/* FloatingButtonContainer */}
            </div>
        );
    }
}

export default connect(
    (state) => ({
        view: state.base.get('view')
    })
)(App);

이번에는, mapDispatchToProps 함수를 따로 만들어주지 않고 connect 내부에서 바로 정의를 해주었습니다. 주의 하실 점은, 애로우 함수를 작성 할 때, 화살표 다음 {} 객체를 () 괄호로 감싸야 한다는 점 입니다. 만약에 => ({ ... }) 이 아닌 => { } 으로 하시면, 객체가 생성되는것이 아니라 코드블록이 생기므로 오류가 발생합니다.

 

5-3. ViewSelectorContainer 컴포넌트 만들기

기존의 ViewSelector 컴포넌트를 컨테이너로 만들어주겠습니다.

src/containers/ViewSelectorContainer.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as baseActions from '../modules/base';
import ViewSelector from '../components/ViewSelector';

class ViewSelectorContainer extends Component {
    handleSelect = (view) => {
        const { BaseActions } = this.props;
        BaseActions.setView(view);
    }

    render() {
        const { view } = this.props;
        const { handleSelect } = this;

        return (
            <ViewSelector selected={view} onSelect={handleSelect}/>
        );
    }
}

// 리덕스에 연결
export default connect(
    (state) => ({
        view: state.base.get('view')
    }),
    (dispatch) => ({
        // bindActionCreators 는 액션함수들을 자동으로 바인딩해줍니다.
        BaseActions: bindActionCreators(baseActions, dispatch)
    })
)(ViewSelectorContainer);

컴포넌트를 컨테이너로 만들 때에는, 기존 컴포넌트가 어떤 props 를 필요로 하는지 참조해서 만드세요.

이번 코드에서는 bindActionCreators 라는 함수가 사용되었습니다. 이는 리덕스에 내장되어있는 함수인데요, 이 함수는 액션을 dispatch 하는 과정을 손쉽게 해줍니다.

원래는 mapDispatchToProps 함수를 따로 만들어서, 내부에서

const mapDispatchToProps = (dispatch) => ({
    handleSelect: (view) => dispatch(baseActions.setView(view))
})

이런 작업을 해주어야 했었겠죠?

bindActionCreators 를 사용하면 액션함수를 모두 자동으로 설정해줍니다. 지금의 경우에는 base 모듈에 있는 모든 액션함수를 불러와서 이를 dispatch 하는 함수를 만들어서 props 로 BaseActions 라는 객체안에 넣어서 전달해주었습니다.

이 코드는 다음과 동일합니다:

(dispatch) => ({
    BaseActions: {
        setView: (payload) => dispatch(baseActions.setView(payload)),
        changeSearch: (payload) => dispatch(baseActions.changeSearch(payload))
    }
})

이렇게 설정한 함수들은, 렌더링 할 때 직접 실행을 해도 되고, 우리가 이번에 작성한 코드처럼, 컴포넌트 안에 여러분의 컨벤션에 따라 함수를 새로 생성해서 호출해주어도 됩니다.

bindActionCreators 는 이번 컴포넌트처럼 다루는 액션의 수가 적을땐 그리 유용하지 않지만, 액션의 수가 많아질땐 매우 유용합니다.

App 에서 불러와서 사용하기

리덕스에 연결 컴포넌트 App 컴포넌트에서 불러와서 렌더링하세요.

src/App.js

import React, { Component } from 'react';
import Header from './components/Header';
import Container from './components/Container';
import { connect } from 'react-redux'

import ViewSelectorContainer from './containers/ViewSelectorContainer';

class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                <ViewSelectorContainer/>

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    {/* FavoriteListContainer */}
                </Container>
                <Container visible={view==='list'}>
                    {/* InputContainer */}
                    {/* ContactListContainer */}
                </Container>

                {/* ContactModalContainer */}
                {/* FloatingButtonContainer */}
            </div>
        );
    }
}

export default connect(
    (state) => ({
        view: state.base.get('view')
    })
)(App);

이 컴포넌트는 이미 리덕스에 연결이 되어있으니, 따로 전달해줄 props 는 없습니다.

 

 

5-4. InputContainer 컴포넌트 만들기

src/containers/InputContainer

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as baseActions from '../modules/base';
import Input from '../components/Input';

class InputContainer extends Component {

    handleChange = (e) => {
        const { BaseActions } = this.props;
        BaseActions.changeSearch(e.target.value);
    }

    render() {
        const { keyword } = this.props;
        const { handleChange } = this;

        return (
            <Input onChange={handleChange} value={keyword} placeholder="검색"/>
        );
    }
}

export default connect(
    (state) => ({
        keyword: state.base.get('keyword')
    }),
    (dispatch) => ({
        BaseActions: bindActionCreators(baseActions, dispatch)
    })
)(InputContainer);

이번 컴포넌트도 간단해서 금방 리덕스에 연결시킬 수 있습니다.

어떤가요? 조금 익숙해짐이 느껴지나요?

App 에서 불러와서 사용하기

이제 이 컴포넌트도 App 컴포넌트에서 불러와서 렌더링하세요.

src/App.js

import React, { Component } from 'react';
import Header from './components/Header';
import Container from './components/Container';
import { connect } from 'react-redux'

import ViewSelectorContainer from './containers/ViewSelectorContainer';
import InputContainer from './containers/InputContainer';

class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                <ViewSelectorContainer/>

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    {/* FavoriteListContainer */}
                </Container>
                <Container visible={view==='list'}>
                    <InputContainer/>
                    {/* ContactListContainer */}
                </Container>

                {/* ContactModalContainer */}
                {/* FloatingButtonContainer */}
            </div>
        );
    }
}

export default connect(
    (state) => ({
        view: state.base.get('view')
    })
)(App);

 

 

5-5. FavoriteListContainer 만들기

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

export default connect(
    (state) => ({
        contacts: state.contacts
    })
)(FavoriteList);

이번 컴포넌트는 더더욱 쉽습니다. 연결 해 줄 액션이 없으니 데이터만 넣어주면 됩니다.

하지만, 이제 우리 컴포넌트가 일반 객체가 아닌 immutable 객체들을 다루게 되었으니, 일부 컴포넌트들을 수정해주어야합니다.

FavoriteList 컴포넌트 수정하기

일반 객체가 아닌, MapList 를 다루니까 이에 맞춰서 수정을 해주겠습니다.

propTypesreact-immutable-proptypes 를 사용해서 설정을 해주고,

필터링을 하는과정과, key 를 설정하는 부분에서 .get() 을 통하여 값을 가져오도록 설정하세요.

src/components/FavoriteList

import React from 'react';
import styled from 'styled-components';
import FavoriteItem from './FavoriteItem';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';

const Wrapper = styled.div`
    /* 레이아웃 */
    position: relative; /* 자식 컴포넌트의 크기를 이 컴포넌트의 50% 로 설정하기 위함 */
    display: flex;
    flex-wrap: wrap; /* 공간이 부족하면 다음 줄에 보여줌 */
`;

const FavoriteList = ({contacts}) => {
    const favoriteList = contacts
                        .filter( // 즐겨찾기 필터링
                            contact => contact.get('favorite')
                        ).map(
                            contact => (
                                <FavoriteItem 
                                    key={contact.get('id')} 
                                    contact={contact}
                                />
                            )
                        );

    return (
        <Wrapper>
            {favoriteList}
        </Wrapper>
    );
};

FavoriteList.propTypes = {
    contacts: ImmutablePropTypes.listOf(
        ImmutablePropTypes.mapContains({
            id: PropTypes.string,
            name: PropTypes.string,
            phone: PropTypes.string,
            color: PropTypes.string,
            favorite: PropTypes.bool
        })
    )
};

export default FavoriteList;

propTypes 를 설정 할 때, ListImmutablePropTypes.listOf() 를 사용하고, MapImmutablePropTypes.mapContains() 를 사용합니다.

FavoriteItem 수정하기

FavoriteItem 컴포넌트도 조금 수정해주어야합니다. propTypes 를 수정하고, 또, contact 를 렌더링 하는 과정에서 contact: { name, phone, color } 문법이 먹히지 않으니 이를 다음과 같이 수정하세요.

src/components/FavoriteItem.js

import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import PersonIcon from 'react-icons/lib/md/person';
import ImmutablePropTypes from 'react-immutable-proptypes';

/* style 컴포넌트 생략 */

const FavoriteItem = ({contact}) => {
    const { color, name, phone } = contact.toJS();
    return (
        <Wrapper>
            <Box color={color}>
                <ThumbnailContainer>
                    <PersonIcon/>
                </ThumbnailContainer>
                <Info>
                    <Name>{name}</Name>
                    <Phone>{phone}</Phone>
                </Info>
            </Box>
        </Wrapper>
    )
};

FavoriteItem.propTypes = {
    contact: ImmutablePropTypes.mapContains({
        id: PropTypes.string,
        name: PropTypes.string,
        phone: PropTypes.string,
        color: PropTypes.string,
        favorite: PropTypes.bool
    })
};
export default FavoriteItem;

일반 Map 인스턴스는 비구조화 할당이 되지 않으니, toJS() 를 한 다음에 비구조화 할당을 하였습니다.

App 에서 불러와서 사용하기

이제 FavoriteListContainerApp 컴포넌트에서 불러와서 렌더링하세요.

src/App.js

import Header from './components/Header';
import Container from './components/Container';
import { connect } from 'react-redux'

import ViewSelectorContainer from './containers/ViewSelectorContainer';
import InputContainer from './containers/InputContainer';
import FavoriteListContainer from './containers/FavoriteListContainer';

class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                <ViewSelectorContainer/>

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    <FavoriteListContainer/>
                </Container>
                <Container visible={view==='list'}>
                    <InputContainer/>
                    {/* ContactListContainer */}
                </Container>

                {/* ContactModalContainer */}
                {/* FloatingButtonContainer */}
            </div>
        );
    }
}

export default connect(
    (state) => ({
        view: state.base.get('view')
    })
)(App);

 

5-6. FloatingButtonContainer 컴포넌트 만들기

이번 컴포넌트는 전달받을 상태는 없으므로 mapDispatchToProps 가 들어갈 자리에는 null 로 설정을 해주고, 두 종류의 액션들을 바인딩 해주겠습니다. 이 컴포넌트에서 이전에 App 컴포넌트에 있었던 generateRandomColor 를 사용하니 아까 따로 복사했던걸 여기에 넣으세요.

src/FloatingButtonContainer.js

import React, { Component } from 'react';
import FloatingButton from '../components/FloatingButton';
import { connect } from 'react-redux';
import * as modalActions from '../modules/modal';
import * as baseActions from '../modules/base';
import { bindActionCreators } from 'redux';
import oc from 'open-color';

// 랜덤 색상 생성
function generateRandomColor() {
    const colors = [
        'gray',
        'red',
        'pink',
        'grape',
        'violet',
        'indigo',
        'blue',
        'cyan',
        'teal',
        'green',
        'lime',
        'yellow',
        'orange'
    ];

    // 0 부터 12까지 랜덤 숫자
    const random = Math.floor(Math.random() * 13);

    return oc[colors[random]][6];
}

class FloatingButtonContainer extends Component {

    handleClick = () => {
        const { ModalActions, BaseActions } = this.props;

        // 뷰를 list 로 전환
        BaseActions.setView('list');

        // 주소록 생성 모달 띄우기 
        ModalActions.show({
            mode: 'create',
            contact: {
                name: '',
                phone: '',
                color: generateRandomColor()
            }
        });
    }

    render() {
        const { handleClick } = this;
        return (
            <FloatingButton onClick={handleClick}/>
        )
    }
}

// 리덕스에 컴포넌트 연결
export default connect(
    null,
    (dispatch) => ({
        ModalActions: bindActionCreators(modalActions, dispatch),
        BaseActions: bindActionCreators(baseActions, dispatch)
    })
)(FloatingButtonContainer);

App 에서 불러와서 사용하기

이제 FloatingButtonContainerApp 컴포넌트에서 불러와서 렌더링하세요.

src/App.js

import React, { Component } from 'react';
import Header from './components/Header';
import Container from './components/Container';
import { connect } from 'react-redux'

import ViewSelectorContainer from './containers/ViewSelectorContainer';
import InputContainer from './containers/InputContainer';
import FavoriteListContainer from './containers/FavoriteListContainer';
import FloatingButtonContainer from './containers/FloatingButtonContainer';


class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                <ViewSelectorContainer/>

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    <FavoriteListContainer/>
                </Container>
                <Container visible={view==='list'}>
                    <InputContainer/>
                    {/* ContactListContainer */}
                </Container>

                {/* ContactModalContainer */}
                <FloatingButtonContainer/>
            </div>
        );
    }
}

export default connect(
    (state) => ({
        view: state.base.get('view')
    })
)(App);

 

5-7. ContactModalContainer 컴포넌트 만들기

이번 컴포넌트는 다룰 액션에 꽤 많은데요, 이번에 만들어야 할 함수들은 다음과 같습니다:

  • handleHide
  • handleChange
  • handleRemove
  • handleAction { create, modify }

handleAction 은 두 함수를 내장하고있는 객체입니다. 모달의 모드에 따라 다른 함수가 실행되도록 설정 할 것입니다.

그리고, 화면을 어둡게 하는 Dimmed 컴포넌트를 이 컨테이너 안에 포함 시키도록 하겠습니다.

src/containers/ContactModalContainer.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

import ContactModal from '../components/ContactModal';
import Dimmed from '../components/Dimmed';

import * as modalActions from '../modules/modal';
import * as contactsActions from '../modules/contacts';

import shortid from 'shortid';

class ContactModalContainer extends Component {

    handleHide = () => {
        const { ModalActions } = this.props;

        ModalActions.hide();
    }

    handleChange = ({name, value}) => {
        const { ModalActions } = this.props;

        ModalActions.change({
            name,
            value
        });
    }

    handleAction = {
        create: () => {
            const { ContactActions, modal } = this.props;
            const { name, phone, color } = modal.get('contact').toJS();
            const id = shortid.generate();

            ContactActions.create({
                id,
                name,
                phone,
                color
            });

            this.handleHide();
        },

        modify: () => {
            const { ContactActions, modal } = this.props;
            const { id, name, phone } = modal.get('contact').toJS();

            ContactActions.modify({
                id,
                contact: {
                    name,
                    phone
                }
            });

            this.handleHide();
        }
    }

    handleRemove = () => {
        const { ContactActions, modal } = this.props;
        const id = modal.getIn(['contact', 'id']);

        ContactActions.remove(id);
        this.handleHide();
    }


    render() {
        const { modal } = this.props;
        const { visible, mode, contact } = modal.toJS();

        const { 
            handleHide, 
            handleAction,
            handleChange, 
            handleRemove
        } = this;


        return (
            <div>
                <ContactModal
                    visible={visible}
                    mode={mode}
                    name={contact.name}
                    phone={contact.phone}
                    color={contact.color}
                    onHide={handleHide}
                    onAction={handleAction[mode]}
                    onRemove={handleRemove}
                    onChange={handleChange}
                />
                <Dimmed visible={visible}/>
            </div>
        );
    }
}

export default connect(
    (state) => ({
        modal: state.modal
    }),
    (dispatch) => ({
        ContactActions: bindActionCreators(contactsActions, dispatch),
        ModalActions: bindActionCreators(modalActions, dispatch)
    })
)(ContactModalContainer);

App 에서 불러와서 사용하기

이제 위 컴포넌트를 App 컴포넌트에서 불러와서 사용하겠습니다.

렌더링을 하고 FloatingButton 컴포넌트를 눌러서 모달이 제대로 작동하는지 체크하세요.

import React, { Component } from 'react';
import Header from './components/Header';
import Container from './components/Container';
import { connect } from 'react-redux'

import ViewSelectorContainer from './containers/ViewSelectorContainer';
import InputContainer from './containers/InputContainer';
import FavoriteListContainer from './containers/FavoriteListContainer';
import FloatingButtonContainer from './containers/FloatingButtonContainer';
import ContactModalContainer from './containers/ContactModalContainer';


class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                <ViewSelectorContainer/>

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    <FavoriteListContainer/>
                </Container>
                <Container visible={view==='list'}>
                    <InputContainer/>
                    {/* ContactListContainer */}
                </Container>

                <ContactModalContainer/>
                <FloatingButtonContainer/>
            </div>
        );
    }
}

export default connect(
    (state) => ({
        view: state.base.get('view')
    })
)(App);

 

 

5-8. ContactListContainer 컴포넌트 만들기

자, 우리의 마지막 컨테이너 ContactListContainer 를 만들어보겠습니다.

src/containers/ContactListContainer.js

이번 컴포넌트에서, handleOpenModify 함수를 만들 때, 주어진 id 를 가지고 주소록 데이터를 가져올 때에는, Map의 내장함수 find 를 사용하세요. 이 함수는 조건이 일치하는 데이터를 반환합니다.

import React, { Component } from 'react';
import ContactList from '../components/ContactList';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

import * as modalActions from '../modules/modal';
import * as contactsActions from '../modules/contacts';

class ContactListContainer extends Component {
    // 수정 모달 열기
    handleOpenModify = (id) => {
        const { contacts, ModalActions } = this.props;

        // id 로 contact 조회
        const contact = contacts.find(contact => contact.get('id') === id);

        ModalActions.show({
            mode: 'modify',
            contact: contact.toJS()
        });
    }


    // 즐겨찾기 활성화 / 비활성화
    handleToggleFavorite = (id) => {
        const { ContactsActions } = this.props;
        ContactsActions.toggleFavorite(id);
    }


    render() {
        const { contacts, keyword } = this.props;
        const { 
            handleOpenModify,
            handleToggleFavorite
        } = this;

        return (
            <ContactList
                contacts={contacts}
                onOpenModify={handleOpenModify}
                onToggleFavorite={handleToggleFavorite}
                search={keyword}
            />
        );
    }
}

export default connect(
    (state) => ({
        keyword: state.base.get('keyword'),
        contacts: state.contacts
    }),
    (dispatch) => ({
        ModalActions: bindActionCreators(modalActions, dispatch),
        ContactsActions: bindActionCreators(contactsActions, dispatch)
    })
)(ContactListContainer);

ContactList / ContactItem 컴포넌트 수정하기

아직 끝이 아닙니다! 우리가 아까 FavoriteListContainer 를 만들었을때 다른 컴포넌트를 수정해야됐던것처럼, 이 컴포넌트도 마찬가지로, ContactList 컴포넌트와 ContactItem 컴포넌트가 Map 그리고 List 인스턴스를 다룰 수 있도록 수정을 해주세요.

src/components/ContactList.js

import React, { Component } from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import ContactItem from './ContactItem';
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup';
import { transitions } from '../lib/style-utils';
import ImmutablePropTypes from 'react-immutable-proptypes';

const Wrapper = styled.div`
    margin-top: 1rem;

    .contact-enter {
        animation: ${transitions.stretchOut} .15s linear;
        animation-fill-mode: forwards;
    }

    .contact-leave {
        animation: ${transitions.shrinkIn} .15s linear;
        animation-fill-mode: forwards;
    }

`;

class ContactList extends Component {

    static propTypes = {
        contacts: ImmutablePropTypes.listOf(
            ImmutablePropTypes.mapContains({
                id: PropTypes.string,
                name: PropTypes.string,
                phone: PropTypes.string,
                color: PropTypes.string,
                favorite: PropTypes.bool
            })
        ),
        search: PropTypes.string, // 검색 키워드
        onToggleFavorite: PropTypes.func, // 즐겨찾기 토글
        onOpenModify: PropTypes.func // 수정 모달 띄우기
    }

    render() {
        const { contacts, onOpenModify, search, onToggleFavorite } = this.props;

        const contactList = contacts
                            .filter( // 키워드로 필터링
                                c => c.get('name').indexOf(search) !== -1
                            ).sort( // 가나다순으로 정렬
                                (a,b) => {
                                    if(a.get('name') > b.get('name')) return 1;
                                    if (a.get('name') < b.get('name')) return -1;
                                    return 0;
                                }
                            ).map( // 컴포넌트로 매핑
                                contact => (
                                    <ContactItem 
                                        key={contact.get('id')} 
                                        contact={contact}
                                        onOpenModify={onOpenModify}
                                        onToggleFavorite={onToggleFavorite}
                                    />
                                )
                            );


        return (
            <Wrapper>
                <CSSTransitionGroup
                        transitionName="contact"
                        transitionEnterTimeout={500}
                        transitionLeaveTimeout={500}>
                {contactList}
                </CSSTransitionGroup>
            </Wrapper>
        );
    }
}

export default ContactList;

src/components/ContactItem.js

import React, { Component } from 'react';
import styled from 'styled-components';
import oc from 'open-color';
import PropTypes from 'prop-types';
import Thumbnail from './Thumbnail';
import StarIcon from 'react-icons/lib/md/star';
import EditIcon from 'react-icons/lib/md/edit';

import ImmutablePropTypes from 'react-immutable-proptypes';


/* styled 컴포넌트 생략 */


class ContactItem extends Component {

    static propTypes = {
        contact: ImmutablePropTypes.mapContains({
            id: PropTypes.string,
            name: PropTypes.string,
            phone: PropTypes.string,
            color: PropTypes.string,
            favorite: PropTypes.bool
        }),
        onToggleFavorite: PropTypes.func,
        onOpenModify: PropTypes.func
    }

    render() {
        // 레퍼런스 준비
        const {
            contact,
            onOpenModify,
            onToggleFavorite
        } = this.props;

        const { name, phone, favorite, id, color } = contact.toJS();

        return (
            <Wrapper>
                <Thumbnail color={color}/>
                <Info>
                    <Name>{name}</Name>
                    <Phone>{phone}</Phone>
                </Info>
                <div className="actions">
                    <CircleButton className="favorite" active={favorite} onClick={() => onToggleFavorite(id)}>
                        <StarIcon/>
                    </CircleButton>
                    <CircleButton onClick={() => onOpenModify(id)}>
                        <EditIcon/>
                    </CircleButton>
                </div>
            </Wrapper>
        );
    }
}

export default ContactItem;

App 에서 불러와서 사용하기

자! 우리의 마지막 컨테이너 컴포넌트를 불러와서 사용해봅시다.

import React, { Component } from 'react';
import Header from './components/Header';
import Container from './components/Container';
import { connect } from 'react-redux'

import ViewSelectorContainer from './containers/ViewSelectorContainer';
import InputContainer from './containers/InputContainer';
import FavoriteListContainer from './containers/FavoriteListContainer';
import FloatingButtonContainer from './containers/FloatingButtonContainer';
import ContactModalContainer from './containers/ContactModalContainer';
import ContactListContainer from './containers/ContactListContainer';


class App extends Component {
    render() {
        // 레퍼런스 준비
        const { view } = this.props;

        return (
            <div>
                <Header/>
                <ViewSelectorContainer/>

                {/* view 값에 따라 다른 컨테이너를 보여준다 */}
                <Container visible={view==='favorite'}>
                    <FavoriteListContainer/>
                </Container>
                <Container visible={view==='list'}>
                    <InputContainer/>
                    <ContactListContainer/>
                </Container>

                <ContactModalContainer/>
                <FloatingButtonContainer/>
            </div>
        );
    }
}

export default connect(
    (state) => ({
        view: state.base.get('view')
    })
)(App);

자! 작업을 모두 마쳤습니다!

모든 기능이 작동하는지 확인하세요.

지금까지의 코드는 Github 에서 열람 할 수 있습니다.

 

 

5-9. DIY: 주소록 저장 / 불러오기

자, 여러분이 직접 해보는 섹션입니다. 우리가 리덕스로 적용하는 과정에서 데이터를 로컬스토리지에 저장하고 불러오는 로직을 날려버렸습니다.

이 기능을, 여러분이 직접 구현을 해보세요.

Hints

  • contacts 모듈에 주소록을 불러오는 액션, LOAD_CONTACTS 을 구현하세요
  • contacts.toJS() 를 하면 List 가 일반 자바스크립트 배열로 변환됩니다.
  • 일반 자바스크립트 배열을 List 로 변환하고, 내부 객체들도 Map 으로 변환을 할 때는, fromJS 를 사용하면됩니다.
import { fromJS } from 'immutable';
const example = fromJS([{a:1}, {a:2}]);
  • App 에서 contacts 상태를 연결시켜주고, componentDidUpdatecomponentDidMount 에서 데이터를 저장하거나, 불러오세요.

 

 

와.. 수고하셨습니다.

아 강의를 1편부터 5편부터 마치셨다면 덧글 한번 달아주세요~ 오프라인으로 강의 들으시는분들은 저와 함께 진행을 하겠지만, 혼자 한다면 끈기가 필요했을텐데.. 정말로 수고 많으셨습니다!

리덕스가 조금 편해졌나요? 1편부터 5편까지, 제가 이런저런 좋은 가이드를 제시해주었지만, 더욱 편하게 사용하는방법들이 존재할것입니다. 사용하시면서, 이런저런 시도를 많이 도전해보세요 🙂

  • 강현식

    좋은 글 잘 보았습니다.
    그런데 비동기 action에 대해서는 설명이 없네요 ^^
    찾아보니 redux-thunk-actions 라는게 있습니다. 저는 비동기는 이걸로 적용해볼 생각입니다.

    • 비동기 액션을 처리하는 방법은 여러가지가 있습니다.

      비동기 액션을 다루는것에 대해선 이어질 강좌에서 계속 다룰예정이구요
      저도 redux-thunk 로 관리해본적이 있구..

      redux-promise-middleware를 애용하다가 불편한점이 몇가지 있어서 이번에 직접 미들웨어 라이브러리를 만들었습니다.
      한글화는 좀 나중에 할 것 같아요 ^^

      https://github.com/velopert/redux-pender

      여러가지 방법으로 비동기 액션을 다루는걸 비교한건 https://github.com/velopert/redux-pender/blob/master/docs/Comparison.md 에 있어요.

      영어이긴하지만 대충 훑어보시면 도움이 될지도요..!

      • 강현식

        감사합니다
        만들어주신 pender 가 더 쓸만한거 같습니다.

        redux-thunk-actions 는 좀 불편했거든요…
        on 시리즈를 쓸수가 없어서..

  • Coding Mentor

    안녕하세요 velopert님

    어..제가 배포를 처음해보는데요

    vultr 구매하려고하는데 어떻게 구입하는지 모르겠어요;;

    페이팔이랑 크레딧카드 등록해서 사는거 있던데 어떻게 구입하셨나요? 해외직구같은것도 해본적이 없어서 지금 손도못대겠네요…

  • 잘 봤습니다. 작년에 올려주신 리액트 튜토리얼부터 해서 기회가 될 때마다 따라가보고, 리액트+리덕스로 무언가 만들어보려해도 쉽게 되지는 않네요. 이번에도 그냥 코드 따라가는 수준이긴 했지만 처음 리덕스를 접할때보단 조금씩 이해가 되고 있는 기분입니다.
    그러던 와중에 Vue.js를 접했는데 이 녀석이 조금 더 쉽게 느껴지더군요. 그래도 역시 범용성 + 사용 풀 때문에 리액트를 공부하는 일은 포기할 수 없네요 ㅎㅎ

    • 오.. 도움이 됐다니 좋군요!
      저도 Vue 사용 해보고 생각보다 쉽고 간단해서 놀랐어요.

      하지만 리액트에 이미 적응이 되어있는지라, 익숙함을 포기할수없기에 잠깐 뷰 만져보다가 다시 리액트만 파고있습니다 🙂
      시간이 부족했던것도 한 몫 했지만요.

      저는 어떤 라이브러리를 사용하던 자신이 좋아하는걸, 잘 하면 된다고 생각해요!

  • 서한준

    이틀동안 1장부터 5장까지 감사히 공부하였습니다. react는 커녕 node.js도 처음이었지만 알려주신 강좌에서 열심히 공부해서 이해가 많이 되었습니다! 다만 오늘 5장을 봤는데.. 이전에 우리가 만들었던 주소록 프로젝트라고 하셨는데, 어디에 있는지 링크도 추가해주실 수 있나요 ㅠㅠ 4장 공부 다하고 5장으로 왔는데 완전 처음보는 프로젝트 파일이라 제가 뭘 치고있는지 이해가 잘 안됬습니다ㅠㅠ

  • bajutae

    감사합니다 유투브와 블로그 항상 잘보고 있습니다~ 리액트 입문에 많은 도움이 됐습니다~

  • 감사합니다 😀

  • Seungil Han

    그 동안 참조를 많이 해왔는데 감사합니다.

    Ducks를 통해 소스코드가 단순화되니 좋네요,
    비동기 코드들은 어떻게 하면 좋을 까요?

    export const login = (userID, password)=>{
    return (dispatch) =>{
    dispatch(requestLogin(userID,password));
    return axios.post(`${API_URL}/${apiUserLogin}`,{username: userID,password:password})
    .then(
    (response) =>{
    dispatch(loginSuccess(response))
    }
    )
    .catch(
    (error) => {dispatch(loginError(error.response.data.err))}
    );
    }
    }

    • 비동기 액션에 관련해서 포스트를 작성중입니다.
      이번주 안에 올라올것같으니 기다려주세요 ^^

      • Seungil Han

        기대할께요.

  • DaekyuLee

    몇 일 전부터 안 내려가는 스크롤을 억지로 잡아 굴리면서 겨우 다 보았네요 ㅎ
    좋은 강의 감사합니다. 아무래도 주소록 프로젝트를 먼저 보느라 더 오래걸린것 같습니다만.. 결국 한번으론 이해가 잘 되지 않네요
    복습만이 살길이겠죠 ㅎㅎ
    비동기 액션 관련 포스트도 기다리면서 연습하겠습니다 ^^