Redux는 앱의 상태(state)만을 관리하기 때문에 React, Angular, Vue 등 어느 웹 프레임워크와도 잘 어울립니다. 이번 글에서는 지난 글에서 작성했던 Todo 앱을 React 앱으로 작성하면서 어떻게 활용될 수 있는지 알아보도록 하겠습니다.
React 앱에서 Redux를 별도 모듈 없이도 사용할 수는 있지만, 컴포넌트가 스토어와 상호작용하는 코드가 붙게 되면서 재사용성이 떨어지게 됩니다. 때문에 Redux를 사용하는 React 앱을 작성할 때는 컴포넌트를 크게 두 종류로 나누는 것이 일반적입니다.
컨테이너 컴포넌트 | 프레젠테이션 컴포넌트 | |
---|---|---|
역할 | 프레젠테이션 컴포넌트에 상태값과 콜백 등을 제공 | 실질적 컴포넌트의 역할(화면 구성) |
동작 | Redux 스토어와 상호작용(액션 dispatch, 스토어의 상태 변화를 구독) | 컨테이너 컴포넌트로부터 받을 값을 화면에 표시 |
위와 같은 컴포넌트의 역할 분리를 용이하게 해주는 것이 React-Redux 모듈에서 제공하는 connect()
함수입니다.
TypeScript로 React 개발하기에서 작성한 형태로 프로젝트를 구성하였고, 디렉터리 구조는 다음과 같습니다.
/
├ public
│ └ dist
└ src
├ action
├ component
│ ├ todoInput
│ ├ todoList
│ └ todoVisibilitySelector
├ lib
├ reducer
└ store
글에서는 코드의 주요 내용만을 다루고 있습니다. 전체 프로젝트는 Github에서 확인하실 수 있습니다.
작성할 Todo 앱의 요구사항을 다시 보겠습니다.
Redux 로직은 동일하기 때문에 대부분의 코드는 그대로 사용하였는데, 다음과 같은 부분들을 수정하였습니다.
src/reducer
// 테스트 코드 제거
import * as redux from "redux";
import { TodoActionCreator, todoVisibility } from "../action/todo";
import { TodoItem } from "../lib/todo.obj";
import { todoApp } from "../reducer/todo";
export interface IStore {
todos: TodoItem[];
visibility: todoVisibility;
}
export const store = redux.createStore(todoApp);
src/reducer/todo.ts
// ...
export function todoApp(state = initialState,
action: todoAction.ITodoActionAdd | todoAction.ITodoActionComplete |
todoAction.ITodoActionRemove | todoAction.ITodoActionSetVisibility | AnyAction) {
// 함수 시그니처에 AnyACtion 타입을 추가, 없으면 connect 호출에서 오류 발생
// import { AnyAction } from "redux";
// ...
다음과 같이 컴포넌트를 나눠서 작성하겠습니다.
먼저, 할 일을 출력하는 리스트 컴포넌트를 작성합니다.
src/component/todoList/todoListItem.component.tsx
import * as React from "react";
import { TodoItem, TodoState } from "../../lib/todo.obj";
export interface ITodoItemProps {
onClick: (id: number) => void;
todo: TodoItem;
}
export class Todo extends React.Component<ITodoItemProps> {
public render() {
return (<li style={% raw %}{{
textDecoration: this.props.todo.getState() === TodoState.COMPLETED ?
"line-through" : "none",
}}{% endraw %} onClick={() => this.props.onClick((this.props.todo.getId()))}>
{this.props.todo.getText()}
</li>);
}
}
컴포넌트의 비즈니스 로직(아이템 클릭에 대한 반응)은 부모 컴포넌트로부터 받아서 사용하고, prop으로 받은 TodoItem
을 출력하는 역할만 담당합니다.
그리고 이를 사용하는 TodoList
컴포넌트를 작성합니다.
src/component/todoList.container.tsx
import * as React from "react";
import { connect } from "react-redux";
import * as Redux from "redux";
import { TodoActionCreator, todoVisibility } from "../../action/todo";
import { TodoItem, TodoState } from "../../lib/todo.obj";
import { IStore } from "../../store";
import { Todo } from "./todoListItem.component";
export interface ITodoListProps {
todos: TodoItem[];
onTodoItemClick: (id: number) => void;
}
class TodoList extends React.Component<ITodoListProps> {
public render() {
return(<ul>
{this.props.todos.map((todo: TodoItem) => {
return <Todo key={todo.getId()}
onClick={this.props.onTodoItemClick}
todo={todo}></Todo>;
})}
</ul>);
}
}
// Send state Store -> Container
const todoListToProp = (state: IStore) => {
return {
todos: state.todos.filter((item: TodoItem) => {
switch (state.visibility) {
case todoVisibility.SHOW_ALL:
return true;
case todoVisibility.SHOW_TODO:
return item.getState() === TodoState.TODO;
case todoVisibility.SHOW_COMPLETED:
return item.getState() === TodoState.COMPLETED;
}
}),
};
};
// Send action Container -> Store
const todoListDispatchToProps = (dispatch: Redux.Dispatch) => {
return {
onTodoItemClick: (id: number) => {
dispatch(TodoActionCreator.completeTodo(id));
},
};
};
export default connect(todoListToProp, todoListDispatchToProps)(TodoList);
TodoList
컴포넌트는 Redux 스토어로 받은 TodoItem
을 각각의 TodoListItem
컴포너느에 뿌려주는 역할을 합니다.
유심히 보셔야 하는 부분은 TodoList
클래스를 직접 export하는 게 아니라는 점입니다. 코드 하단의 connect
함수에 TodoList
클래스를 넘겨주면서 이 함수가 반환한 컴포넌트를 export하고 있습니다.
connect()
함수의 매개변수는 순서대로 다음과 같습니다.
connect()
에서 매개변수로 받는 두 함수가 각각 객체를 반환하는 것을 알 수 있는데, 여기서 반환된 두 객체가 합쳐(merge)지면서 컨테이너 컴포넌트의 props 인터페이스를 구현한 객체를 만듭니다.
위의 코드에서는 todoListToProp()
에서 ITodoListProps
의 todos
를, todoListDispatchToProps()
에서 onTodoItemClick
을 가진 객체를 각각 반환함으로써 두 객체가 합쳐져 ITodoListProps
인터페이스를 만족하는 TodoList
컴포넌트의 prop이 됩니다.
이 connect()
호출에서 반환된 함수의 매개변수로 TodoList
컴포넌트 클래스를 넘겨줌으로써 반환된 클래스가 실제로 앱에서 사용되는 TodoList
컴포넌트가 됩니다. 이 컴포넌트는 별도의 props
없이 다음과 같이 사용합니다.
src/component/App.tsx
import * as React from "react";
import { default as TodoInput } from "./todoInput/todoInput.component";
import { default as TodoList } from "./todoList/todoList.container";
import { default as TodoSelector } from "./todoVisibilitySelector/todoVisibilitySelector";
export class App extends React.Component {
public render() {
return(
<div>
<TodoInput />
<TodoSelector />
<TodoList />
</div>
);
}
}
구체적으로 connect
함수를 이용한 컴포넌트 생성이 어떤 이점을 갖는지에 대해서는 기회가 되는대로 별도의 글로 다뤄보겠습니다.
다른 컴포넌트의 경우는 별도의 컨테이너-프레젠테이션 분리 없이 하나의 컴포넌트로 작성했습니다.
다음은 할 일을 입력받아 리스트에 추가하기 위한 컴포넌트입니다.
src/component/todoInput/todoInput.component.tsx
import * as jquery from "jquery";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { connect } from "react-redux";
import * as Redux from "redux";
import { TodoActionCreator, todoActionTypes } from "../../action/todo";
let idNext = 1;
export interface ITodoInputProps {
onClick: (input: string) => void;
}
class TodoInput extends React.Component<ITodoInputProps> {
public render() {
return (
<div>
<input id="todoInput" type="text"/>
<button onClick={this.onInputButtonClick}>ADD</button>
</div>);
}
private onInputButtonClick = () => {
const elmInput = jquery("#todoInput");
if (!elmInput) { return; }
const input = elmInput.val();
elmInput.val("");
this.props.onClick(input as string);
}
}
const dispatchToProp = (dispatch: Redux.Dispatch) => {
return {
onClick: (input: string) => {
dispatch(TodoActionCreator.addTodo(input, idNext++));
},
};
};
export default connect(null, dispatchToProp)(TodoInput);
버튼 클릭 이벤트에 호출되는 onClick
prop이 할 일을 추가하는 액션을 dispatch하는 함수로 만들어 매핑하였습니다.
이 경우에도 TodoList
컴포넌트처럼 connect()
함수로 랩핑된 컴포넌트를 생성했는데, 입력만 받기 때문에 스토어의 상태 변화와는 무관하므로 mapStateToProps()
메서드는 필요 없습니다.
다음은 리스트의 출력 내용을 변경하기 위한 컴포넌트입니다.
src/component/todoVisibilitySelector/todoVisibilitySelector.tsx
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { TodoActionCreator, todoVisibility } from "../../action/todo";
export interface ITodoVisibilitySelectorProp {
onVisibilityChanged: (visibility: todoVisibility) => void;
}
class TodoVisibilitySelector extends React.Component<ITodoVisibilitySelectorProp> {
public render() {
return (
<form>
<input type="radio" name="visibility" id="show-all"
onClick={() => this.props.onVisibilityChanged(todoVisibility.SHOW_ALL)}/>
<label htmlFor="show-all">Show all</label>
<input type="radio" name="visibility" id="show-todo"
onClick={() => this.props.onVisibilityChanged(todoVisibility.SHOW_TODO)}/>
<label htmlFor="show-todo">Todo</label>
<input type="radio" name="visibility" id="show-completed"
onClick={() => this.props.onVisibilityChanged(todoVisibility.SHOW_COMPLETED)}/>
<label htmlFor="show-completed">Completed</label>
</form>
);
}
}
const dispatchToProp = (dispatch: Dispatch) => {
return {
onVisibilityChanged: (visibility: todoVisibility) => {
dispatch(TodoActionCreator.changeVisibility(visibility));
},
};
};
export default connect(null, dispatchToProp)(TodoVisibilitySelector);
라디오버튼의 선택 상태가 변경될 때마다 대응되는 todoVisibility
값으로 visibility를 변경하는 액션을 dispatch합니다.
React 앱에 Redux를 효과적으로 결합할 수 있는 모듈인 ‘React-Redux’에 대해 살펴봤습니다. 컴포넌트를 비즈니스 로직과 분리함으로써 컴포넌트의 재사용성을 높일 수 있는 방법을 설명하고 Todo 앱의 컴포넌트에 이를 적용해 작성해봤습니다. Redux는 분명 강력한 상태 관리 모듈입니다. 하지만 그렇다고 만병통치약은 아닐 수 있습니다. 불필요한 설명을 줄이기 위해 Todo 앱을 예시로 작성했지만 이정도 수준의 소규모 애플리케이션처럼 Redux가 필요 없거나, 오히려 사용했을 때 효율이 미미한 경우도 있다는 점은 염두에 둬야겠습니다.