이번에 할 공부는 TypeScript 환경에서 Redux를 프로처럼 사용하는 방법을 다뤄보도록 하겠습니다. 이번 포스팅에서는 먼저 기본적인 리덕스 코드 작성 방법을 알아보고, 나중에 더욱 프로처럼 사용해보는것도 배워보겠습니다.
이번 포스팅에서는 저번 Context API와 같이 간단한 리덕스 예시인 카운터와 투두리스트를 만들어보게 됩니다.
먼저, 리덕스를 적용한 프로젝트를 만들어 봅시다.
$ npx create-react-app ts-react-redux-tutorial --template typescript
$ cd ts-react-redux-tutorial
$ yarn add redux react-redux @types/react-redux
redux의 경우엔 자체적으로 TypeScript 지원이 되지만, react-redux의 경우 그렇지 않기 때문에 패키지명 앞에 @types 를 붙인 패키지를 설치해 주어야 합니다.
@types는 TypeScript 미지원 라이브러리에 TypeScript 지원을 받을 수 있게 해주는 써드파티 라이브러리입니다. 이에 관련된 소스코드는
DefinitelyTyped 라는 GitHub 레포에서 관리되고 있다고 합니다.
라이브러리에서 공식 TypeScript 지원이 되는지 안되는지 확인하려면 직접 설치 후 불러와서 확인해봐도 되고, Github 레포를 열어서 index.d.ts 라는 파일이 존재하는지 확인하면 됩니다.
가장 간단한 예시인 카운터를 구현하기 위한 리덕스 모듈을 작성해보겠습니다. 리덕스 관련 코드를 작성 할 때 Ducks 패턴 을 사용할 것입니다. Ducks 패턴에서는 편의성을 위하여 액션의 type, 액션 생성함수, 리듀서를 모두 한 파일에 작성하는 방식입니다.
src 디렉터리 안에 modules 디렉터리를 만들고 그 안에 counter.ts 파일을 만들어서 다음 코드들을 순서대로 입력해보세요.
액션 type들을 선언해보겠습니다. 여기서의 "type"은 TypeScript의 type을 의미하는게 아니라 리덕스 액션 안에 들어가게 될 type값 입니다.
type을 선언 할 때에는 다음과 같이 문자열 뒤에 as const 라는 키워드를 붙여 주어야 합니다!
const INCREASE = 'counter/INCREASE' as const;
const DECREASE = 'counter/DECREASE' as const;
const INCREASE_BY = 'counter/INCREASE_BY' as const;
as consst 는 consert assertions라는 Typescript 문법입니다. 이 문법을 사용하면 우리가 추후 액션 생성함수를 통해 액션 객체를 만들게 됐을 때 type의 Typescript 타입이 string이 되지 않고 실제 값을 가리키게 됩니다.
그 다음에는 액션 생성 함수들을 선언해보겠습니다. 액션 생성 함수를 작성 할 때에는 function 키워드를 사용해도 되고, 화살표 함수 문법을 사용해도 됩니다. 화살표 함수 문법을 사용하면 return을 생략 할 수 있어서 깔끔하기 때문에, 여기에서는 화살표 함수를 사용하여 선언하도록 하겠습니다.
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
export const increaseBy = (diff: number) => ({
type: INCREASE_BY,
payload: diff
});
여기서 increase와 decrease의 경우에는 함수에서 따로 파라미터를 받아오지 않습니다. increaseBy 의 경우엔 diff 라는 값을 파라미터로 받아와서 액션의 payload 값으로 설정해줍니다. 이 과정에서 값의 이름을 paylaod로 바꿔주었는데 이는 FSA 규칙 을 따르기 위해서입니다. 이 규칙을 따름으로서 액션 객체의 구조를 일관성 있게 가져갈 수 있어서 추후 리듀서에서 액션을 다룰 때에도 편하고, 읽기 쉽고, 액션에 관련된 라이브러리를 사용 할 수도 있게 해줍니다. 다만, 꼭 따라야 할 필요는 없으니 만약 fsa가 불편하다면 굳이 이렇게 payload라는 이름으로 넣을 필요는 없습니다.
여기서의 "type"은 Typescript의 타입을 의미합니다. 나중에 리듀서를 작성 할 때 action 파라미터의 타입을 설정하기 위해서 우리가 만든 모든 액션들의 Typescript 타입을 준비해주어야 합니다. 이는 다음과 같이 선언할 수 있습니다.
type CounterAction =
| ReturnType<typeof increase>
| Returntype<typeof decrease>
| ReturnType<typeof increaseBy>;
여기서 사용 된 ReturnType 은 함수에서 반환하는 타입을 가져올 수 있게 해주는 유틸타입입니다.
우리가 이전에 액션의 type 값들을 선언 할 때 as const 라는 키워드를 사용했었습니다. 만약 이 작업을 처리하지 않으면 ReturnType 을 사용하게 됐을 때 type 의 타입이 무조건 string 으로 처리되어 버립니다.
그렇게 되면 나중에 리듀서를 구현할 수가 없다고 합니다.
이번에는 counter 모듈에서 관리할 상태의 타입과 초깃값을 선언하겠습니다.
type = CounterState = {
count: Number;
}
const initialState: CounterState = {
count: 0
};
매우 간단합니다. 리덕스 상태의 타입을 선언 할 때에는 type을 계속 써도 되고, interface를 써도 됩니다. 앞으로 둘 중에 하나만 선택 해서 일관성 있게 계속 하나만 사용하는 것을 권장한다고 합니다.
마지막으로, 리듀서를 작성하고 내보내주겠습니다. 리듀서를 작성 하는 것은, 우리가 이전에 useReducer 의 사용법을 배웠을 때랑 똑같습니다. 함수의 반환 타입에 상태의 타입을 넣는 것을 잊지 마세요. 이를 통해 사소한 실수를 방지할 수 있습니다.
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;
}
}
리듀서를 작성하는 과정에서 case 부분에서 액션의 type 값에 유효하지 않은 값을 넣게 되면 오류가 나타나게 됩니다.
추가적으로, case 부분에 따라 액션 안에 어떤 ㄱ밧이 들어 있는지도 아주 잘 알 수 있죠.
리듀서를 다 작성했다면 추후 루트 리듀서를 만들 때 불러올 수 있도록 내보내주세요.
전체 코드는 다음과 같습니다.
const INCREASE = 'counter/INCREASE' as const;
const DECREASE = 'counter/DECREASE' as const;
const INCREASE_BY = 'counter/INCREASE_BY' as const;
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
export const increaseBy = (diff: number) => ({
type: INCREASE_BY,
payload: diff
});
type CounterAction =
| ReturnType<typeof increase>
| ReturnType<typeof decrease>
| ReturnType<typeof increaseBy>;
type 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;
모듈 작성이 이제 모두 끝났습니다!
이제 프로젝트에 리덕스를 적용해보겠습니다. 지금은 리듀서가 하나 뿐이지만, 추후 우리가 달느 리듀서를 더 만들 것이므로 루트 리듀서를 만들어주도록 하겠습니다. modules 디렉터리에 index.ts 파일을 만들어서 다음과 같이 코드를 작성해주세요.
import { combineReducers } from 'redux';
import counter from './counter';
const rootReducer = combineReducers({
counter
});
export default rootReducer;
export type RootState = ReturnType<typeof rootReducer>;
루트 리듀서를 만들 때에는 일반 Javascript 환경에서 할 때랑 방법이 동일한데, 주의해야 하는 부분은 RootState 라는 타입을 만들어서 내보내주어야 한다는 것입니다. 이 타입은 추후 우리가 컨테이너 컴포넌트를 만들게 될 때 스토어에서 관리하고 있는 상태를 최회하기 위해서 useSelector 를 사용 할 때 필요로 합니다.
이제 루트 리듀서를 만들었으니, idnex.tsx 파일에서 스토어를 생성하고 Provider 컴포넌트를 사용하여 리액트 프로젝트에 리덕스를 적용해보세요.
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import rootReducer from './modules';
const store = createStore(rootReducer);
ReactDOM.render(
<Provider store={store}>
<React.StrictMode>
<App />
</React.StrictMode>
</Provider>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
이제 프리젠테이셔널 컴포넌트를 만들어 보겠습니다. 이번에는 프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 구분해서 만들어보도록 하겠습니다.
하지만, 참고로 프리젠테이셔널/컨테이너 구조는 필수적인게 아니라고 아니 쓰지 않아도 무방합니다.
src 디렉터리에 components 디렉터리를 만들고 그 안에 Counter.tsx 를 다음과 같이 작성해보세요.
import React from 'react';
type CounterProps = {
count: number;
onIncrease: () => void;
onDecrease: () => void;
onIncreaseBy: (diff: number) => void;
};
function Counter({
count,
onIncrease,
onDecrease,
onIncreaseBy
}: CounterProps) {
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로 받아오도록 처리하였습니다. 위 컴포넌트는 3개의 버튼을 보여주는데 3번째 버튼의 경우 클릭이 되면 5를 onIncreaseBy 함수의 파라미터로 설정하여 호출합니다.
그 다음에는 리덕스 스토어 안에 있는 상태를 조회하여 사용하고, 액션도 디스패치하는 컨테이너 컴포넌트를 만들어봅시다.
src 디렉터리에 containers 디렉터리를 만들고, 그 안에 CounterContainer.tsx 파일을 생성하여 다음 코드를 작성해주세요.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../modules';
import { increase, decrease, increaseBy } from '../modules/counter';
import Counter from '../components/Counter';
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));
};
return (
<Counter
count={count}
onIncrease={onIncrease}
onDecrease={onDecrease}
onIncreaseBy={onIncreaseBy}
/>
);
}
export default CounterContainer;
TypeScript 로 컨테이너 컴포넌트를 작성 할 때 특별한 점은 useSelector 부분에서 state 의 타입을 RootState로 지정해서 사용한다는 것 외에는 없습니다.
이제. 이 CounterContainer 컴포넌트를 App 컴포넌트에서 렌더링 해보세요.
import React from 'react';
import CounterContainer from './containers/CounterContainer';
function App() {
return (
<CounterContainer />
);
}
export default App;
카운터 컴포넌트가 잘 나타났나요? 버튼들을 눌러서 잘 작동하는지 확인해보세요.
이번에는 할 일 목록(투두리스트)를 구현해 봅시다. 먼저 리덕스 모듈부터 준비해보겠습니다. 아까 작성했던 카운터 모듈이랑 별반 다를 바가 없습니다. 그냥 배열을 다룰 뿐이죠.
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;
let nextId = 1; // 새로운 항목을 추가 할 때 사용할 고유 ID 값
// 액션 생성 함수
export const addTodo = (text: string) => ({
type: ADD_TODO,
payload: {
id: nextId++,
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;
};
// 이 모듈에서 관리할 상태는 Todo 객체로 이루어진 배열
export type TodosState = Todo[];
// 초기 상태 선언
const initialState: TodosState = [];
// 리듀서 작성
function todos(
state: TodosState = initialState,
action: TodosAction
): TodosState {
switch (action.type) {
case ADD_TODO:
return state.concat({
// action.payload 객체 안의 값이 모두 유추됩니다.
id: action.payload.id,
text: action.payload.text,
done: false
});
case TOGGLE_TODO:
return state.map(todo =>
// payload가 number 인 것이 유추됩니다.
todo.id === action.payload ? { ...todo, done: !todo.done } : todo
);
case REMOVE_TODO:
// payload 가 number 인 것이 유추됩니다.
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
}
export default todos;
모듈을 다 만들었다면 루트 리듀서에 todos 리듀서를 등록해주세요.
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';
const rootReducer = combineReducers({
counter,
todos
});
// 루트 리듀서를 내보내주세요.
export default rootReducer;
// 루트 리듀서의 반환값를 유추해줍니다
// 추후 이 타입을 컨테이너 컴포넌트에서 불러와서 사용해야 하므로 내보내줍니다.
export type RootState = ReturnType<typeof rootReducer>;
이제 투두리스트를 구현하기 위한 프리젠테이셔널 컴포넌트들을 준비하겠습니다. 우리가 앞으로 만들 컴포넌트는 총 3개입니다.
그럼, components 디렉터리에 하나씩 만들어봅시다.
새 항목을 등록 할 수 있는 TodoInsert 컴포넌트를 만들어봅시다. 이 컴포넌트에서는 onInsert 라는 props 를 받아와서 이 함수를 호출하여 새 항목을 추가하며, input 의 상태는 컴포넌트 내부에서 로컬 상태로 관리합니다.
import React, { ChangeEvent, FormEvent, useState } from 'react';
type TodoInsertProps = {
onInsert: (text: string) => void;
};
function TodoInsert({ onInsert }: TodoInsertProps) {
const [value, setValue] = useState('');
const Change = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const onSubmit = (e: FormEvent) => {
e.preventDefault();
onInsert(value);
setValue('');
};
return (
<form onSubmit={onSubmit}>
<input
placeholder="할 일을 입력하세요."
value={value}
onChange={Change}
/>
<button type="submit">등록</button>
</form>
);
}
export default TodoInsert;
TodoItem 컴포넌트는 각 할 일 항목에 대한 정보를 보여주는 컴포넌트이며, 텍스트 영역을 클릭하면 done 값이 바뀌고, 우측의 (X) 를 클릭하면 항목이 삭제됩니다. 이 컴포넌트에서는 할 일 정보를 지니고 있는 todo, 그리고 상태 토글 및 삭제를 해주는 함수 onToggle 과 onRemove 를 props 로 받아옵니다.
import React, { CSSProperties } from 'react';
import { Todo } from '../modules/todos';
type TodoItemProps = {
todo: Todo;
onToggle: (id: number) => void;
onRemove: (id: number) => void;
};
function TodoItem({ todo, onToggle, onRemove }: TodoItemProps) {
// CSSProperties 는 style 객체의 타입입니다.
const textStyle: CSSProperties = {
textDecoration: todo.done ? 'line-through' : 'none'
};
const removeStyle: CSSProperties = {
marginLeft: 8,
color: 'red'
};
const handleToggle = () => {
onToggle(todo.id);
};
const handleRemove = () => {
onRemove(todo.id);
};
return (
<li>
<span onClick={handleToggle} style={textStyle}>
{todo.text}
</span>
<span onClick={handleRemove} style={removeStyle}>
(X)
</span>
</li>
);
}
export default TodoItem;
이 컴포넌트는 여러개의 TodoItem 컴포넌트를 렌더링해줍니다. 할 일 정보들을 지니고 있는 배열인 todos와 각 TodoItem 컴포넌트들에게 전달해줘야 할 onToggle 과 onRemove 를 props 로 받아옵니다.
import React from 'react';
import { Todo } from '../modules/todos';
import TodoItem from './TodoItem';
type TodoListProps = {
todos: Todo[];
onToggle: (id: number) => void;
onRemove: (id: number) => void;
};
function TodoList({ todos, onToggle, onRemove }: TodoListProps) {
if (todos.length === 0) return <p>등록된 항목이 없습니다.</p>;
return (
<ul>
{todos.map(todo => (
<TodoItem
todo={todo}
onToggle={onToggle}
onRemove={onRemove}
key={todo.id}
/>
))}
</ul>
);
}
export default TodoList;
이제 프리젠테이셔널 컴포넌트들의 준비는 끝났습니다.
이번에는 투두리스트를 위한 컴포넌트를 작성할 차례입니다. 컨테이너 컴포넌트의 이름은 TodoApp 으로 하겠습니다. containers 디렉터리에 TodoApp.tsx 를 다음과 같이 작성하세요.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../modules';
import { toggleTodo, removeTodo, addTodo } from '../modules/todos';
import TodoInsert from '../components/TodoInsert';
import TodoList from '../components/TodoList';
function TodoApp() {
const todos = useSelector((state: RootState) => state.todos);
const dispatch = useDispatch();
const onInsert = (text: string) => {
dispatch(addTodo(text));
};
const onToggle = (id: number) => {
dispatch(toggleTodo(id));
};
const onRemove = (id: number) => {
dispatch(removeTodo(id));
};
return (
<>
<TodoInsert onInsert={onInsert} />
<TodoList todos={todos} onToggle={onToggle} onRemove={onRemove} />
</>
);
}
export default TodoApp;
정말 간단하죠? 이제 이 컴포넌트를 App에서 렌더링해보세요.
import React from 'react';
import TodoApp from './containers/TodoApp';
function App() {
return (
<TodoApp />
);
}
export default App;
(삭제 후)
잘 작동하나요?
typesafe-actions 는 리덕스를 사용하는 프로젝트에서 액션 생성 함수와 리듀서를 훨씬 쉽고 깔끔하게 작성할 수 있게 해주는 라이브러리입니다.
이 라이브러리를 프로젝트에 설치해주세요.
$ yarn add typesafe-actions
그 다음에 카운터 리덕스 모듈을 typesafe-actions 를 사용하여 리팩토링하겠습니다.
import { createAction, ActionType, createReducer } from 'typesafe-actions';
// 액션 타입 선언
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
const INCREASE_BY = 'counter/INCREASE_BY';
// 액션 생성함수를 선언합니다.
export const increase = createAction(INCREASE)();
export const decrease = createAction(DECREASE)();
export const increaseBy = createAction(INCREASE_BY)<number>(); // payload 타입을 Generics로 설정해 주세요.
// 액션 객체 타입 준비
const actions = { increase, decrease, increaseBy }; // 모든 액션 생섬함수들을 actions 객체에 넣습니다.
type CounterAction = ActionType<typeof actions>; // ActionType 를 사용하여 모든 액션 객체들의 타입을 준비해줄 수 있습니다
// 이 리덕스 모듈에서 관리 할 상태의 타입을 선언합니다.
type CounterState = {
count: number;
};
// 초기 상태를 선언합니다.
const initialState: CounterState = {
count: 0
};
// 리듀서를 만듭니다.
// createReducer 는 리듀서를 쉽게 만들 수 있게 해주는 함수입니다.
// Generics로 리듀서에서 관리할 상태, 그리고 리듀서에서 처리 할 모든 액션 객체들의 타입을 넣어야 합니다.
const counter = createReducer<CounterState, CounterAction>(initialState, {
[INCREASE]: state => ({ count: state.count + 1 }), // 액션을 참조 할 필요 없으면 파라미터로 state 만 받아와도 됩니다.
[DECREASE]: state => ({ count: state.count - 1 }),
[INCREASE_BY]: (state, action) => ({ count: state.count + action.payload }) // 액션의 타입을 유추 할 수 있습니다.
});
export default counter;
코드가 훨씬 깔끔해졌지요? 액션 생성 함수를 매번 직접 만들 필요 없이 createAction 을 사용하여 한 줄로 쉽게 작성할 수 있게 되었습니다.
createReducer 는 리듀서를 switch 문이 아닌 객체 형태로 작성 할 수 있게 해줍니다. 취향에 따라 다르긴 하겠지만, 이 방싱식이 switch 문을 사용하는 것 보다 코드가 훨씬 간결하다고 생각됩니다.
이제, App에서 CounterContainer를 렌더링해서 이전과 똑같이 잘 작동하는지 확인해보세요. (저는 잘 작동했습니다!)
import React from 'react';
import CounterContainer from './containers/CounterContainer';
function App() {
return (
<CounterContainer />
);
}
export default App;
createReducer 를 사용 할 때 우리는 객체 형태로 작성을 해주었는데요, 이 함수에서는 메서드 체이닝 방식 을 통해 구현하는 기능도 지원해줍니다.
한번 메서드 체이닝 방식으로 구현해봅시다.
import {
createStandardAction,
ActionType,
createReducer
} from 'typesafe-actions';
// 액션 type 선언
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
const INCREASE_BY = 'counter/INCREASE_BY';
// 액션 생성함수를 선언합니다
export const increase = createStandardAction(INCREASE)();
export const decrease = createStandardAction(DECREASE)();
export const increaseBy = createStandardAction(INCREASE_BY)<number>(); // payload 타입을 Generics 로 설정해주세요.
// 액션 객체 타입 준비
const actions = { increase, decrease, increaseBy }; // 모든 액션 생성함수들을 actions 객체에 넣습니다
type CounterAction = ActionType<typeof actions>; // ActionType 를 사용하여 모든 액션 객체들의 타입을 준비해줄 수 있습니다
// 이 리덕스 모듈에서 관리 할 상태의 타입을 선언합니다
type CounterState = {
count: number;
};
// 초기상태를 선언합니다.
const initialState: CounterState = {
count: 0
};
// 리듀서를 만듭니다
// createReducer 는 리듀서를 쉽게 만들 수 있게 해주는 함수입니다.
// Generics로 리듀서에서 관리할 상태, 그리고 리듀서에서 처리 할 모든 액션 객체들의 타입을 넣어야합니다
const counter = createReducer<CounterState, CounterAction>(initialState)
.handleAction(INCREASE, state => ({ count: state.count + 1 }))
.handleAction(DECREASE, state => ({ count: state.count - 1 }))
.handleAction(INCREASE_BY, (state, action) => ({
count: state.count + action.payload
}));
export default counter;
취향에 따라 이렇게 구현 하는 것을 선호하신다면 객체 형태 말고 메서드 체이닝 형태로 구현하시는것도 좋습니다. 아마 대부분의 경우엔 객체 형태로 구현하는게 코드가 조금 더 깔끔하다고 생각하실것입니다. 그렇긴 하지만.. 메서드 체이닝 형태로 구현하게 됐을 때 얻을 수 있는 정말 큰 장점이 있습니다. 바로 handleAction 의 첫번째 인자에 타입에 액션의 type 를 넣는것이 아니라 액션 생성함수 자체를 넣어도 작동한다는 것 입니다. 그렇게 하면, 액션의 type 을 굳이 선언 할 필요가 없어집니다. (아래의 코드가 작동되는 코드입니다.)
import { createAction, ActionType, createReducer } from 'typesafe-actions';
// 액션 타입 선언
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
const INCREASE_BY = 'counter/INCREASE_BY';
// 액션 생성함수를 선언합니다.
export const increase = createAction(INCREASE)();
export const decrease = createAction(DECREASE)();
export const increaseBy = createAction(INCREASE_BY)<number>(); // payload 타입을 Generics로 설정해 주세요.
// 액션 객체 타입 준비
const actions = { increase, decrease, increaseBy }; // 모든 액션 생섬함수들을 actions 객체에 넣습니다.
type CounterAction = ActionType<typeof actions>; // ActionType 를 사용하여 모든 액션 객체들의 타입을 준비해줄 수 있습니다
// 이 리덕스 모듈에서 관리 할 상태의 타입을 선언합니다.
type CounterState = {
count: number;
};
// 초기 상태를 선언합니다.
const initialState: CounterState = {
count: 0
};
// 리듀서를 만듭니다.
// createReducer 는 리듀서를 쉽게 만들 수 있게 해주는 함수입니다.
// Generics로 리듀서에서 관리할 상태, 그리고 리듀서에서 처리 할 모든 액션 객체들의 타입을 넣어야 합니다.
const counter = createReducer<CounterState, CounterAction>(initialState)
.handleAction(increase, state => ({ count: state.count + 1 }))
.handleAction(decrease, state => ({ count: state.count - 1 }))
.handleAction(increaseBy, (state, action) => ({
count: state.count + action.payload
}));
export default counter;
이렇게 액션의 type 대신에 생성 함수를 참조하여 리듀서를 구현 해주면 모든 액션 객체들의 타입인 CounterAction 을 준비하는것도 생략 할 수 있습니다.
코드가 많이 줄었지요? 만약 redux-saga, redux-observable 같은 미들웨어를 사용할 때에는 액션들의 type 또는 모든 액션 객체들의 타입을 사용해야 하는 일이 발생 할 수 있으므로 위와 같은 구조가 적합하지 않을 수도 있습니다.(만약에 위 구조로 작성을 하게 된다면 해당 미들웨어들을 사용하게 될 때 getType 를 활하면 되긴 합니다.)
todos 리덕스 모듈도 방금 했던 것 처럼 typesafe-actions 를 사용하여 리팩토링해봅시다. (handleActions 로 리팩토링 했습니다.)
import { createAction, ActionType, createReducer } from "typesafe-actions";
let nextId = 1; // 새로운 항목을 추가 할 때 사용 할 고유 ID 값
export const addTodo = createAction("todos/ADD_TODO", (text: string) => ({
id: nextId++,
text: text,
done: false,
}))<Todo>();
export const toggleTodo = createAction("todos/TOGGLE_TODO")<number>();
export const removeTodo = createAction("todos/REMOVE_TODO")<number>();
const actions = {
addTodo,
toggleTodo,
removeTodo,
};
type TodosAction = ActionType<typeof actions>;
// 상태에서 사용 할 할 일 항목 데이터 타입 정의
export type Todo = {
id: number;
text: string;
done: boolean;
};
// 이 모듈에서 관리할 상태는 Todo 객체로 이루어진 배열
export type TodosState = Todo[];
// 초기 상태 선언
const initialState: TodosState = [];
// 리듀서 작성
const todos = createReducer<TodosState, TodosAction>(initialState)
.handleAction(removeTodo, (state, action) =>
state.filter((todo) => todo.id !== action.payload)
)
.handleAction(toggleTodo, (state, action) =>
state.map((todo) =>
todo.id === action.payload ? { ...todo, done: !todo.done } : todo
)
)
.handleAction(addTodo, (state, action) =>
state.concat({ ...action.payload })
);
export default todos;
현재 todos 리덕스 모듈은 주석을 제외하면 53줄 정도 됩니다. 지금은 코드가 그렇게 긴 것은 아니지만, 현재 이 파일에서 액션 type, 액션 생성 함수, 액션 객체들의 타입, 상태의 타입, 리듀서를 선언하고 있기 때문에 나중에 액션의 수가 더 많아지면 코드가 너무 길어지게 될 텐데요, 그러면 개발 할 때 찾고자 하는 것을 찾기 위하여 스크롤을 많이 해야 돼서 생산성을 저하시킬 수 있습니다.
그렇다고 해서 src 디렉터리안에서 actions 디렉터리, reducers 디렉터리를 따로 분류해서 하는 것은 서로 너무 멀리 떨어져있기 때문에 오히려 더 불편해질수도 있다는 단점이 있습니다.
다음과 같은 구조를 한번 상상해보세요.
actions
A
B
C
D
E
components
...
containers
...
lib
...
styles
...
reducers
A
B
C
D
E
위와 같은 형식으로 분리시키는 것 대신에, 추천드리는 분리 방식은 todos 라는 디렉터리를 만들어서 그안에 여러 개의 파일을 작성하는 것입니다.
modules
todos
actions.ts
index.ts
reducer.ts
types.td
counter.ts # 파일이 그렇게 길지 않은 경우 그냥 파일 하나로 작성
이런 구조로 작성하면 꽤나 편하다고 합니다.
이번 공부시간에는 타입스크립트에서 리덕스를 사용하는 방법을 배워보았습니다. 앞으로 타입스크립트-리덕스를 사용할 때 이 포스팅을 많이 참고하지 않을까 싶습니다.(모르면 예제 참고해야죠 ㅎㅎ) 다음 공부에는 리덕스 미들웨어 redux-thunk와 redux-saga를 사용 할 때 타입스크립트를 어떻게 활용 할 수 있는지 배워보도록 하겠습니다!