타입스크립트와 리덕스연동

타입스크립트를 어느정도 학습은했지만 직접적으로 프로젝트에 적용해본적은 없어서 손에 익지않았다. 일반 자바스크립트에서 사용할떄와 리액트에서 좀 다른느낌이며 리덕스와 쓸때는 또 다르더라 타입스크립트에 익숙해지기위한 정리

프로젝트 설치

npx create-react-app ts-react-redux-tutorial --typescript
cd ts-react-redux-tutorial
npm install redux react-redux @types/react @types/react-dom @types/react-redux

redux자체는 자체적으로 타입스크립트 지원이 되지만 react에서는 지원이 되지 않기때문에 @types가 붙은 패키지들을 설치한다. @typessms ts 미지원 라이브러리에 지원을 받을 수 있게 해주는 써드파티 라이브러리이다. 라이브러리에서 공식 typescript가 지원되는지 안되는지 확인을 하려면 github레파지토리에서 index.d.ts파일이 있는지 확인하는것도 하나의 방법

카운터 리덕스 모듈

src/modules/counter.ts

const INCREASE = 'counter/INCREASE' as const
const DECREASE = 'counter/DECREASE' as const
const INCREASE_BY = 'counter/INCREASE_BY' as const

액션 type들을 선언하는데 문자열 뒤에 as const라는 키워드를 붙인다. as const란 const aseertions라는 타입스크립트 문법인데 액션생성함수를 통해 액션 객체를 만들 때 type의 타입스크립트 타입이 string이 되지 않고 실제 값을 가르킨다.

export const increase = () => ({ type: INCREASE })
export const decrease = () => ({ type: DECREASE })
export const increaseBy = (diff: number) => ({
  type: INCREASE_BY,
  payload: diff,
})

increaseBy같은 경우엔 diff 값을 받아서 payload값으로 설정한다. 값의 이름을 payload로 한 이유는 이는 FSA규칙을 따르기 위함. 나중에 액션에 관련된 라이브러리를 사용 할 수도 있게 해준다.

type CounterAction =
  | ReturnType<typeof increase>
  | ReturnType<typeof decrease>
  | ReturnType<typeof increaseBy>

리듀서를 작성할 때 action 파라미터의 타입을 설정하기 위해 액션들의 타입을 준비한다. ReturnType은 함수에서 반환하는 타입을 가져올 수 있게 해주는 유틸 타입이다. 이전에 as const로 액션을 정의했는데 이 작업을 안했다면 ReturnType을 사용할 때 type의 타입이 무조건 string으로 처리된다.

interface CounterState {
  count: number
}

const initialState: CounterState = {
  count: 0,
}

카운터 모듈에서 관리할 상태의 타입과 상태의 초깃값

function counter(state: CounterState = initialState, action: CounterAction) {
  switch (action.type) {
    case INCREASE:
      return { count: state.count + 1 }
    case DECREASE:
      return { count: state.count - 1 }
    case INCREASE_BY:
      return { count: state.count + action.payload }
    default:
      return state
  }
}

export default counter

리듀서를 작성하는 것은 useReducer의 사용법과 같다. 함수의 반환 타입에 상태의 타입을 넣는것을 잊으면 안됨

프로젝트에 적용

src/modules/index.ts

import { combineReducers } from 'redux'
import counter from './counter'

const rootReducer = combineReducers({
  counter,
})

export default rootReducer

export type RootState = ReturnType<typeof rootReducer>

루트리듀서도 일반 자바스크립트와 동일하지만 RootState라는 타입을 만들어서 내보내주어야 한다. 이 타입은 컨테이너 컴포넌트를 만들게 될 때 스토어에서 관리하고 있는 상태를 조회하기 위해 useSelector를 사용 할 때 필요하다.

index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorker from './serviceWorker'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import rootReducer from './modules'

const store = createStore(rootReducer)

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

카운터 프레젠테이셔널 컴포넌트 만들기

