TypeScript with React + Redux/Immutable.js 빠르게 배우기


 

 

이 포스트는 velog 에 새로 리뉴얼됐습니다. 다음 링크를 클릭하여 새 포스트를 읽어주세요!

  1. 리액트 프로젝트에서 타입스크립트 사용하기
  2. 타입스크립트 기초 연습
  3. 리액트 컴포넌트 타입스크립트로 작성하기
  4. 타입스크립트로 리액트 Hooks 사용하기 (useState, useReducer, useRef)
  5. TypeScript 환경에서 리액트 Context API 제대로 활용하기
  6. TypeScript 환경에서 Redux를 프로처럼 사용하기

 

 

 

타입스크립트를 리액트와 함께 사용하게 됐을 때 어떠한 이점이 있는지, 또 어떻게 사용해야하는지 빠르게 한번 배워봅시다!

프로젝트에 사용된 코드: https://github.com/velopert/typescript-react-sample

이 강의는 FastCampus 오프라인 강의 에서 사용된 자료이며 부연설명이 생략되어있습니다.

서론

JavaScript 는 weakly typed 언어 입니다. 따라서,

이런게 너무 자연스럽게됩니다. 변수가 숫자였다가 또 문자열이였다가 할 수 있죠.

타입스크립트를 사용하면 다음과 같이 레퍼런스에 타입을 지정해줄 수 있습니다.

한번 숫자 타입이라고 지정한 값엔, 숫자로만 사용 할 수 있고 문자열을 넣게 되거나 다른 타입의 값을 넣어주게 된다면 오류가 발생하게 됩니다.

이를 통하여, 잠재적인 버그들을 런타임 전에 미리 잡아 줄 수 있습니다. 예를 들어 다음과 같은 함수가 있다고 가정해봅시다.

이 함수는 숫자로 이뤄진 배열을 받아와서 총합을 계산해줍니다. 이 함수의 인자로 만약에 문자열을 넣어준다면 어떻게될까요?

문자열에 reduce 함수가 내장되어있지 않기 때문에 오류가 런타임에서 발생하게 됩니다.

반면에, 타입스크립트를 사용하면 어떨까요?

파라미터와 결과물에도 타입을 지정해줄수 있어서, 자동완성의 도움을 얻을 수도 있구요,

잘못된 파라미터를 전달해주면 런타임전에 뭔가 잘못됐다는 것을 잡아줄 수 있습니다.

그렇다면, 리액트에서 타입스크립트를 사용하면 어떤 이점이 있을까요? 대표적으로는 다음과 같은 사항들이 있습니다.

PropTypes 대신 사용 할 수 있다

리액트의 PropTypes, 아마 사용해보셨겠지요? 컴포넌트의 props 에 타입을 지정해주고, 만약에 잘못된 값을 전달해주면 브라우저의 콘솔창에 경고를 띄워줍니다. 반면에, 타입스크립트를 사용하게되면, 브라우저의 콘솔이 아닌, 브라우저에서 실행하기도 전에 여러분의 IDE 혹은 콘솔에서 에러를 확인 할 수 있게 됩니다.

자동완성

타입스크립트를 사용하게 되면 자동완성이 더욱 수준 높게 작동하게 됩니다. 컴포넌트를 작성 할 때, 해당 컴포넌트가 어떤 props 를 필요로하는지, 자동완성을 통해서 볼 수도 있구요, 가장 실용적이라고 생각하는 부분은 리덕스와 함께 사용하게 됐을 때, 액션생성함수를 생성하게 될 때, 그리고 connect 의 mapStateToProps 를 통하여 리덕스 스토어에 있는 상태를 props 로 주입해줄때, 자동완성이 됩니다. 만약에 타입스크립트가 없다면 일일히 모듈 코드를 확인해가면서 해야하죠.

1. TypeScript & React 프로젝트 시작하기

프로젝트 만들기

CRA 를 통해서 TypeScript 가 탑재된 프로젝트를 손쉽게 생성 할 수 있습니다.

$ create-react-app typescript-react-tutorial --scripts-version=react-scripts-ts

그 다음엔, 평상시에 CRA 를 통하여 만든 프로젝트에서 작업해왔듯이, 코드 에디터로 해당 디렉토리를 열고, yarn start 로 개발 서버를 시작하세요.

첫 컴포넌트 만들기

