Meteor에서 Redux 환경을 설정하고, 애플리케이션에 적용해 본다.
- Meteor Collection의 Pub/Sub 설정
- Collection의 CRUD 서비스 클래스 개발
- Redux 환경 설정
- Redux의 action, reducer 개발 및 store 설정
- 애플리케이션에 Redux action 호출하기
- 소스코드
MongoDB Collection Pub/Sub 설정
기본설치를 하게 되면 autopublish 미티어 패키지가 설치되어 있다. 삭제한다.
$ meteor remove autopublish
삭제를 하면 links 컬렉션으로 부터 데이터를 받지 못한다. 이제 필요한 것을 받기 위해 publish, subscribe를 설정한다.
- server/main.tsx에서 insertLink 코드를 제거한다.
- server/main.tsx에서 links 코드 import 문을 수정한다.
// main.tsx 전체코드
import { Meteor } from 'meteor/meteor';
import '../imports/api/links'; // 서버에서 사용하기 위해 반드시 import해야 한다. 하지 않으면 collection 생성, 제어를 못 한다.
Meteor.startup(() => { }); // 텅빈 코드 블록
- imports/api/links.ts 에 publish 한다.
if (Meteor.isServer) {
Meteor.publish('links', () => Links.find());
}
- subscribe를 사용할 때 미티어의 Tracker.autorun을 사용하지 않고, React의 props에 subscribe정보를 바로 맵핑해 주는 withTracker를 사용하기 위해 react-meteor-data 패키지를 사용한다.
$ meteor npm install --save react-meteor-data
- imports/api/ui/Info.tsx에 withTracker를 설정한다.
import { Meteor } from 'meteor/meteor';
import Links from '../api/links';
interface InfoProps {
links: any;
loading: boolean;
}
// 맨 하단
export default withTracker(() => {
const handle = Meteor.subscribe('links');
return {
links: Links.find().fetch(),
loading: !handle.ready()
}
})(Info);
MongoDB Collection CRUD
간단한 Link CRUD 애플리케이션을 만든다.
- Collection의 CRUD 로직을 담은 service를 생성한다. imports/ui밑에 link폴더를 생성한다.
// imports/ui/link/link.service.ts
import Links from '../api/links';
class LinkService {
addLink({title, url}) {
Links.insert({title, url, createAt: new Date()});
}
removeLink(_id: string) {
Links.remove(_id);
}
updateLink(_id: string, {title, url}) {
Links.update(_id, {title, url, updateAt: new Date()});
}
}
export const linkService = new LinkService();
- imports/ui/link/AddLink.tsx 파일 추가
import * as React from 'react';
import { linkService } from './links.service';
export interface AddLinkProps {
}
export default class AddLink extends React.Component<AddLinkProps, any> {
handleSubmit = (e: any) => {
//ignore validation
e.preventDefault();
const param = {
title: e.target.title.value,
url: e.target.url.value
}
linkService.addLink(param);
};
public render() {
return (
<form onSubmit={this.handleSubmit} >
<input type="text" name="title" placeholder="title" />
<input type="text" name="url" placeholder="url" />
<button>Add Link</button>
</form>
)
}
}
- imports/ui/App.tsx에서 <Info/> 만 남기고 삭제하고, Info.tsx에 AddLink를 추가한다.
// App.tsx
class App ... {
render() {
return (
<div></InfoContainer/></div>
)
}
}
// Info.tsx
class Info ... {
render() {
return(
<div>
<AddLink />
....
</div>
)
}
}
- link폴더 밑에 LinkList.tsx와 Link.tsx를 추가한다.
// Link.tsx
import * as React from 'react';
export interface LinkProps {
link: any
}
export default class Link extends React.Component<LinkProps, any> {
public render() {
const { link } = this.props;
return (
<li key={link._id}>
<a href={link.url} target="_blank">{link.title}</a>
</li>
);
}
}
// LinkList.tsx
import * as React from 'react';
import Link from './Link';
export interface LinkListProps {
links: any[]
}
export default class LinkList extends React.Component<LinkListProps, any> {
public render() {
const links = this.props.links.map(
link => <Link link={link}/>
);
return (
<div>
<h2>Links</h2>
<ul>{links}</ul>
</div>
);
}
}
- Info.tsx에서 LinkList를 사용토록 변경한다.
class Info extends React.Component<InfoProps, any> {
linkList() {
const { links, loading } = this.props;
if (loading) {
return <div>loading...</div>
} else {
return <LinkList links={links} />
}
}
render() {
const { links } = this.props;
return (
<div>
<AddLink />
{this.linkList()}
</div>
);
}
}
- 마지막으로 link 삭제를 한다.
// imports/ui/link.Link.tsx
import * as React from 'react';
import { linkService } from './links.service';
export interface LinkProps {
link: any
}
export default class Link extends React.Component<LinkProps, any> {
removeLink = () => {
const { link } = this.props;
linkService.removeLink(link._id);
}
public render() {
const { link } = this.props;
return (
<li key={link._id}>
<a href={link.url} target="_blank">{link.title}</a>
<button onClick={this.removeLink}> x </button>
</li>
);
}
}
Redux 관련 패키지 설치
redux 기본 패키지를 설치한다.
$ meteor npm install --save redux react-redux react-router-redux connected-react-router
$ meteor npm install --save-dev @types/react-redux @types/react-router-redux
redux action, reducer을 보다 편하게 사용할 수 있는 typesafe-actions 모듈을 설치한다.
$ meteor npm install --save typesafe-actions
redux에서 비동기를 처리할 수 있는 redux-observable 과 rxjs 모듈을 설치한다.
$ meteor npm install --save redux-observable rxjs
state 갱신으로 인한 반복 rendering을 제거해 성능향상을 위한 reselect 모듈도 설치한다.
$ meteor npm install --save reselect
Redux 코드 개발
link action 개발
- action type constants 정의
- action Model 정의
- action 에 대한 정의
// imports/ui/pages/link/link.action.ts
import { action } from 'typesafe-actions';
/**************************
* constants, model
**************************/
// constants
export const ADD_REQUEST = '[link] ADD_REQUEST';
export const ADD_SUCCESS = '[link] ADD_SUCCESS';
export const ADD_FAILED = '[link] ADD_FAILED';
export const DELETE_REQUEST = '[link] DELETE_REQUEST';
export const DELETE_SUCCESS = '[link] DELETE_SUCCESS';
export const DELETE_FAILED = '[link] DELETE_FAILED';
export const CHANGE = '[link] CHANGE';
// model
export type LinkModel = {
_id?: string;
title?: string;
url?: string;
visited?: boolean;
error?: boolean;
errorMsg?: any;
success?: boolean;
createdAt?: any;
updatedAt?: any;
};
/**************************
* actions, action-type
**************************/
export const addLink = (params: LinkModel) => action(ADD_REQUEST, params);
export const addLinkSuccess = (params: LinkModel) => action(ADD_SUCCESS, params);
export const addLinkFailed = (params: LinkModel) => action(ADD_FAILED, params);
export const removeLink = (id: string) => action(DELETE_REQUEST, id);
export const removeLinkSuccess = (id: string) => action(DELETE_SUCCESS, id);
export const removeLinkFailed = (id: string) => action(DELETE_FAILED, id);
export const changeLink = (id: string) => action(CHANGE, id);
link reducer 개발
- Link State 정의
- LinkAction 타입
- Link reducer 분기
- reselect를 이용한 selector는 별도로 만들지 않고, link.reduer.ts 파일이 함께 둔다. (파일이 너무 많아져서...)
// imports/ui/pages/link/link.reducer.ts
import { ActionType } from 'typesafe-actions';
import { combineReducers } from 'redux';
import { createSelector } from 'reselect';
import * as actions from './link.action';
/**************************
* state
**************************/
// state
export type LinkState = {
list: actions.LinkModel[],
linkFilter: string
};
/**************************
* reducers
**************************/
export type LinkAction = ActionType<typeof actions>;
export const linkReducer = combineReducers<LinkState, LinkAction>({
list: (state = [], action) => {
switch (action.type) {
case actions.ADD_FAILED:
return state;
case actions.ADD_SUCCESS:
return [...state, action.payload];
case actions.DELETE_FAILED:
return state;
case actions.DELETE_SUCCESS:
return state.filter(item => item._id === action.payload);
case actions.CHANGE:
return state.map(
item =>
item._id === action.payload
? { ...item, visited: !item.visited }
: item
);
default:
return state;
}
},
linkFilter: (state = '', action) => {
switch (action.type) {
case actions.CHANGE:
if (action.payload === 'visited'){
return '';
} else {
return 'visited';
}
default:
return state;
}
}
})
/**************************
* selectors
**************************/
export const getLinks = (state: LinkState) => state.list;
export const getLinkFilter = (state: LinkState) => state.linkFilter;
export const getFilteredLinks = createSelector(getLinks, getLinkFilter, (links, linkFilter) => {
switch (linkFilter) {
case 'visited':
return links.filter(t => t.visited);
default:
return links;
}
});
redux-observable을 이용한 epic을 만든다.
- async 처리
- rxjs 이용
- takeUntil은 cancel을 위한 장치이다.
// imports/ui/pages/link/link.epic.ts
import { Epic, combineEpics } from 'redux-observable';
import { filter, map, switchMap, takeUntil } from 'rxjs/operators';
import { isOfType } from 'typesafe-actions';
import Links from '../../../api/links';
import { insertCollection, removeCollection, RequestModel } from '../../sdk';
import * as actions from './link.action';
const addLink: Epic = (
action$,
store
) =>
action$.pipe(
filter(isOfType(actions.ADD_REQUEST)),
switchMap(action => {
const { title, url } = action.payload;
return insertCollection(Links, { title, url, createdAt: new Date() })
}),
map((response: RequestModel) => {
if (response.error) {
return actions.addLinkFailed({ ...response.result })
}
return actions.addLinkSuccess(response.result)
}),
// takeUntil(action$.pipe(
// filter(isOfType(actions.ADD_REQUEST))
// ))
);
const removeLink: Epic = (
action$,
store
) =>
action$.pipe(
filter(isOfType(actions.DELETE_REQUEST)),
switchMap(action => {
return removeCollection(Links, action.payload);
}),
map((response: RequestModel) => {
if (response.error) {
return actions.removeLinkFailed({ ...response.result, ...response.params })
}
return actions.removeLinkSuccess(response.params._id);
}),
// takeUntil(action$.pipe(
// filter(isOfType(actions.ADD_REQUEST))
// ))
);
export const linkEpic = combineEpics(addLink, removeLink);
Meteor Client Collection 을 Observable로 전환
- meteor client collection의 insert, remove시에 call 결과를 Observable로 변환하여 반환하는 유틸을 만든다.
- 이는 redux-observable의 epic에서 async 데이터를 switchMap 오퍼레이터로 다루기 위함이다.
// imports/sdk/util/ddp.util.ts
import { from, Observable } from 'rxjs';
export type RequestModel = {
error?: boolean;
success?: boolean;
result: any;
params?: any;
}
export function insertCollection(collection: any, params: any): Observable<RequestModel> {
return from(new Promise((resolve, reject) => {
collection.insert(params, (error, result) => {
if (error) {
reject({ error: true, result: { ...error }, params: { ...params } });
}
if (typeof result === 'string' || typeof result === 'number') {
resolve({ success: true, result, params: {...params} });
} else {
resolve({ success: true, result: { ...result }, params: { ...params } });
}
});
}));
}
export function removeCollection(collection: any, _id: string): Observable<RequestModel> {
return from(new Promise((resolve, reject) => {
collection.remove(_id, (error, result) => {
if (error) {
reject({ error: true, result: { ...error }, params: { _id } });
}
if (typeof result === 'string' || typeof result === 'number') {
resolve({ success: true, result, params: { _id } });
} else {
resolve({ success: true, result: { ...result }, params: { _id } });
}
});
}));
}
RootReducer와 Store 설정
link관련 action, reducer, epic 개발이 끝나면 이를 등록하는 설정을 한다.
- root Reducer 정의
- root State 타입 정의
- root Action 타입 정의
- root Epic 정의
- root Store 설정
// imports/ui/store.ts
import { combineEpics, createEpicMiddleware } from 'redux-observable';
import { createStore, applyMiddleware, combineReducers, compose } from 'redux';
import { RouterAction, LocationChangeAction } from 'react-router-redux';
import { routerReducer } from 'react-router-redux';
import { StateType } from 'typesafe-actions';
import { linkEpic } from './pages/link/link.epic';
import { linkReducer, LinkAction } from './pages/link/link.reducer';
/***********************
* root reducer
***********************/
const rootReducer = combineReducers({
router: routerReducer,
links: linkReducer
});
/***********************
* root state
***********************/
export type RootState = StateType<typeof rootReducer>;
type ReactRouterAction = RouterAction | LocationChangeAction;
export type RootAction = ReactRouterAction | LinkAction;
/***********************
* root epic
***********************/
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
(window as any) &&
(window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
compose;
const rootEpic = combineEpics(linkEpic);
const epicMiddleware = createEpicMiddleware();
/***********************
* root store
***********************/
function configureStore(initialState?: object) {
// configure middlewares
const middlewares = [epicMiddleware];
// compose enhancers
const enhancer = composeEnhancers(applyMiddleware(...middlewares));
// create store
const store = createStore(rootReducer, enhancer);
// run epic: https://redux-observable.js.org/docs/basics/SettingUpTheMiddleware.html
epicMiddleware.run(rootEpic);
return store;
}
const store = configureStore();
export default store;
애플리케이션에 store를 최종 설정한다.
// client/main.tsx
import * as React from 'react';
import { Meteor } from 'meteor/meteor';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import App from '../imports/ui/App'
import store from '../imports/ui/store';
const Root = (
<Provider store={store}>
<App />
</Provider>
);
Meteor.startup(() => {
render(Root, document.getElementById('react-target'));
});
window 객체 인식을 위해 type definition을 정의한다.
- 루트에 typings 폴더 생성
- typings/index.d.ts 파일 생성
Application에서 Redux Action 호출하기
add/remove link 리덕스 액션을 애플리케이션에 적용한다. link.service.ts 는 사용하지 않는다.
- AddLink.tsx에 addLink 액션 적용
// imports/ui/link/AddLink.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { addLink } from './link.action';
export interface AddLinkProps {
addLink: Function;
}
class AddLink extends React.Component<AddLinkProps, any> {
handleSubmit = (e: any) => {
//ignore validation
e.preventDefault();
const param = {
title: e.target.title.value,
url: e.target.url.value
}
const { addLink } = this.props;
addLink(param);
};
public render() {
return (
<form onSubmit={this.handleSubmit} >
<input type="text" name="title" placeholder="title" />
<input type="text" name="url" placeholder="url" />
<button>Add Link</button>
</form>
)
}
}
export default connect(undefined, { addLink })(AddLink);
- Link.tsx에 removeLink 액션 적용
// imports/ui/pages/link/Link.tsx
import * as React from 'react';
import { removeLink } from './link.action';
import { connect } from 'react-redux';
export interface LinkProps {
link: any,
removeLink: Function
}
class Link extends React.Component<LinkProps, any> {
removeLink = () => {
const { link, removeLink } = this.props;
removeLink(link._id);
}
public render() {
const { link } = this.props;
return (
<li key={link._id}>
<a href={link.url} target="_blank">{link.title}</a>
<button onClick={this.removeLink}> x </button>
</li>
);
}
}
export default connect(undefined, { removeLink })(Link);
<참조>
- 블로그 소스 코드
- react-redux-typescript-guide 소스
- redux-actions 사용하기
- redux-observable 사용하기
- typesafe-actions 사용하기
- reselect 사용하여 성능 최적화 하기