React와 Redux 함께 사용하기

Redux는 앱의 상태(state)만을 관리하기 때문에 React, Angular, Vue 등 어느 웹 프레임워크와도 잘 어울립니다. 이번 글에서는 지난 글에서 작성했던 Todo 앱을 React 앱으로 작성하면서 어떻게 활용될 수 있는지 알아보도록 하겠습니다.

React-Redux

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() 함수의 매개변수는 순서대로 다음과 같습니다.

mapStateToProps
스토어를 구독하여 스토어의 상태가 변경될 때마다 호출되는 함수입니다. 변경된 스토어의 상태를 매개변수로 받아 갱신될 컴포넌트의 props를 반환합니다. 정리하면, 현재 스토어의 상태를 컴포넌트의 props와 맵핑하는 함수입니다.
mapDispatchToProps
스토어의 `dispatch(action)`함수를 매개변수로 받는 함수입니다. 컨테이너 컴포넌트의 props 중, `dispatch` 호출이 필요한 props를 이 함수에서 맵핑합니다.

connect()에서 매개변수로 받는 두 함수가 각각 객체를 반환하는 것을 알 수 있는데, 여기서 반환된 두 객체가 합쳐(merge)지면서 컨테이너 컴포넌트의 props 인터페이스를 구현한 객체를 만듭니다.

위의 코드에서는 todoListToProp()에서 ITodoListPropstodos를, 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가 필요 없거나, 오히려 사용했을 때 효율이 미미한 경우도 있다는 점은 염두에 둬야겠습니다.

목록으로