서론

최근 취업을 위해 서류를 제출하면서 우대 사항에 TypeScript 에 대한 경험이 많이 적혀있는 것을 보고 공부를 해야겠다 생각하였습니다. 그래서 TypeScript React를 구글링을 하였는데...

image.png

벨로퍼트님... 그는 React의 신인가?..
벨로퍼트님의 블로그 === React의 라프텔 // True 트루 참트루

검색 결과 이미 1년 전에 벨로퍼트님이 글을 쓰신 게 있어서 이 예제로 튜토리얼을 시작하기로 하였습니다.

하지만 거의 약 1년 전의 글이다 보니 예제의 일부분이 동작하지 않았습니다. 그래서...

image.png

벨로퍼트님의 허락을 받고 벨로퍼트님의 예제를 기반으로 한 TodoList를 진행한 경험을 공유하기 위해 글을 쓰게 되었습니다.

혹시나 TypeScript 를 사용함으로써 오는 장점들이나 상세한 내용들은 아래의 링크들에 상세히 설명되어있습니다. 저도 아직 공부하는 입장으로 잘못된 정보를 전달 할 수 있어 링크들로 대체합니다.

이 포스트의 예제 코드는 제 저장소에서 확인하실 수 있습니다.


CRA with TypeScript 프로젝트 생성

우선 TodoList를 만들기에 앞서 CRA 프로젝트를 TypeScript 버전으로 생성하겠습니다.

  • npx create-react-app typescript-velopert-todolist —-typescript

  • 설치 후 src 폴더 내에 필요없는 파일들을 지워주겠습니다.

image.png

  • 그 후 App.tsx 와 index.tsx 에서 지운 파일들에 대한 코드를 수정하고 서버를 실행 시켜주세요.

image.png


ToDoList with TypeScript + React

프로젝트를 생성 하였으니 저희가 만들 TodoList를 보겠습니다.

image.png

CSS를 적용하지 않고 할 일 을 입력하면 아래 리스트로 추가되고 리스트를 클릭하여 취소 선을 긋거나 지우기를 클릭하면 할 일을 지우는 웹 애플리케이션을 만들겠습니다.

초록색 박스와 파란색 박스는 컴포넌트를 구분하기 위한 선으로 실제 애플리케이션에는 그려지지 않습니다.

React 웹 애플리케이션을 만들 때 컴포넌트를 생성하는 방법은 2가지가 있습니다.

  1. 큰 컨테이너(녹색) 부터 만들어 가는 하향식 개발 방법
  2. 작은 컨테이너(파랑색) 부터 만들어 가는 상향식 개발 방법

2개 모두 장 단점이 있으나 저는 2번 방식이 편하므로 상향식 개발을 하겠습니다.

(그래봤자 컴포넌트 2개...)

우선, 파랑색 박스의 TodoItem 부터 만들어 보겠습니다.

src/components/TodoItem.tsx

src 폴더 아래에 components 라는 폴더를 만들고 TodoItem.tsx 라는 파일을 생성해 줍시다.

그 후 아래의 코드를 작성합니다.

import 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>
        <button style={{all: 'unset' , marginLeft: '0.5rem'}} onClick={onRemove}>[지우기]</button>
    </li>
);

export default TodoItem;

interface Props 는 컴포넌트에 들어오는 인자에 대한 타입들을 TypeScript 문법으로 미리 선언 한 것입니다.

기존의 React에서는 컴포넌트에서 사용하는 인자들에 대한 정의를 PropTypes로 정의 하였지만 TypeScript는 interface라는 구현체를 구현하여 ToDoItem 컴포넌트에 React.SFC의 Generic으로 주입하여줍니다.

그 후 할 일에 대한 상태들을 관리하기 위해 녹색 박스인 TodoList 컴포넌트를 만들겠습니다.

src/components/TodoList.tsx

역시 src/components 폴더 내부에 TodoList.tsx 파일을 생성해주고 아래의 코드를 작성합니다.

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

interface Props {

}

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

interface State {
    input: string;
    todoItems: TodoItemState[];
}

class TodoList extends React.Component<Props, State> {
    nextTodoId: number = 0;

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