타입스크립트를 사용하여 컴포넌트를 만들어봅시다. 컴포넌트를 만들 땐, 확장자를 tsx 로 하셔야 합니다.

src/components/Profile.tsx

import * as React from 'react';

interface Props {
  name: string;
  job: string;
}

class Profile extends React.Component<Props> {
  render() {
    const { name, job } = this.props;
    return (
      <div>
        <h1>프로필</h1>
        <div>
          <b>이름: </b>
          {name}
        </div>
        <div>
          <b>직업: </b>
          {job}
        </div>
      </div>
    );
  }
}

export default Profile;

이 컴포넌트가 어떤 Props 를 받게 될 지 미리 정의를 해주었습니다. 이게 있으면, 더 이상 우리가 PropTypes 를 사용 할 필요가 없습니다. 이렇게 타입 시스템을 사용하면, PropTypes 를 사용하는 것 보다 훨씬 유용합니다. PropTypes 는 런타임에서 props 를 검증하는 방면, 이렇게 타입 시스템을 사용하면, 코드를 실행하기 전 부터 값이 누락되었거나 잘못된 형태를 전달해주었을 때 바로 알 수 있습니다.

이제, 이 컴포넌트를 App 에서 렌더링 해보세요.

src/App.tsx

import * as React from 'react';
import Profile from './components/Profile';

class App extends React.Component {
  render() {
    return (
      <div>
        <Profile />
      </div>
    );
  }
}

export default App;

오류가 발생 할 것입니다!

/src/App.tsx
(8,9): Type '{}' is not assignable to type 'IntrinsicAttributes & IntrinsicClassAttributes<Profile> & Readonly<{ children?: ReactNode; }> & R...'.
  Type '{}' is not assignable to type 'Readonly<Props>'.
    Property 'name' is missing in type '{}'.

name 값이 없다고 에러가 나타나지요?
그러면, name 값과 job 값을 props 로 전달해봅시다.

src/App.tsx

import * as React from 'react';
import Profile from './components/Profile';

class App extends React.Component {
  render() {
    return (
      <div>
        <Profile
          name="벨로퍼트"
          job="코드사랑꾼"
        />
      </div>
    );
  }
}

export default App;

렌더링도 잘 될 것입니다.

자, 이번엔 이 컴포넌트를 함수형 컴포넌트로 작성해보겠습니다.

src/components/Profile.tsx

import * as React from 'react';

interface Props {
  name: string;
  job: string;
}

const Profile = ({name, job}: Props) => (
  <div>
    <h1>프로필</h1>
    <div>
      <b>이름: </b>
      {name}
    </div>
    <div>
      <b>직업: </b>
      {job}
    </div>
  </div>
);

export default Profile;

따로 타입을 지정해주지 않아도, 자동으로 유추해냅니다. Profile 에 마우스 커서를 올려보면 다음과 같이 뜰 것입니다:

const Profile: ({ name, job }: Props) => JSX.Element

하지만, 조금 더 세밀하게 하고 싶다면, 다음과 같이 SFC 를 사용하여 타입 지정을 해주면 됩니다.

src/components/Profile.tsx

import * as React from 'react';

interface Props {
  name: string;
  job: string;
}

const Profile: React.SFC<Props> = ({name, job}) => (
  <div>
    <h1>프로필</h1>
    <div>
      <b>이름: </b>
      {name}
    </div>
    <div>
      <b>직업: </b>
      {job}
    </div>
  </div>
);

export default Profile;

SFC 를 사용하지 않았을 때의 문제점은, 심각하지는 않지만 만약에 컴포넌트에서 JSX 가 아닌 문자열을 리턴하게 되는 경우 오류가 Profile 에서가 아닌 App 에서 나타난다는 점 입니다.

참고: https://github.com/Mercateo/react-with-typescript#stateless-functional-components

2. 카운터 만들기

이번에는 리액트 컴포넌트의 state 도 사용하는 카운터를 만들어보겠습니다.

src/components/Counter.tsx

import * as React from 'react';

interface Props {

}

interface State {
  counter: number;
}

class Counter extends React.Component<Props, State> {
  state: State = {
    counter: 0
  };

  onIncrement = (): void => {
    this.setState(
      ({ counter }) => ({ counter: counter + 1 })
    );
  }

  onDecrement = (): void => {
    this.setState(
      ({ counter }) => ({ counter: counter - 1 })
    );
  }

