April 18, 2020
타입스크립트를 어느정도 학습은했지만 직접적으로 프로젝트에 적용해본적은 없어서 손에 익지않았다. 일반 자바스크립트에서 사용할떄와 리액트에서 좀 다른느낌이며 리덕스와 쓸때는 또 다르더라 타입스크립트에 익숙해지기위한 정리
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를 사용 할 때 필요하다.
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 훅을 사용한다.
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를 사용 할 때 필요하다.
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를 사용 할 때 필요하다.
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 훅을 사용한다.
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가지이다.
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
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
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
이제 브라우저에 초기 할 일 항목들이 보여진다.
새로운 할 일을 등록하는 함수를 사용할 수 있게 해주는 함수.
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라는 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로 모듈 리팩토링을 진행하겠습니다.