    onToggle = (id: number): void => {
        const { todoItems } = this.state;
        const nextTodoItems:TodoItemState[] = todoItems.map( item => {
            if(item.id === id) {
                item.done = !item.done
            }
            return item;
        });

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

    onSubmit = (e: React.FormEvent<HTMLFormElement>):void => {
        e.preventDefault();
        const { todoItems, input } = this.state;
        const newItem:TodoItemState = { id: this.nextTodoId++, text: input, done: false};
        const nextTodoItems:TodoItemState[] = todoItems.concat(newItem);
        this.setState({
            input: '',
            todoItems: nextTodoItems
        });
    }

    onRemove = (id: number): void => {
        const { todoItems } = this.state;
        const nextTodoItems: TodoItemState[] = todoItems.filter( item => item.id !== id);
        this.setState({
            todoItems: nextTodoItems
        });
    }

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

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

        const todoItemList: React.ReactElement[] = 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;

코드를 다 작성하였으면 index.tsx에 컴포넌트를 추가하여 서버를 실행시켜 주세요.

image.png

이상이 TypeScript + React 를 사용한 TodoList 만들기 였습니다.


ToDoList with TypeScript + React + Redux

이제 이 애플리케이션을 Redux를 사용하여 리팩토링 해보겠습니다.

일단 Redux 라이브러리 들을 설치하겠습니다.

  • yarn add redux react-redux

  • yarn add --dev @types/react-redux

라이브러리 설치가 끝나면 Redux 모듈을 작성해 보겠습니다.

모듈은 Duck 구조를 사용하여 작성할 것이며, 다음과 같은 순서로 작성합니다.

  1. types
  2. actions
  3. reducer

src/store/modules/todos.ts

// types

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

export interface TodoState {
  todoItems: TodoItemDataParams[];
  input: string;
}

export const CREATE = "todo/CREATE";
export const REMOVE = "todo/REMOVE";
export const TOGGLE = "todo/TOGGLE";
export const CHANGE_INPUT = "todo/CHANGE_INPUT";

interface CreateAction {
  type: typeof CREATE;
  payload: TodoItemDataParams;
}

interface RemoveAction {
  type: typeof REMOVE;
  meta: {
    id: number;
  };
}

interface ToggleAction {
  type: typeof TOGGLE;
  meta: {
    id: number;
  };
}

interface ChangeInputAction {
  type: typeof CHANGE_INPUT;
  meta: {
    input: string;
  };
}

export type TodoActionTypes =
  | CreateAction
  | RemoveAction
  | ToggleAction
  | ChangeInputAction;

// actions

let autoId = 0;

function create(text: string) {
  return {
    type: CREATE,
    payload: {
      id: autoId++,
      text: text,
      done: false
    }
  };
}

function remove(id: number) {
  return {
    type: REMOVE,
    meta: {
      id
    }
  };
}

function toggle(id: number) {
  return {
    type: TOGGLE,
    meta: {
      id
    }
  };
}

function changeInput(input: string) {
  return {
    type: CHANGE_INPUT,
    meta: {
      input
    }
  };
}

export const actionCreators = {
  create,
  toggle,
  remove,
  changeInput
};

// reducers

const initialState: TodoState = {
  todoItems: [],
  input: ""
};

export function todoReducer(
  state = initialState,
  action: TodoActionTypes
): TodoState {
  switch (action.type) {
    case CREATE:
      return {
        input: "",
        todoItems: [...state.todoItems, action.payload]
      };
    case REMOVE:
      return {
        ...state,
        todoItems: state.todoItems.filter(todo => todo.id !== action.meta.id)
      };
    case TOGGLE:
      return {
        ...state,
        todoItems: state.todoItems.map(todo => {
          if (todo.id === action.meta.id) {
            todo.done = !todo.done;
          }
          return todo;
        })
      };
    case CHANGE_INPUT:
      return {
        ...state,
        input: action.meta.input
      };
    default:
      return state;
  }
}

모듈을 작성을 하셨으면 Root Reducer를 생성하겠습니다.

src/modules/index.ts

import { combineReducers } from 'redux';
import { TodoState, todoReducer as todo } from './todos';

export interface StoreState {
    todos: TodoState;
}

export default combineReducers<StoreState>({
    todos
});

이제 스토어를 생성하여 프로젝트에 스토어를 적용 시켜 주겠습니다.

src/store/configureStore.ts

import modules, { StoreState } from "./modules";
import { createStore, Store } from "redux";

export default function configureStore():Store<StoreState> {
  const store = createStore(
    modules,
    (window as any).__REDUX_DEVTOOLS_EXTENSION__ &&
      (window as any).__REDUX_DEVTOOLS_EXTENSION__()
  );
  return store;
}

createStore에서 modules 아래에 추가한 7~8줄의 코드는 redux devtools가 설치되어 있을 시 redux 환경을 브라우저에서 보기 위해 추가하는 코드입니다.

자 스토어를 생성하였으니 이제 Redux를 애플리케이션에 적용하여 보겠습니다.

src/App.tsx

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { Provider } from "react-redux";
import configureStore from "./store/configureStore";

const store = configureStore();

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

TodoList를 프레젠터 컴포넌트로 변경

리덕스 모듈로 Todo에 대한 상태를 모두 관리하기 위해 기존의 TodoList 컴포넌트를 상태가 없는 컴포넌트로 리팩토링 하겠습니다.

src/components/TodoList.tsx

import React from "react";
import TodoItem from "./TodoItem";
import { TodoItemDataParams } from "../store/modules/todos";

interface Props {
  input: string;
  todoItems: TodoItemDataParams[];
  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<HTMLElement>) => {
          e.preventDefault();
          onCreate();
      }}>
        <input onChange={onChange} value={input} />
        <button type="submit">추가하기</button>
      </form>
      <ul>{todoItemList}</ul>
    </div>
  );
};