  render() {
    const { onIncrement, onDecrement } = this;
    return (
      <div>
        <h1>카운터</h1>
        <h3>{this.state.counter}</h3>
        <button onClick={onIncrement}>+</button>
        <button onClick={onDecrement}>-</button>
      </div>
    );
  }
}

export default Counter;

State 를 사용하는 경우엔 위와 같이 extends React.Component<Props, State> 이렇게 뒷 부분에 State 도 추가해주시면 됩니다.

다 하셨으면 App 에 렌더링하세요.

src/App.tsx

import * as React from 'react';
import Profile from './components/Profile';
import Counter from './components/Counter';

class App extends React.Component {
  render() {
    return (
      <div>
        <Profile
          name="벨로퍼트"
          job="코드사랑꾼"
        />
        <Counter />
      </div>
    );
  }
}

export default App;

버튼들을 눌러서 숫자가 바뀌는지 확인하세요.

그 다음엔, 한번 실험 삼아 setState 하는 부분에서 숫자가 아닌 다른 값을 전달해보세요.

  onIncrement = (): void => {
    this.setState(
      ({ counter }) => ({ counter: (counter + 1).toString() })
    );
  }

당연하지만, 에러가 납니다.

/src/components/Counter.tsx
(18,7): Argument of type '({ counter }: Readonly<State>) => { counter: string; }' is not assignable to parameter of type 'State | ((prevState: Readonly<State>, props: Props) => State | Pick<State, "counter"> | null) | P...'.
  Type '({ counter }: Readonly<State>) => { counter: string; }' is not assignable to type 'Pick<State, "counter">'.
    Property 'counter' is missing in type '({ counter }: Readonly<State>) => { counter: string; }'.

만약에 타입 체킹을 하지 않았다면 어쩌다가 이런 실수를 저질렀을 때, 런타임에서 뭔가 제대로 안되는것을 보고 확인 했을 것입니다. 하지만, 우리는 타입스크립트가 있었기에, 실행하기도 전에 코드가 문제가 있는 것을 발견 할 수 있었죠!

그럼 다시 원상복구 하고 다음으로 넘어가봅시다!

3. 투두리스트 만들기

타입스크립트를 조금 더 활용하기 위해 투두리스트를 만들어보겠습니다.

우리는 TodoList 라는 컴포넌트에서 state 를 설정하여 할 일 목록 데이터를 관리하도록 하고, 각 항목을 TodoItem 이라는 컴포넌트를 통하여 보여주도록 하겠습니다.

우선 TodoItem 을 만들어보겠습니다.

src/components/TodoItem.tsx

import * as React from 'react';

interface Props {
  text: string;
  done: boolean;
  onToggle(): void;
  onRemove(): void;
}

const TodoItem: React.SFC<Props> = ({ text, done, onToggle, onRemove }) => (
  <li>
    <b 
      onClick={onToggle} 
      style={{
      textDecoration: done ? 'line-through' : 'none',
      }}
    >
      {text}
    </b>
    <span style={{marginLeft: '0.5rem'}} onClick={onRemove}>[지우기]</span>
  </li>
);

export default TodoItem;

그 다음에는, TodoList 컴포넌트도 만들어봅시다. 이 컴포넌트 또한 아까 만들었던 Counter 처럼 따로 Props 는 없으니, 해당 부분은 비워주겠습니다.

src/components/TodoList.tsx

import * as React from 'react';
import TodoItem from './TodoItem';

interface Props {

}

interface TodoItemData {
  id: number;
  text: string;
  done: boolean;
}

interface State {
  todoItems: TodoItemData[]; // TodoItemData 로 이뤄진 배열
  input: string;
}

class TodoList extends React.Component<Props, State> {

  id: number = 0;

  state: State = {
    todoItems: [],
    input: '',
  };

  onToggle = (id: number): void => {
    const { todoItems } = this.state;
    const index = todoItems.findIndex(todo => todo.id === id); // id 로 인덱스 찾기
    const selectedItem = todoItems[index]; //  아이템 선택
    const nextItems = [ ...todoItems ]; // 배열 내용을 복사

    const nextItem = {
      ...selectedItem,
      done: !selectedItem.done,
    };

    nextItems[index] = nextItem; // 교체 처리

    this.setState({
      todoItems: nextItems
    });
  }

  onRemove = (id: number): void => {
    this.setState(
      ({ todoItems }) => ({
        todoItems: todoItems.filter(todo => todo.id !== id)
      })
    );
  }