src/components/Counter.tsx

import React from 'react'

interface CounterProps {
  counter: number
  onIncrease: () => void
  onDecrease: () => voide
  onIncreaseBy: (diff: number) => void
}

function Counter({ count, onIncrease, onDecrease, onIncreaseBy }) {
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
      <button onClick={() => onIncreaseBy(5)}>+5</button>
    </div>
  )
}

export default Counter

3번째 버튼이 클릭되면 5를 함수의 파라미터로 설정하여 호출한다.

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

src/containers/CounterContainer.tsx

function CounterContainer() {
  const count = useSelector((state: RootState) => state.counter.count)
  const dispatch = useDispatch()

  const onIncrease = () => {
    dispatch(increase())
  }

  const onDecrease = () => {
    dispatch(Decrease())
  }

  const onIncreaseBy = (diff: number) => {
    dispatch(increaseBy(diff))
  }
}

useSelector부분에서 state의 타입을 RootState로 지정한다.
app.tsx에서 카운터컨테이너 컴포넌트를 불러와서 렌더링하면 잘 작동한다.

커스텀 훅을 만들어서 프레젠테이셔널/컨테이너를 분리하지 않기

src/hooks/useCounter.tsx

import { useSelector, useDispatch } from 'react-redux'
import { RootState } from '../modules'
import { increase, decrease, increaseBy } from '../modules/counter'
import { useCallback } from 'react'

export default function useCounter() {
  const count = useSelector((state: RootState) => state.counter.count)
  const dispatch = useDispatch()

  const onIncrease = useCallback(() => dispatch(increase()), [dispatch])
  const onDecrease = useCallback(() => dispatch(decrease()), [dispatch])
  const onIncreaseBy = useCallback(
    (diff: number) => dispatch(increaseBy(diff)),
    [dispatch]
  )

  return {
    count,
    onIncrease,
    onDecrease,
    onIncreaseBy,
  }
}

컴포넌트가 아닌 훅 형태로 구현을 했다. 이 useCounter 훅을 사용한다.

src/components/Counter.tsx

import React from 'react'
import useCounter from '../hooks/useCounter'

function Counter() {
  const { count, onIncrease, onDecrease, onIncreaseBy } = useCounter()
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
      <button onClick={() => onIncreaseBy(5)}>+5</button>
    </div>
  )
}

export default Counter

함수의 값을 props로 받아오는것이 아니라, useCounter를 통해 받아온다. 그 후 App에서 CounterContainer대신 Counter를 렌더링한다. 이전과 똑같이 작동하는것을 확인할 수 있다.

프로젝트에 적용

src/modules/index.ts

import { combineReducers } from 'redux'
import counter from './counter'

const rootReducer = combineReducers({
  counter,
})

export default rootReducer

export type RootState = ReturnType<typeof rootReducer>

루트리듀서도 일반 자바스크립트와 동일하지만 RootState라는 타입을 만들어서 내보내주어야 한다. 이 타입은 컨테이너 컴포넌트를 만들게 될 때 스토어에서 관리하고 있는 상태를 조회하기 위해 useSelector를 사용 할 때 필요하다.

index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorker from './serviceWorker'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import rootReducer from './modules'

const store = createStore(rootReducer)

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

카운터 프레젠테이셔널 컴포넌트 만들기

src/components/Counter.tsx

import React from 'react'

interface CounterProps {
  counter: number
  onIncrease: () => void
  onDecrease: () => voide
  onIncreaseBy: (diff: number) => void
}

function Counter({ count, onIncrease, onDecrease, onIncreaseBy }) {
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
      <button onClick={() => onIncreaseBy(5)}>+5</button>
    </div>
  )
}

export default Counter

3번째 버튼이 클릭되면 5를 함수의 파라미터로 설정하여 호출한다.

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

src/containers/CounterContainer.tsx

