이 튜토리얼은 5개의 포스트로 나뉘어진 이어지는 강좌입니다. 목차를 확인하시려면 여기를 참고하세요.
5장. 주소록에 Redux 끼얹기
이번 장에서는 이전에 우리가 만들었었던 주소록 프로젝트에 Redux 를 적용해보겠습니다. 기존의 코드에선 프로젝트의 모든 로직을 App
컴포넌트에서 관리했었습니다. 사실상, 이 프로젝트는 소규모 프로젝트이기 때문에 App 컴포넌트에서 관리하는것만으로도 충분하긴 합니다만, 이를 각 기능마다 분리해준다면 훨씬 코드가 간결해지고, 유지보수를 하는것도 더욱 쉬워집니다.
현재 App.js
의 코드의 길이가 320줄인데요, 이 안의 모든 로직을 분리시키고, 46줄 정도로 분리를 시켜보도록 하겠습니다.
이번 실습에서는, 지금까지 우리가 배운것들을 모두 응용해보겠습니다. 이 실습을 마치고나면 리덕스가 훨씬 친숙하게 느껴질거에요.
프로젝트 클론하기
$ 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)
이 모듈에서는 검색 인풋과, 뷰를 관리합니다. 간단하지요?
modal 모듈 만들기
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 컴포넌트 수정하기
일반 객체가 아닌, Map
과 List
를 다루니까 이에 맞춰서 수정을 해주겠습니다.
propTypes
를 react-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
를 설정 할 때, List
는 ImmutablePropTypes.listOf()
를 사용하고, Map
은 ImmutablePropTypes.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 에서 불러와서 사용하기
이제 FavoriteListContainer
를 App
컴포넌트에서 불러와서 렌더링하세요.
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 에서 불러와서 사용하기
이제 FloatingButtonContainer
를 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';
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
상태를 연결시켜주고,componentDidUpdate
와componentDidMount
에서 데이터를 저장하거나, 불러오세요.
끝
와.. 수고하셨습니다.
아 강의를 1편부터 5편부터 마치셨다면 덧글 한번 달아주세요~ 오프라인으로 강의 들으시는분들은 저와 함께 진행을 하겠지만, 혼자 한다면 끈기가 필요했을텐데.. 정말로 수고 많으셨습니다!
리덕스가 조금 편해졌나요? 1편부터 5편까지, 제가 이런저런 좋은 가이드를 제시해주었지만, 더욱 편하게 사용하는방법들이 존재할것입니다. 사용하시면서, 이런저런 시도를 많이 도전해보세요 🙂