  onChange = (e: React.FormEvent<HTMLInputElement>): void => {
    const { value } = e.currentTarget;
    this.setState({
      input: value
    });
  }

  onSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault(); // 페이지 전환 막기
    // input 비우고, todoItems 추가
    this.setState(
      ({ todoItems, input }) => ({
        input: '',
        todoItems: todoItems.concat({
          id: this.id++,
          text: input,
          done: false
        })
      })
    );
  }

  render() {
    const { onSubmit, onChange, onToggle, onRemove } = this;
    const { input, todoItems } = this.state;

    const todoItemList = todoItems.map(
      todo => (
        <TodoItem
          key={todo.id}
          done={todo.done}
          onToggle={() => onToggle(todo.id)}
          onRemove={() => onRemove(todo.id)}
          text={todo.text}
        />
      )
    );

    return (
      <div>
        <h1>오늘 뭐하지?</h1>
        <form onSubmit={onSubmit}>
          <input onChange={onChange} value={input} />
          <button type="submit">추가하기</button>
        </form>
        <ul>
          {todoItemList}
        </ul>
      </div>
    );
  }
}

export default TodoList;

컴포넌트에 만들게 되는 메소드의 파라미터에도, 타입 지정을 할 수 있습니다. 그래서, 만약에 파라미터의 형태가 잘못된것을 전달하게 된다면, 런타임 전에 미리 알 수 있답니다.

투두 리스트가 제대로 작동하는지 확인해보세요

4. 리덕스와 함께 사용하기

타입스크립트를 사용한다면, 컨테이너 컴포넌트를 만들게 될 때, 그리고 리듀서를 작성하게 될 때, 단순히 타입 체킹 뿐만이 아니라 자동완성이 되므로 생산성이 매우 향상됩니다.

리덕스 관련 패키지 설치

우리의 프로젝트에 리덕스를 적용하기 위하여, 사용 할 라이브러리들을 설치하겠습니다.

$ yarn add redux react-redux immutable redux-actions

새 패키지를 설치하게 된다면, 해당 패키지들에도 타입 지원을 받기 위하여 각 패키지를 위한 타입 패키지 또한 받아주어야 합니다.

$ yarn add --dev @types/redux @types/react-redux @types/immutable @types/redux-actions

설치를 하면 다음과 같은 문구를 볼 수 있는데요:

warning ../../package.json: No license field
[1/4] Resolving packages...
warning @types/immutable@3.8.7: This is a stub types definition for Facebook's Immutable (https://github.com/facebook/immutable-js). Facebook's Immutable provides its own type definitions, so you don't need @types/immutable installed!
warning @types/redux@3.6.0: This is a stub types definition for Redux (https://github.com/reactjs/redux). Redux provides its own type definitions, so you don't need @types/redux installed!

이 뜻은 즉 immutable 과 redux 의 경우 이미 TypeScript 지원이 내장되어있으니 따로 설치 할 필요가 없다는 의미입니다.

앞으로 이런걸 확인하게 되면, 지워주세요.

$ yarn remove @types/immutable @types/redux

카운터 리덕스 모듈 작성

우리는 우리가 이전에 배웠던 리덕스 Ducks 구조를 사용하겠습니다 (액션 타입, 액션 생성 함수, 리듀서를 한 파일에 작성)

src/store/modules/counter.ts

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

const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';

export const actionCreators = {
  increment: createAction(INCREMENT),
  decrement: createAction(DECREMENT),
};

export interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0
};

export default handleActions<CounterState>(
  {
    [INCREMENT]: (state) => ({ value: state.value + 1 }),
    [DECREMENT]: (state) => ({ value: state.value - 1 }),
  }, 
  initialState
);

이번 파일의 경우 컴포넌트가 아니니 .tsx 가 아닌 .ts 확장자로 저장했습니다. 이 모듈은 워낙 간단해서, 액션의 경우 따로 들어가는 인자가 필요 없습니다.

스토어 생성 및 적용

코드를 다 작성하셨다면, 루트 리듀서를 만들어주겠습니다 – 지금은 물론 리듀서가 하나 뿐이지만, 나중에 투두리스트를 위한 모듈도 만들어 줄 것이니 미리 combineReducers 를 사용하겠습니다.

src/store/modules/index.ts