function CounterContainer() {
  const count = useSelector((state: RootState) => state.counter.count)
  const dispatch = useDispatch()

  const onIncrease = () => {
    dispatch(increase())
  }

  const onDecrease = () => {
    dispatch(Decrease())
  }

  const onIncreaseBy = (diff: number) => {
    dispatch(increaseBy(diff))
  }
}
;``

프로젝트에 적용

src/modules/index.ts

import { combineReducers } from 'redux'
import counter from './counter'

const rootReducer = combineReducers({
  counter,
})

export default rootReducer

export type RootState = ReturnType<typeof rootReducer>

루트리듀서도 일반 자바스크립트와 동일하지만 RootState라는 타입을 만들어서 내보내주어야 한다. 이 타입은 컨테이너 컴포넌트를 만들게 될 때 스토어에서 관리하고 있는 상태를 조회하기 위해 useSelector를 사용 할 때 필요하다.

index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorker from './serviceWorker'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import rootReducer from './modules'

const store = createStore(rootReducer)

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

카운터 프레젠테이셔널 컴포넌트 만들기

src/components/Counter.tsx

import React from 'react'

interface CounterProps {
  counter: number
  onIncrease: () => void
  onDecrease: () => voide
  onIncreaseBy: (diff: number) => void
}

function Counter({ count, onIncrease, onDecrease, onIncreaseBy }) {
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
      <button onClick={() => onIncreaseBy(5)}>+5</button>
    </div>
  )
}

export default Counter

3번째 버튼이 클릭되면 5를 함수의 파라미터로 설정하여 호출한다.

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

src/containers/CounterContainer.tsx

function CounterContainer() {
  const count = useSelector((state: RootState) => state.counter.count)
  const dispatch = useDispatch()

  const onIncrease = () => {
    dispatch(increase())
  }

  const onDecrease = () => {
    dispatch(Decrease())
  }

  const onIncreaseBy = (diff: number) => {
    dispatch(increaseBy(diff))
  }
}

useSelector부분에서 state의 타입을 RootState로 지정한다.
app.tsx에서 카운터컨테이너 컴포넌트를 불러와서 렌더링하면 잘 작동한다.

커스텀 훅을 만들어서 프레젠테이셔널/컨테이너를 분리하지 않기

src/hooks/useCounter.tsx

import { useSelector, useDispatch } from 'react-redux'
import { RootState } from '../modules'
import { increase, decrease, increaseBy } from '../modules/counter'
import { useCallback } from 'react'

export default function useCounter() {
  const count = useSelector((state: RootState) => state.counter.count)
  const dispatch = useDispatch()

  const onIncrease = useCallback(() => dispatch(increase()), [dispatch])
  const onDecrease = useCallback(() => dispatch(decrease()), [dispatch])
  const onIncreaseBy = useCallback(
    (diff: number) => dispatch(increaseBy(diff)),
    [dispatch]
  )

  return {
    count,
    onIncrease,
    onDecrease,
    onIncreaseBy,
  }
}

컴포넌트가 아닌 훅 형태로 구현을 했다. 이 useCounter 훅을 사용한다.

src/components/Counter.tsx

import React from 'react'
import useCounter from '../hooks/useCounter'

function Counter() {
  const { count, onIncrease, onDecrease, onIncreaseBy } = useCounter()
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
      <button onClick={() => onIncreaseBy(5)}>+5</button>
    </div>
  )
}

export default Counter

함수의 값을 props로 받아오는것이 아니라, useCounter를 통해 받아온다. 그 후 App에서 CounterContainer대신 Counter를 렌더링한다. 이전과 똑같이 작동하는것을 확인할 수 있다.

투두리스트 리덕스 만들기

카운터보다 조금 더 복잡한 투두리스트 를 만들자.

액션타입 / 생성함수 / 타입스크립트의 타입 선언

카운터를 구현할떄와 코드 형식이 동일하다.
src/modules/todos.ts