export default TodoList;

Todo 컨테이너 컴포넌트 생성

이제 TodoList에 상태를 관리할 컨테이너 컴포넌트를 생성하겠습니다.

src/container/TodoListContainer.tsx

import React from 'react';
import TodoList from '../components/TodoList';
import { connect } from 'react-redux';
import { StoreState } from '../store/modules';
import {
    TodoItemDataParams,
    actionCreators as todosActions,
} from '../store/modules/todos';
import {bindActionCreators} from 'redux';

interface Props {
    todoItems: TodoItemDataParams[];
    input: string;
    TodosActions: typeof todosActions;
}

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

    render() {
        const { input, todoItems } = this.props;
        const { onCreate, onChange, onRemove, onToggle } = this;
        return (
            <TodoList
                input={input}
                todoItems={todoItems}
                onChange={onChange}
                onCreate={onCreate}
                onToggle={onToggle}
                onRemove={onRemove}
            />
        );
    }
}

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

이제 App 컴포넌트에 TodoList를 TodoListContainer로 교체하면 됩니다.

src/App.tsx

import React, { Component } from 'react';
import TodoListContainer from './containers/TodoListContainer';

class App extends Component {
  render() {
    return (
      <div className="App">
        <TodoListContainer />
      </div>
    );
  }
}

export default App;

정리

이렇게 TypesSciprt with React + Redux 프로젝트를 완료하였습니다. 이 주제에 대한 더 나은 설명은 벨로퍼트님의 포스팅에 자세히 나와 있으며 전 벨로퍼트님의 예제를 돌아가게끔 다시 작성하였습니다.

저는 Redux에 따로 액션 생성자 유틸 라이브러리를 사용하지 않았으나, 벨로퍼트님의 포스트에서는 redux-actions라는 라이브러리를 사용하셨습니다. 하지만 이 redux-actions 라이브러리는 TypeScript 로 진행 시 오류가 발생하여 따로 액션 생성자 유틸 라이브러리를 사용하지 않고 진행하였습니다.

물론 TypeScript 전용 액션 생성자 유틸 라이브러리인 typesafe-actions 가 있습니다.

다음 블로그 포스팅은 이 라이브러리를 사용하여 Test 까지 진행한 프로젝트를 수행 후 그 경험을 공유하는 글을 작성하도록 하겠습니다.

마지막으로 제가 TypeScript를 사용한 Redux 사용 경험이 적어서 혹시나 저의 redux 구조가 이상하거나 더 나은 방식이 있으시면 댓글로 피드백을 주시면 적극적으로 반영하겠습니다.

긴 글 읽어주셔서 감사합니다.💕