import { combineReducers } from 'redux';
import counter, { CounterState } from './counter';

export default combineReducers({
  counter
});

// 스토어의 상태 타입 정의
export interface StoreState {
  counter: CounterState;
}

평상시와 비슷하지만, 추가적으로 CounterState 도 불러와서 StoreState 라는 인터페이스에 넣어주었습니다.

자, 이제 스토어를 생성하는 함수를 만들고, 프로젝트에 스토어를 적용해주겠습니다.

src/store/configureStore.ts

import modules from './modules';
import { createStore } from 'redux';

export default function configureStore() {
  const store = createStore(
    modules, /* preloadedState, */
    (window as any).__REDUX_DEVTOOLS_EXTENSION__ && (window as any).__REDUX_DEVTOOLS_EXTENSION__()
   );
  return store;
}

window 객체에 원래는 REDUX_DEVTOOLS_EXTENSION 이 없으므로 에러가 날 것입니다. 따라서, 우리는 타입을 강제 캐스팅 (Type Assertion) 하겠습니다.

any 를 사용하면, 문법 검사 시스템에서 사용하지 말라고 토를 달 것입니다.
any 를 자주 쓰는건 물론 좋지 않지만, 가끔씩은 이렇게 써야 하는 상황도 있습니다. 따라서, 문법 검사 설정을 변경하여 오류가 나타나지 않게 설정하세요.

루트 디렉토리에서 tslint.json 을 열어서

        "no-any": false,

no-any 부분을 찾아서 false 로 지정해주면 됩니다.

에러가 바로 사라지지 않는다면 개발서버를 재시작하세요.

이제 index.tsx 에서 Provider 를 통해 리덕스 스토어를 적용시키세요.

src/index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import './index.css';
import configureStore from './store/configureStore';

const store = configureStore();

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

카운터 컨테이너 컴포넌트 만들기

자, 이제 컨테이너 컴포넌트를 만들어볼 차례입니다. 그 전에, 디렉토리를 조금 더 편하게 불러올 수 있도록 NODE_PATH 와 baseUrl 값을 설정해줄게요.

NODE_PATH 설정

NODE_PATH 설정은, 프로젝트의 최상단 디렉토리에 .env 파일을 만들어서 값을 입력해주면됩니다.

/.env

NODE_PATH=src

그 다음에는, 타입스크립트 도구가 제대로 이를 인식 할 수 있도록 baseUrl 설정을 추가하세요.

