TypeScript 로 스마트하게 리덕스 사용하기
이번에는, 타입스크립트를 사용하는 리액트 환경에서 리덕스를 사용하는 방법을 알아보겠습니다. 타입스크립트와 리덕스를 함께 사용한다면 다음과 같은 이점들이 있습니다.
- connect 하게 될 때 리덕스 스토어 안에 들어있는 값들의 타입추론 + 자동완성
- 액션 생성 함수를 사용하게 될 때 자동완성
- 리듀서를 작성하게 될 때 액션에 어떤 값이 들어있는지 확인 가능
이 실습은 다음 흐름으로 진행됩니다.
- 리액트 + 리덕스 + 타입스크립트 작업환경 설정
- 카운터 구현 (일반 방식으로)
- 리스트 구현 (Immer.js + redux-actions 의 활용)
자, 시작해볼까요?
프로젝트에서 사용된 코드는 GitHub 에서 열람 가능합니다.
리액트 + 리덕스 + 타입스크립트 작업환경 설정
CRA 로 프로젝트를 생성하세요.
$ create-react-app typescript-redux-app --scripts-version=react-scripts-ts
그리고, 해당 프로젝트에 필요한 모듈들을 설치하세요.
$ yarn add redux react-redux redux-actions immer
라이브러리에도 타입지원을 받기 위하여 타입 관련 모듈들을 설치하세요. (여기서 생략된 immer 과 redux 에는 타입 지원이 자체적으로 내장되어있습니다.)
$ yarn add --dev @types/react-redux @types/redux-actions
그리고, 몇가지 TSLint 옵션들을 꺼주겠습니다.
tslint.json
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
"linterOptions": {
"exclude": [
"config/**/*.js",
"node_modules/**/*.ts"
]
},
"rules": {
"import-name": false,
"interface-name": false,
"semicolon": [true, "always", "ignore-bound-class-methods"],
"variable-name": false,
"object-literal-sort-keys": false,
"interface-over-type-literal": false,
"ordered-imports": false,
}
}
카운터 모듈 작성
이번에는, 리덕스 관련 헬퍼함수나, immutability 유지 관련 라이브러리인 immer.js 를 따로 사용하지 않고, 일반 자바스크립트만을 사용하여 카운터 모듈을 작성해보겠습니다.
src/store/modules/counter.ts
// 액션 타입
const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';
// 액션 생성함수
export const counterActions = {
increment: (diff: number) => ({ type: INCREMENT, payload: diff }),
decrement: (diff: number) => ({ type: DECREMENT, payload: diff }),
};
// 액션 객체의 타입
type IncrementAction = ReturnType<typeof counterActions.increment>;
type DecrementAction = ReturnType<typeof counterActions.decrement>;
type Actions = IncrementAction | DecrementAction;
// 카운터 리듀서 값 타입
export type CounterState = Readonly<{
someExtraValue: string,
value: number,
}>;
// 카운터 리듀서 기본 값
const initialState: CounterState = {
someExtraValue: 'hello',
value: 0,
};
// 리듀서
export default function reducer(state: CounterState = initialState, action: Actions): CounterState {
switch(action.type) {
case INCREMENT:
return {
...state,
value: state.value + (action as IncrementAction).payload,
};
case DECREMENT:
return {
...state,
value: state.value - (action as DecrementAction).payload,
};
default:
return state;
}
}
여기서 CounterState
부분에 해당 값이 읽기전용임을 명시하는 Readonly
를 사용해주었는데, 우리가 직접 수정하면 오류가 나도록 설정해준것이며, 이는 생략해도 무방합니다.
각 액션 객체에도 타입 선언을 해주었는데, 함수의 반환값의 타입을 가져오는 ReturnType 를 이용하면 액션마다 하나하나 타입 선언을 해줄 필요 없이 자동으로 넣어줄 필요가 없어서 편합니다.
루트 리듀서 작성
루트 리듀서를 작성해봅시다. 물론 아직까진 리듀서가 하나밖에 없긴 하지만, 나중에 더 추가헤줄거니까, combineReducers 를 통해서 방금 만든 리듀서를 감싸주겠습니다. 그리고, 스토어에서 사용될 상태의 타입에 대한 선언을 여기서 해준다음에 내보내주겠습니다.
src/store/modules/index.ts
import { combineReducers } from "redux";
import counter, { CounterState } from './counter';
export default combineReducers({
counter
});
export type State = {
counter: CounterState,
};
스토어 생성함수 작성
스토어를 만들어주는 configureStore 함수를 작성해봅시다!
src/store/configureStore.ts
import { createStore } from 'redux';
import rootReducer from './modules';
export default function configureStore() {
// window 를 any 로 강제 캐스팅
const devTools = (window as any).__REDUX_DEVTOOLS_EXTENSION__;
const store = createStore(rootReducer, devTools && devTools());
return store;
}
원래 window 값에는 리덕스 개발자도구 관련 값이 들어가있지 않으므로, window 를 any 타입으로 강제 캐스팅해주었습니다.
프로젝트에 리덕스 적용
프로젝트에 리덕스를 적용해줍시다!
`src/Root.tsx
import * as React from 'react';
import { Provider } from 'react-redux';
import configureStore from './store/configureStore';
import App from './App';
const store = configureStore();
const Root: React.SFC<{}> = () => (
<Provider store={store}>
<App />
</Provider>
);
export default Root;
src/index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import './index.css';
import registerServiceWorker from './registerServiceWorker';
import Root from './Root';
ReactDOM.render(
<Root />,
document.getElementById('root') as HTMLElement
);
registerServiceWorker();
카운터 프리젠테이셔널 컴포넌트 만들기
카운터의 프리젠테이셔널 컴포넌트를 만들어봅시다!
src/components/Counter.tsx
import * as React from 'react';
interface CounterProps {
value: number;
onIncrease(): void;
onDecrease(): void;
}
const Counter: React.SFC<CounterProps> = ({ value, onIncrease, onDecrease }) => {
return (
<div>
<h1>{value}</h1>
<button onClick={onIncrease}>+</button>
<button onClick={onDecrease}>-</button>
</div>
);
};
export default Counter;
카운터 컨테이너 컴포넌트 만들기
그럼, 컨테이너 컴포넌트는 어떻게 만드는지 볼까요?
src/containers/CounterContainer.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Counter from '../components/Counter';
import { State } from '../store/modules';
import { counterActions } from '../store/modules/counter';
interface CounterContainerProps {
value: number;
CounterActions: typeof counterActions;
}
class CounterContainer extends React.Component<CounterContainerProps> {
public handleIncrease = () => {
this.props.CounterActions.increment(3);
};
public handleDecrease = () => {
this.props.CounterActions.decrement(5);
};
public render() {
return (
<Counter
value={this.props.value}
onIncrease={this.handleIncrease}
onDecrease={this.handleDecrease}
/>
);
}
}
export default connect(
({ counter }: State) => ({
value: counter.value,
}),
(dispatch) => ({
CounterActions: bindActionCreators(counterActions, dispatch),
})
)(CounterContainer);
지금은, mapStateToProps 와 mapDispatchToProps 를 connect 함수에서 바로 선언해주었는데요, 만약에 이를 분리시킨다면 다음과 같은 작업을 할 수도 있습니다.
src/containers/CounterContainer.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import Counter from '../components/Counter';
import { State } from '../store/modules';
import { counterActions } from '../store/modules/counter';
type StateProps = ReturnType<typeof mapStateToProps>;
type DispatchProps = ReturnType<typeof mapDispatchToProps>;
type OwnProps = {
somethingExtra?: string;
};
type CounterContainerProps = StateProps & DispatchProps & OwnProps;
class CounterContainer extends React.Component<CounterContainerProps> {
public handleIncrease = () => {
this.props.CounterActions.increment(3);
};
public handleDecrease = () => {
this.props.CounterActions.decrement(5);
};
public render() {
return (
<Counter
value={this.props.value}
onIncrease={this.handleIncrease}
onDecrease={this.handleDecrease}
/>
);
}
}
const mapStateToProps = ({ counter }: State) => ({
value: counter.value,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
CounterActions: bindActionCreators(counterActions, dispatch),
});
export default connect<StateProps, DispatchProps, OwnProps>(
mapStateToProps,
mapDispatchToProps,
)(CounterContainer);
전자를 사용하던, 후자를 사용하던 큰 상관은 없습니다. 다만, 컨테이너 컴포넌트에 전달 해야 할 OwnProps 가 있는 경우엔 (<CounterContainer somethingExtra={...}>
처럼 사용 시 따로 전달해주고 싶은 props 가 있는 경우) 후자를 사용하는것이 조금 더 완벽합니다. 그래야 사용 할 때 자동완성을 해줄 수 가 있거든요.
이제, 컨테이너도 만들었으니 App 에서 렌더링해볼까요?
src/App.tsx
import * as React from 'react';
import CounterContainer from './containers/CounterContainer';
class App extends React.Component {
public render() {
return (
<div>
<CounterContainer />
</div>
);
}
}
export default App;
잘 됐나요?
리스트 모듈 작성
자, 이번에는 새로운 리덕스 모듈을을 작성하겠습니다. 이번엔 우리가 액션 생성함수와, 리듀서를 더욱 간편하게 작성하기 위하여 redux-actions 를 타입스크립트와 함께 이용하는 방법을 배워보고, 불변성 관리도 더 쉽게 하기 위해서 immer.js 를 사용하는 방법을 배워보겠습니다.
src/store/list.ts
import produce from 'immer';
import { createAction, handleActions } from 'redux-actions';
const SET_INPUT = 'list/SET_INPUT';
const INSERT = 'list/INSERT';
let id = 0; // 나중에 배열 렌더링 시 key 로 사용 될 고유 값
export type Info = {
text: string;
id: number;
};
export const listActions = {
// createAction<Payload 타입, ...액션 생성함수의 파라미터들>
setInput: createAction<string, string>(SET_INPUT, text => text),
insert: createAction<Info, string>(INSERT, text => {
const info: Info = {
id,
text,
};
id += 1;
return info;
}),
};
type SetInputAction = ReturnType<typeof listActions.setInput>;
type InsertAction = ReturnType<typeof listActions.insert>;
export type ListState = {
list: Info[];
input: string;
};
const initialState: ListState = {
list: [],
input: '',
};
// handleActions<StateType, PayloadType>
const reducer = handleActions<ListState, any>({
[SET_INPUT]: (state, action: SetInputAction) => {
return produce(state, draft => {
if (action.payload === undefined) {
return;
}
draft.input = action.payload;
});
},
[INSERT]: (state, action: InsertAction) => {
return produce(state, draft => {
if (!action.payload) {
return;
}
draft.list.push(action.payload);
});
}
}, initialState);
export default reducer;
createAction 을 사용함으로서, 반복되는 코드를 많이 없애주고, handleActions 로 조금 더 보기 좋은 리듀서를 작성 할 수 있게 됐지요? 그럼 마저 구현을 해봅시다! 이 리듀서를 루트 리듀서에 포함시키고 State 타입에도 포함시키세요.
src/store/modules/index.ts
import { combineReducers } from 'redux';
import counter, { CounterState } from './counter';
import list, { ListState } from './list';
export default combineReducers({
counter,
list,
});
export type State = {
counter: CounterState;
list: ListState;
};
List 프리젠테이셔널 컴포넌트 만들기
List 프리젠테이셔널 컴포넌트를 만들어보겠습니다.
src/components/List.tsx
import * as React from 'react';
import { Info } from '../store/modules/list';
interface ListProps {
input: string;
list: Info[];
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onInsert: () => void;
}
const List: React.SFC<ListProps> = ({ input, list, onChange, onInsert }) => (
<div>
<input value={input} onChange={onChange} />
<button onClick={onInsert}>INSERT</button>
<ul>{list.map(info => <li key={info.id}>{info.text}</li>)}</ul>
</div>
);
export default List;
Info 타입을 list 모듈에서 불러옴으로서, list 값의 타입을 지정 할 때 타입 관련 코드를 재사용 할 수 있게끔 해주었습니다. 이렇게 해주면, list.map
을 사용 할 때에도 해당 원소 안에 무엇이 들어있는지 자동완성도 할 수 있고 매우 좋습니다.
ListContainer 컨테이너 컴포넌트 만들기
ListContainer 컴포넌트도 만들어봅시다! 이전에 했던 것과 크게 다를것이 없습니다.
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import List from '../components/List';
import { State } from '../store/modules';
import { Info, listActions } from '../store/modules/list';
interface ListContainerProps {
ListActions: typeof listActions;
input: string;
list: Info[];
}
class ListContainer extends React.Component<ListContainerProps> {
public handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { ListActions } = this.props;
ListActions.setInput(e.target.value);
};
public handleInsert = () => {
const { input, ListActions } = this.props;
ListActions.insert(input);
ListActions.setInput('');
};
public render() {
const { input, list } = this.props;
return (
<List onChange={this.handleChange} onInsert={this.handleInsert} input={input} list={list} />
);
}
}
export default connect(
({ list }: State) => ({
input: list.input,
list: list.list,
}),
dispatch => ({
ListActions: bindActionCreators(listActions, dispatch),
})
)(ListContainer);
혹은, mapStateToProps 와 mapDispatchToProps 를 분리하여 이렇게 작성해줄수도 있겠죠?
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import List from '../components/List';
import { State } from '../store/modules';
import { listActions } from '../store/modules/list';
type StateProps = ReturnType<typeof mapStateToProps>;
type DispatchProps = ReturnType<typeof mapDispatchToProps>;
type ListContainerProps = StateProps & DispatchProps;
class ListContainer extends React.Component<ListContainerProps> {
public handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { ListActions } = this.props;
ListActions.setInput(e.target.value);
};
public handleInsert = () => {
const { input, ListActions } = this.props;
ListActions.insert(input);
ListActions.setInput('');
};
public render() {
const { input, list } = this.props;
return (
<List onChange={this.handleChange} onInsert={this.handleInsert} input={input} list={list} />
);
}
}
const mapStateToProps = ({ list }: State) => ({
input: list.input,
list: list.list,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
ListActions: bindActionCreators(listActions, dispatch),
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(ListContainer);
정리
타입스크립트를 리덕스와 함께 사용하면 기존에 불편하다고 느꼈을만한 부분들이 많이 해소됩니다. 하지만, 물론 이 때문에 작성해야 할 코드가 조금 늘어나긴 하지만 타입스크립트의 자동완성 기능을 통해서 더 높은 생산성을 얻을 수 있기 때문에 이는 충분히 조금의 노력을 투자할 만한 가치가 있습니다.