const ADD_TODO = 'todos/ADD_TODO' as const
const TOGGLE_TODO = 'todos/TOGGLE_TODO' as const
const REMOVE_TODO = 'todos/REMOVE_TODO' as const

export const addTodo = (text: string) => ({
  type: ADD_TODO,
  payload: text,
})

export const toggleTodo = (id: number) => ({
  type: TOGGLE_TODO,
  payload: id,
})

export const removeTodo = (id: number) => ({
  type: REMOVE_TODO,
  payload: id,
})

// 액션들의 타입스크립트 타입 준비
type TodosAction =
  | ReturnType<typeof addTodo>
  | ReturnType<typeof toggleTodo>
  | ReturnType<typeof removeTodo>

상태를 위한 타입 및 초기 상태 선언

그리고 타입 초기 상태를 선언해준다. 리덕스 모듈의 상태는 배열의 타입으로 준비.

export type Todo = {
  id: number
  text: string
  done: boolean
}
type TodosState = Todo[]

const initialState: TodoState = [
  { id: 1, text: '타입스크립트 배우자', done: true },
  { id: 2, text: '타입스크립트와 리덕스 같이쓰자', done: true },
  { id: 3, text: '투두리스트 만들자', done: true },
]

Todo의 타입은 나중에 컴포넌트에서 사용할 것이기에 내보내주었다.

리듀서 구현하기

function todos(
  state: TodosState = initialState,
  action: TodosAction
): TodosState {
  switch (action.type) {
    case ADD_TODO:
      const nextId = Math.max(...state.map(todo => todo.id)) + 1
      return state.concat({
        id: nextId,
        text: action.payload,
        done: false,
      })
    case TOGGLE_TODO:
      return state.map(todo =>
        todo.id === action.payload ? { ...todo, done: !todo.done } : todo
      )
    case REMOVE_TODO:
      return state.filter(todo => todo.id !== action.payload)
    default:
      return state
  }
}

export default todos

새 항목을 만들때마다 고유 ID를 설정하기 위해 현재 상태의 모든 항목들의 아이디를 체크하고 가장큰값으로 사용하도록 처리하였다. 루트리듀서에 추가해줄 수 있도록 내보내준다.

루트리듀서에 등록

src/modules/index.ts

import todos from './todos'

const rootReducer = combineReducers({
  counter,
  todos,
})

export default rootReducer

투두리스트 컴포넌트 준비

hooks로 분리해서 구현하는 구조로 구현해보자 만들 컴포넌트는 총 3가지이다.

  • TodoInsert: 새 항목을 등록하는 컴포넌트
  • Todoitem: 할 일 정보를 보여주는 컴포넌트
  • TodoList: 여러개의 TodoItem 컴포넌트를 보여주는 컴포넌트

src/components/TodoInsert.tsx TodoInsert로 항목을 등록할 수 있는 컴포넌트. 인풋의 상태는 useState로 로컬 상태로 관리를 한다.

import React, { ChangeEvent, FormEvent, useState } from 'react'

function TodoInsert() {
  const [value, setValue] = useState('')
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value)
  }
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setValue('')
  }

  return (
    <form onSubmit={onSubmit}>
      <input
        placeholder="할 일을 입력하세용"
        value={value}
        onChange={onChange}
      />
      <button type="submit">등록</button>
    </form>
  )
}

src/components/TodoItem.tsx
각 할 일 항목에 대한 정보를 보여주는 컴포넌트

import React from 'react'
import { Todo } from '../modules/todos'

type TodoItemProps = {
  todo: Todo
}

function TodoItem({ todo }: TodoItemProps) {
  return (
    <li>
      <span> {todo.text} </span>
      <span>(x)</span>
    </li>
  )
}

export default TodoItem

간단한 css적용 예도 있지만 생략,,,
src/components/TodoList.tsx
리덕스 스토어의 상태 todos배열을 조회할 컴포넌트

import React from 'react'
import { Todo } from '../modules/todos'
import TodoItem from './TodoItem'