/tsconfig.json

  "compilerOptions": {
    "outDir": "build/dist",
    ...,
    "baseUrl": "./src"
  },
  "exclude": [
    ...

그 다음엔 VSCode 와 개발 서버를 재시작해주세요.

이제 본격적으로 컨테이너 컴포넌트를 만들기 전에, 기존의 Counter 컴포넌트를 프리젠테이셔널 컴포넌트로서 사용하기 위해 들고있던 State 와 메소드들을 제거해주겠습니다.

src/components/Counter.tsx

import * as React from 'react';

interface Props {
  value: number;
  onIncrement(): void;
  onDecrement(): void;
}

const Counter: React.SFC<Props> = ({ value, onIncrement, onDecrement }) => (
  <div>
    <h2>카운터</h2>
    <h3>{value}</h3>
    <button onClick={onIncrement}>+</button>
    <button onClick={onDecrement}>-</button>
  </div>
);

export default Counter;

이제 컨테이너 컴포넌트를 작성해보세요.

src/containers/CounterContainer.tsx

import * as React from 'react';
import Counter from 'components/Counter';
import { actionCreators as counterActions } from 'store/modules/counter';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { StoreState } from 'store/modules';

type Props = {
  value: number;
  CounterActions: typeof counterActions;
};

class CounterContainer extends React.Component<Props> {
  onIncrement = () => {
    const { CounterActions } = this.props;
    CounterActions.increment();
  }
  onDecrement = () => {
    const { CounterActions } = this.props;
    CounterActions.decrement();
  }
  render() {
    const { onIncrement, onDecrement } = this;
    const { value } = this.props;
    return (
      <Counter
        onIncrement={onIncrement}
        onDecrement={onDecrement}
        value={value}
      />
    );
  }
}

export default connect(
  ({ counter }: StoreState) => ({
    value: counter.value
  }),
  (dispatch) => ({
    CounterActions: bindActionCreators(counterActions, dispatch)
  })
)(CounterContainer);

타입스크립트와 함께라면, 리덕스 스토어의 내부 값, 그리고 bindActionCreators 를 통해 전달된 액션생성함수에 대한 자동완성 지원을 제대로 받을 수 있습니다.


다 하셨나요? 그렇다면 App 에서 기존에 보여주던 Counter 를 CounterContainer 로 바꿔주세요.

src/App.tsx

import * as React from 'react';
import Profile from './components/Profile';
import CounterContainer from './containers/CounterContainer';
import TodoList from './components/TodoList';

class App extends React.Component {
  render() {
    return (
      <div>
        <Profile
          name="벨로퍼트"
          job="코드사랑꾼"
        />
        <CounterContainer />
        <TodoList />
      </div>
    );
  }
}

export default App;

투두리스트 리덕스 모듈 생성

이번에 만들 리덕스 모듈은 조금 복잡합니다.

src/store/modules/todos.ts

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

const CREATE = 'todos/CREATE';
const REMOVE = 'todos/REMOVE';
const TOGGLE = 'todos/TOGGLE';
const CHANGE_INPUT = 'todos/CHANGE_INPUT';

type CreatePayload = string;
type RemovePayload = number;
type TogglePayload = number;
type ChangeInputPayload = string;

/* type AnotherPayload = {
  something: string;
  like: number;
  this: boolean
}; */

export const actionCreators = {
  create: createAction<CreatePayload>(CREATE),
  remove: createAction<RemovePayload>(REMOVE),
  toggle:  createAction<TogglePayload>(TOGGLE),
  changeInput: createAction<ChangeInputPayload>(CHANGE_INPUT)
};

const TodoItemRecord = Record({
  id: 0,
  text: '',
  done: false
});

interface TodoItemDataParams {
  id?: number;
  text?: string;
  done?: boolean;
}

export class TodoItemData extends TodoItemRecord {
  static autoId = 0;
  id: number;
  text: string;
  done: boolean;
  constructor(params?: TodoItemDataParams) {
    const id = TodoItemData.autoId;
    if (params) {
      super({
        ...params,
        id,
      });
    } else {
      super({ id });
    }
    TodoItemData.autoId = id + 1;
  }
}

const TodosStateRecord = Record({
  todoItems: List(),
  input: ''
});

export class TodosState extends TodosStateRecord {
  todoItems: List<TodoItemData>;
  input: string;
}

const initialState = new TodosState();

export default handleActions<TodosState, any>(
  {
    [CREATE]: (state, action: Action<CreatePayload>): TodosState => {
      return <TodosState> state.withMutations(
        s => {
          s.set('input', '')
          .update('todoItems', (todoItems: List<TodoItemData>) => todoItems.push(
            new TodoItemData({ text: action.payload })
          ));
        }
      );
    },
    [REMOVE]: (state, action: Action<RemovePayload>): TodosState => {
      return <TodosState> state.update(
        'todoItems',
        (todoItems: List<TodoItemData>) => todoItems.filter(
          t => t ? t.id !== action.payload : false
        )
      );
    },
    [TOGGLE]: (state, action: Action<TogglePayload>): TodosState => {
      const index = state.todoItems.findIndex(t => t ? t.id === action.payload : false);
      return <TodosState> state.updateIn(['todoItems', index, 'done'], done => !done);
    },
    [CHANGE_INPUT]: (state, action: Action<ChangeInputPayload>): TodosState => {
      return <TodosState> state.set('input', action.payload);
    },
  },
  initialState
);

우리가 실무에서 사용하는 것 처럼, Immutable, createAction, handleActions 를 사용했습니다.

Immutable 을 사용하게 될 때에는, Map 대신에 Record 를 사용하면 타입스크립트의 이점을 제대로 누릴 수 있습니다.

참고: Immutable.js Records in Typescript

추가적으로, 각 액션의 Payload 또한 타입을 지정해두면, 각 액션 생성함수를 호출하거나, 혹은 리듀서에서 액션을 처리하게 될 때 큰 노력 없이도 바로 어떠한 종류의 payload 를 가진 액션인지 파악 가능합니다.

리듀서의 각 함수를 보면 return <TodosState> state... 이런식으로 되어있지요? 이 부분은 기본적으로 state.update, state.set 등의 함수의 결과물 타입이 Map 이기 때문에 이를 다시 타입 캐스팅 해주는 것 입니다. 이 문법은 우리가 이전에 배운 window as any 랑 같은 역할을 합니다. 따라서, return state…. as TodosState 와 같은 형식으로 하셔도 무방합니다.

모듈을 다 만들었다면, combineReducers 쪽과 StoreState 에 추가시켜주세요.

src/store/modules/index.ts

import { combineReducers } from 'redux';
import counter, { CounterState } from './counter';
import todos, { TodosState } from './todos';

export default combineReducers({
  counter,
  todos,
});

// 스토어의 상태 타입 정의
export interface StoreState {
  counter: CounterState;
  todos: TodosState;
}

TodoList 컴포넌트 프리젠테이셔널 컴포넌트로 전환

TodoList 컴포넌트에 있던 state 를 없애고, 함수형 컴포넌트로 전환해주겠습니다.

src/components/TodoList.tsx

import * as React from 'react';
import TodoItem from './TodoItem';
import { TodoItemData } from 'store/modules/todos';
import { List } from 'immutable';

interface Props {
  input: string;
  todoItems: List<TodoItemData>;
  onCreate(): void;
  onRemove(id: number): void;
  onToggle(id: number): void;
  onChange(e: any): void;
}

const TodoList: React.SFC<Props> = ({
  input, todoItems, onCreate, onRemove, onToggle, onChange
}) => {
  const todoItemList = todoItems.map(
    todo => todo ? (
      <TodoItem
        key={todo.id}
        done={todo.done}
        onToggle={() => onToggle(todo.id)}
        onRemove={() => onRemove(todo.id)}
        text={todo.text}
      />
    ) : null
  );

  return (
    <div>
      <h1>오늘 뭐하지?</h1>
      <form 
        onSubmit={
          (e: React.FormEvent<HTMLFormElement>) => {
            e.preventDefault();
            onCreate();
          }
        }
      >
        <input onChange={onChange} value={input} />
        <button type="submit">추가하기</button>
      </form>
      <ul>
        {todoItemList}
      </ul>
    </div>
  );
};

export default TodoList;

TodoListContainer 만들기

아까 만들었던것과 비슷하게 컨테이너 컴포넌트를 만들어주겠습니다. 이번에 조금 특별한점은, 해당 모듈에서 사용하던 TodoItemData 를, 컴포넌트의 Props 에 받아오게 되니, 액션 생성함수들을 불러올 때 함께 TodoItemDate 타입도 불러와서 사용하게 됩니다.

src/containers/TodoListContainer.tsx

import * as React from 'react';
import TodoList from 'components/TodoList';
import { connect } from 'react-redux';
import { StoreState } from 'store/modules';
import { 
  TodoItemData,
  actionCreators as todosActions
} from 'store/modules/todos';
import { bindActionCreators } from 'redux';
import { List } from 'immutable';

interface Props {
  todoItems: List<TodoItemData>;
  input: string;
  TodosActions: typeof todosActions;
}

class TodoListContainer extends React.Component<Props> {
  onCreate = () => {
    const { TodosActions, input } = this.props;
    TodosActions.create(input);
  }
  onRemove = (id: number) => {
    const { TodosActions } = this.props;
    TodosActions.remove(id);
  }
  onToggle = (id: number) => {
    const { TodosActions } = this.props;
    TodosActions.toggle(id);
  }
  onChange = (e: React.FormEvent<HTMLInputElement>) => {
    const { value } = e.currentTarget;
    const { TodosActions } = this.props;
    TodosActions.changeInput(value);
  }
  render() {
    const { input, todoItems } = this.props;
    const { onCreate, onRemove, onToggle, onChange } = this;

    return (
      <TodoList
        input={input}
        todoItems={todoItems}
        onCreate={onCreate}
        onRemove={onRemove}
        onToggle={onToggle}
        onChange={onChange}
      />
    );
  }
}

export default connect(
  ({ todos }: StoreState) => ({
    input: todos.input,
    todoItems: todos.todoItems,
  }),
  (dispatch) => ({
    TodosActions: bindActionCreators(todosActions, dispatch),
  })
)(TodoListContainer);

다 끝났습니다! App 에서 기존에 보여주던 TodoList 를 TodoListContainer 로 교체하세요.

src/App.tsx

import * as React from 'react';
import Profile from './components/Profile';
import CounterContainer from './containers/CounterContainer';
import TodoListContainer from './containers/TodoListContainer';

class App extends React.Component {
  render() {
    return (
      <div>
        <Profile
          name="벨로퍼트"
          job="코드사랑꾼"
        />
        <CounterContainer />
        <TodoListContainer />
      </div>
    );
  }
}

export default App;

5. 정리

우리는 타입스크립트를 리액트에서 쓰면 어떠한 점이 편한지 알아보기 위해서, 기초 개념은 조금 생략하고 활용 부분으로 바로 넘어갔습니다. 타입스크립트를 사용하면, 우리가 할 수 있는 실수를 잡아줄 뿐만 아니라 자동완성 기능이 더욱 완벽하게 작동하여 개발 할 때 생산성을 높여주기도 합니다.

자바스크립트 앱에 타입 시스템을 적용하는것은, 필수 작업은 아니지만 우리가 이전에 자바스크립트 테스트를 했던 것 처럼, 우리가 작성하는 코드에 조금 더 자신감을 가질 수 있게 되고, 조금 더 탄탄한 코드를 작성하는 것을 도와줍니다.

여러분들이 지금 당장 프로젝트에서 타입스크립트를 사용 할 필요는 없습니다 (저도 오랜 기간동안 타입스크립트 없이 개발을 해왔고, 실무에서도 아직까지 타입스크립트 없어도 큰 문제 없이 작업 하는 프로젝트도 있기도 합니다)

타입스크립트를 조금 더 깊게 공부해보고 싶다면 이현섭 님의 블로그 를 참고하는것을 추천드립니다.

영어 문서에도 문제가 없다면 TypeScript Handbook 도 권장합니다.

 

Reference

  • 타입스크립트가 주는 장점에 대해서 잘 설명해주셨군요 좋은 글 감사합니다.
    타입스크립트는 처음 접근하기엔 설정에 대한것이나 학습 비용 또한 필요하다는 분명한 단점 또한 있습니다..
    단점이나 접근성에 대한것도 설명 해주셨으면 더 좋았을것 같아서 아쉬운 맘에 댓글 작성합니다.

  • seong-hun shin

    프로젝트에 사용된 코드의 깃헙 링크가 잘못된 것이 아닌가 싶습니다. 이곳을 말씀하시는게 아닌가합니다-> https://github.com/velopert/typescript-react-sample
    항상 강좌 잘 보고 있습니다, 감사합니다.

  • Jaewoo Tony Cho

    리듀서를 작성할때 주석처리 하신 AnotherPayload 부분 처럼 type을 객체로 정의하게 될때 handleActions 내부에서 action.payload.something 이 payload undefined 로 에러가 나는데 혹시 이 부분은 어떻게 해결하면 좋을까요?

    • Jaewoo Tony Cho

      strictNullChecks 때문에 항상 if 문으로 payload 체크를 해야하는건지.. 이걸 false 하려니 타입스크립트 쓰는 장점이 확 줄거 같아서..

  • 박성진

    velopert 님 여기서 이런 질문글 올려서 죄송하지만 현재 rest api 사용해서 소셜 로그인 구현중인데요.
    bitimulate 에서 소셜 로그인 구현한게 rest 를 사용하신건가요 ? 아니면 javascript 인건가요
    지금 페이지 이동경로가 매우 꼬여버리네요

  • Beom Yeon Andrew Kim

    정적 타입의 언어를 접하지 않은 상태에서 타입스크립트를 적용하는 중이어서 어려움을 겪고 있었는데 좋은 길 감사합니다!

  • ideveloper

    1.interface name must start with a capitalized I
    2.’private’ ‘public’ or ‘protected’ warnings
    위 두가지와 같은 에러가 떠서 tslint.json 파일에
    “rules”: {
    “interface-name”:[false],
    “member-access”: [false]
    }
    아래내용을 추가하니 해결되네요!

  • 이현석

    /src/components/TodoList.tsx
    TypeError: Cannot read property ‘map’ of undefined 에러가 발생하였습니다.
    todoItems 값 (TodoItemData)이 Null이라 판단되어 수정을 해봐도 동일하네요..
    어떤 방식으로 해결해야 할까요..?

  • jin

    typescript-react-tutorial/src/store/modules/todos.ts
    (89,55): Argument of type ‘string | undefined’ is not assignable to parameter of type ‘string’.
    Type ‘undefined’ is not assignable to type ‘string’.
    혹시 이게 무슨 문제인지 알 수 있을까요