TypeScript 로 스마트하게 리덕스 사용하기

이번에는, 타입스크립트를 사용하는 리액트 환경에서 리덕스를 사용하는 방법을 알아보겠습니다. 타입스크립트와 리덕스를 함께 사용한다면 다음과 같은 이점들이 있습니다.

  • connect 하게 될 때 리덕스 스토어 안에 들어있는 값들의 타입추론 + 자동완성
  • 액션 생성 함수를 사용하게 될 때 자동완성
  • 리듀서를 작성하게 될 때 액션에 어떤 값이 들어있는지 확인 가능

이 실습은 다음 흐름으로 진행됩니다.

  1. 리액트 + 리덕스 + 타입스크립트 작업환경 설정
  2. 카운터 구현 (일반 방식으로)
  3. 리스트 구현 (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);

정리

타입스크립트를 리덕스와 함께 사용하면 기존에 불편하다고 느꼈을만한 부분들이 많이 해소됩니다. 하지만, 물론 이 때문에 작성해야 할 코드가 조금 늘어나긴 하지만 타입스크립트의 자동완성 기능을 통해서 더 높은 생산성을 얻을 수 있기 때문에 이는 충분히 조금의 노력을 투자할 만한 가치가 있습니다.

results matching ""

    No results matching ""