function TodoList() {
  const todos: Todo[] = []

  if (todos.length === 0) return <p>등록된 항목이 없어용</p>

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem todo={todo} key={todo.id} />
      ))}
    </ul>
  )
}

export default TodoList

App 에서 만든 컴포넌트 렌더링하기

src/App.tsx

import React from 'react'
import Counter from './component/counter'
import TodoInsert from './component/TodoInsert'
import TodoList from './component/TodoList'

function App() {
  return (
    <>
      <TodoInsert />
      <TodoList />
    </>
  )
}

export default App

커스텀 hook을 작성해 기능 구현

src/hooks/useTodos.ts

import { useSelector } from 'react-redux'
import { RootState } from '../modules'

export default function useTodos() {
  const todos = useSelector((state: RootState) => state.todos)
  return todos
}

그냥 useSelector를 사용해서 상태를 조회하는 코드이다. 이 코드를 TodoList컴포넌트에서 사용한다.
src/components/TodoList.tsx

import React from 'react'
import TodoItem from './TodoItem'
import useTodos from '../hooks/useTodos'

function TodoList() {
  const todos = useTodos()

  if (todos.length === 0) return <p>등록된 항목이 없어용</p>

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem todo={todo} key={todo.id} />
      ))}
    </ul>
  )
}

export default TodoList

이제 브라우저에 초기 할 일 항목들이 보여진다.

useAddTodo

새로운 할 일을 등록하는 함수를 사용할 수 있게 해주는 함수.
src/hooks/useAddTodo.tsx

import { useDispatch } from 'react-redux'
import { useCallback } from 'react'
import { addTodo } from '../modules/todos'

export default function useAddTodo() {
  const dispatch = useDispatch()
  return useCallback(text => dispatch(addTodo(text)), [dispatch])
}

TodoInsert 에서 사용
src/components/TodoInsert.tsx

import React, { ChangeEvent, FormEvent, useState } from 'react'
import useAddTodo from '../hooks/useAddTodo'

function TodoInsert() {
  const [value, setValue] = useState('')
  const addTodo = useAddTodo()

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value)
  }
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    addTodo(value)
    setValue('')
  }

  return (
    <form onSubmit={onSubmit}>
      <input
        placeholder="할 일을 입력하세용"
        value={value}
        onChange={onChange}
      />
      <button type="submit">등록</button>
    </form>
  )
}

export default TodoInsert

이제 새로운 항목을 등록하는 기능도 정상작동한다.

useTodoActions

이제 할 일의 상태를 토글하는 함수와 할 일을 제거하는 함수를 제공하는 useTodoActions라는 hook을 만든다. 함수의 파라미터로 id값을 받는다.
src/hooks/useTodoActions.ts

import { useDispatch } from 'react-redux'
import { useCallback } from 'react'
import { toggleTodo, removeTodo } from '../modules/todos'

export default function useTodoActions(id: number) {
  const dispatch = useDispatch()

  const onToggle = useCallback(() => dispatch(toggleTodo(id)), [dispatch, id])
  const onRemove = useCallback(() => dispatch(removeTodo(id)), [dispatch, id])

  return { onToggle, onRemove }
}

이제 훅을 TodoItem에서 간편하게 사용할 일만 남았다.
src/components/TodoItem.tsx

import React from 'react'
import { Todo } from '../modules/todos'
import useTodoActions from '../hooks/useTodoActions'

type TodoItemProps = {
  todo: Todo
}

function TodoItem({ todo }: TodoItemProps) {
  const { onToggle, onRemove } = useTodoActions(todo.id)

  return (
    <li>
      <span onClick={onToggle}> {todo.text} </span>
      <span onClick={onRemove}>(x)</span>
    </li>
  )
}

export default TodoItem

다음 시간에는 typesafe-actions로 모듈 리팩토링을 진행하겠습니다.


Written by@jaeyoung-son
배운것을 기록하는 공간입니다.

GitHub