이전 게시글에 이어서..
컨테이너 컴포넌트 생성
컴포넌트 리덕스 스토어에 접근해 원하는 상태를 받아오고, 또 액션도 디스패치할 것이다. 리덕스 스토어와 연동된 컴포넌트를 컨테이너 컴포넌트라고 부른다.
CounterContainer 만들기
src 디렉토리 내에 containers 디렉토리를 생성한 뒤, CounterContainer 컴포넌트를 만들자.
import Counter from "../components/Counter";
export default function CounterContainer() {
return <Counter />;
}
위 컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야한다.
connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)
리덕스의 connect
함수는 리액트 컴포넌트를 리덕스 스토어에 연결하는 역할을 수행한다.
connect 함수는 두 개의 인자를 받는다. 첫 번째 인자는 스토어의 상태를 컴포넌트의 props로 매핑하는 함수이며, 두 번째 인자는 액션을 디스패치하는 함수를 컴포넌트의 props로 매핑하는 함수이다.
connect 함수는 이러한 두 개의 함수를 인자로 받아, 리덕스의 Provider 컴포넌트에서 제공되는 스토어에 연결된 새로운 컴포넌트를 반환한다. 이 새로운 컴포넌트는 스토어의 변경 사항을 구독하고, 변경 사항이 발생할 때마다 새로운 상태를 가져와서 자동으로 컴포넌트를 업데이트한다.
connect 함수의 첫 번째 인자는 mapStateToProps 함수로, 이 함수는 현재 스토어의 상태를 컴포넌트의 props로 매핑한다. 이 함수는 스토어의 상태를 인자로 받아서 컴포넌트에서 필요한 데이터만 추출해서 반환한다. 이렇게 하면 컴포넌트에서 필요한 데이터만 받아와서 불필요한 렌더링을 방지할 수 있다.
connect 함수의 두 번째 인자는 mapDispatchToProps 함수로, 이 함수는 액션을 디스패치하는 함수를 컴포넌트의 props로 매핑한다. 이 함수는 액션을 디스패치할 수 있는 함수들을 객체로 반환한다. 이렇게 하면 컴포넌트에서 액션을 디스패치할 때마다 dispatch 함수를 직접 호출하는 것이 아니라, props를 통해 간단하게 액션을 디스패치할 수 있다.
CounterContainer 컴포넌트에서 connect를 사용해보자.
// src/containers/CounterContainers.tsx
import { connect } from "react-redux";
import { Dispatch } from "redux";
import Counter from "../components/Counter";
interface Props {
number: number;
increase: () => void;
decrease: () => void;
}
const mapStateToProps = (state) => ({
number: state.counter.number,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
increase: () => {
console.log("increase");
},
decrease: () => {
console.log("decrease");
},
});
const CounterContainer = ({ number, increase, decrease }: Props) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(mapStateToProps, mapDispatchToProps)(CounterContainer);
그리고, App에서 Counter를 CounterContainer로 교체하자
import Counter from "./components/Counter";
import Todos from "./components/Todos";
import CounterContainer from "./containers/CounterContainer";
function App() {
return (
<div>
<CounterContainer />
<hr />
<Todos />
</div>
);
}
export default App;
여기까지 작성하고, 렌더링해서 개발자 도구를 켜준 뒤 +1, -1 버튼을 눌러보면 콘솔에 출력되는것을 확인할 수 있다.
여기서 console.log 대신 액션 생성 함수를 불러와 액션 객체를 만들어 디스패치를 해보자.
import { connect } from "react-redux";
import { Dispatch } from "redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";
interface Props {
number: number;
increase: () => void;
decrease: () => void;
}
const mapStateToProps = (state) => ({
number: state.counter.number,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
increase: () => {
dispatch(increase());
},
decrease: () => {
dispatch(decrease());
},
});
const CounterContainer = ({ number, increase, decrease }: Props) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(mapStateToProps, mapDispatchToProps)(CounterContainer);
connect 함수를 사용할 때는 일반적으로 위 코드와 같이 mapStateToProps와 mapDispatchToProps를 미리 선언해놓고 사용한다. 하지만 connect 함수 내부에 익명 함수 형태로 선언해도 문제가 되지 않는다. 하지만 connect 함수 내부에 익명 함수 형태로 선언해도 문제가 되지 않는다. 취향에 따라 다음과 같이 작성해볼 수 있다.
import { connect } from "react-redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";
interface Props {
number: number;
increase: () => void;
decrease: () => void;
}
const CounterContainer = ({ number, increase, decrease }: Props) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(
(state) => ({
number: state.counter.number,
}),
(dispatch) => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
})
)(CounterContainer);
위 코드는 액션 생성 함수를 호출해서 디스패치하는 코드가 한 줄이기 때문에 불필요한 코드 블록을 생략해주었다.
또한, 액션 생성 함수의 개수가 많아짐에 따라 dispatch로 감싸는 작업이 불편할 수도 있다. 조금 더 편한 방법은 dispatch 부분의 파라미터를 함수 형태가 아닌 액션 생성 함수로 이루어진 객체로 넣어주는 것이다.
import { connect } from "react-redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";
interface Props {
number: number;
increase: () => void;
decrease: () => void;
}
const CounterContainer = ({ number, increase, decrease }: Props) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(
(state: { counter: { number: number } }) => ({
number: state.counter.number,
}),
{
increase,
decrease,
}
)(CounterContainer);
TodosContainer 만들기
Todos 컴포넌트를 위한 컨테이너인 TodosContainer를 작성해보자.
// src/containers/TodosContainer.tsx
import Todos from "../components/Todos";
import { connect } from "react-redux";
import { changeInput, insert, toggle, remove } from "../modules/todos";
interface Props {
input: string;
todos: { id: number; text: string; done: boolean }[];
changeInput: (input: string) => void;
insert: (text: string) => void;
toggle: (id: number) => void;
remove: (id: number) => void;
}
const TodosContainer = ({
input,
todos,
changeInput,
insert,
toggle,
remove,
}: Props) => {
return (
<Todos
input={input}
todos={todos}
onChangeInput={changeInput}
onInsert={insert}
onToggle={toggle}
onRemove={remove}
/>
);
};
export default connect(
({ todos }) => ({
input: todos.input,
todos: todos.todos,
}),
{
changeInput,
insert,
toggle,
remove,
}
)(TodosContainer);
Props
인터페이스는 이 컨테이너 컴포넌트에서 사용할 props들을 정의하고 있다. input
은 Todo List에 새로 추가될 항목을 입력하는 값이고, todos
는 현재 Todo List에 등록된 항목들을 담은 배열이다. changeInput, insert, toggle, remove
는 각각 Todo List에 새로운 항목을 추가하거나, 항목을 체크/언체크하거나, 항목을 삭제하는 기능을 수행하는 액션 생성 함수들이다.
TodosContainer
컴포넌트는 위에서 정의한 Props 인터페이스를 props로 받아서 그대로 Todos 컴포넌트에 전달하는 역할을 수행한다. 이 때 connect 함수를 사용하여 TodosContainer와 Redux store를 연결해준다. connect 함수는 두 개의 파라미터를 받는데, 첫 번째 파라미터는 Redux store의 state를 props로 매핑해주는 함수이고, 두 번째 파라미터는 액션 생성 함수들을 props로 매핑해주는 객체이다. 이렇게 connect 함수를 통해 TodosContainer와 Redux store를 연결하면, TodosContainer는 Redux store의 상태와 액션 생성 함수들에 접근할 수 있게 된다.
마지막으로 connect 함수를 이용해 생성된 컴포넌트인 TodosContainer를 내보낸다.
// src/App.tsx
import CounterContainer from "./containers/CounterContainer";
import TodosContainer from "./containers/TodosContainer";
function App() {
return (
<div>
<CounterContainer />
<hr />
<TodosContainer />
</div>
);
}
export default App;
여기까지 작성해주면 모든 작업이 끝났다. 브라우저에서 구현한 모든 기능을 시험해보자.
리덕스 조금 더 편하게 사용하기
redux-action
redux-action
을 사용하면 액션 생성 함수를 더 짧은 코드로 작성할 수 있다. redux-action을 사용하면 액션 객체를 생성하는 코드의 중복을 줄이고, 액션 타입 문자열을 상수로 정의하는 것이 번거로운 점을 해결할 수 있다.
redux-action은 액션 생성자를 만들어주는 createAction
함수와, 생성된 액션을 처리하는 handleActions
함수를 제공한다.
createAction
함수를 사용하면, 액션 타입과 페이로드를 받아 액션 객체를 생성하는 함수를 생성할 수 있다. 이 함수는 Flux 표준 액션 객체를 생성하여 반환하므로, 다른 미들웨어나 라이브러리와 쉽게 호환된다.
handleActions
함수는, 액션 타입을 키로, 상태를 값으로 가지는 객체와 초기 상태를 받아서, 액션 타입에 따라 상태를 업데이트하는 리듀서 함수를 생성한다. 이 함수는 switch문보다 간단하고 가독성이 좋아서, 코드의 유지보수성을 높여준다.
redux-action을 사용하면, 코드량을 줄이고 가독성을 높일 수 있다. 또한 액션 타입이나 액션 생성자의 이름을 오타로 인해 발생하는 버그를 방지할 수 있다.
라이브러리를 먼저 설치해주자.
npm install redux-actions
npm install @types/redux-actions
counter 모듈에 라이브러리 적용
import { createAction, handleActions } from "redux-actions";
const INCREASE: string = "counter/INCREASE" as const;
const DECREASE: string = "counter/DECREASE" as const;
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
interface InitialStateType {
number: number;
}
const initialState: InitialStateType = {
number: 0,
};
const counter = handleActions(
{
[INCREASE]: (state, action) => ({ number: state.number + 1 }),
[DECREASE]: (state, action) => ({ number: state.number - 1 }),
},
initialState
);
export default counter;
counter 모듈에 적용시켜보면, 코드의 길이가 굉장히 짧아지고 가독성도 좋아진 것을 확인할 수 있다. 동작 또한 동일하게 작동한다.
todos 모듈에 적용하기
리듀서에서 상태를 업데이트할 때는 불변성을 지켜야하기 때문에 앞에서는 spread 연산자와 배열의 내장 함수를 활용했다. 그러나 모듈의 상태가 복잡해질수록 불변성을 지키기가 까다로워진다. 따라서 모듈의 상태를 설계할 때는 객체의 깊이가 너무 깊어지지 않도록 주의해야한다. 객체의 깊이가 깊지 않을 수록 불변성을 지키면서 값을 업데이트하기 수월하기 때문이다.
하지만 상황에 따라 하나의 객체 안에 묶어서 넣는 것이 코드의 가독성을 높이는 데 유리하며, 나중에 컴포넌트에 리덕스를 연동할 때도 더욱 편하다.
객체의 구조가 복잡해지거나 객체로 이루어진 배열을 다룰 경우, immer 라이브러리를 사용하는것도 좋은 방법이 될 수 있다.
npm install immer
import { createAction, handleActions } from "redux-actions";
import produce, { Draft } from "immer";
interface Todo {
id: number;
text: string;
done: boolean;
}
interface TodosState {
input: string;
todos: Todo[];
}
const CHANGE_INPUT = "todos/CHANGE_INPUT"; // 인풋 값을 변경함
const INSERT = "todos/INSERT"; // 새로운 todo 를 등록함
const TOGGLE = "todos/TOGGLE"; // todo 를 체크/체크해제 함
const REMOVE = "todos/REMOVE"; // todo 를 제거함
export const changeInput = createAction<string, string>(
CHANGE_INPUT,
(input) => input
);
let id = 3; // insert 가 호출 될 때마다 1씩 더해집니다.
export const insert = createAction<Todo, string>(INSERT, (text) => ({
id: id++,
text,
done: false,
}));
export const toggle = createAction<number, number>(TOGGLE, (id) => id);
export const remove = createAction<number, number>(REMOVE, (id) => id);
const initialState: TodosState = {
input: "",
todos: [
{
id: 1,
text: "리덕스 기초 배우기",
done: true,
},
{
id: 2,
text: "리액트와 리덕스 사용하기",
done: false,
},
],
};
const todos = handleActions<TodosState, any>(
{
[CHANGE_INPUT]: (state, { payload: input }) =>
produce(state, (draft: Draft<TodosState>) => {
draft.input = input;
}),
[INSERT]: (state, { payload: todo }) =>
produce(state, (draft: Draft<TodosState>) => {
draft.todos.push(todo);
}),
[TOGGLE]: (state, { payload: id }) =>
produce(state, (draft: Draft<TodosState>) => {
const todo = draft.todos.find((todo) => todo.id === id);
if (todo) {
todo.done = !todo.done;
}
}),
[REMOVE]: (state, { payload: id }) =>
produce(state, (draft: Draft<TodosState>) => {
const index = draft.todos.findIndex((todo) => todo.id === id);
if (index !== -1) {
draft.todos.splice(index, 1);
}
}),
},
initialState
);
export default todos;
우선 코드에서는 Todo
와 TodosState
두 개의 인터페이스를 정의하고 있다. Todo는 각각의 할 일 항목을 의미하며, TodosState는 전체 할 일 목록과 현재 입력된 값(input)을 포함하는 상태를 정의한다.
그 다음으로는 CHANGE_INPUT, INSERT, TOGGLE, REMOVE 네 개의 액션 타입 상수를 정의한다. 이 액션 타입 상수들은 createAction 함수를 이용하여 액션 생성자 함수를 생성한다.
액션 생성자 함수는 해당 액션의 type과 payload 값을 반환하는 함수이다. createAction 함수에서는 첫 번째 인자로 액션 타입을, 두 번째 인자로는 payload를 반환하는 함수를 전달한다. 이렇게 생성된 액션 생성자 함수는 각각 changeInput, insert, toggle, remove 함수로 저장된다.
이후 initialState에는 초기 상태값을 설정하고 있다. input은 빈 문자열이며, todos는 두 개의 할 일 객체를 담은 배열로 초기화된다.
그 다음은 handleActions
함수를 사용하여 액션들을 처리하는 리듀서 함수를 생성한다.
handleActions 함수는 두 개의 매개변수를 받는다. 첫 번째는 액션에 대한 리듀서 함수들을 가지고 있는 객체이다. 이 객체의 속성 키는 액션 타입이 되고, 속성 값은 그에 해당하는 리듀서 함수가 된다.
이 리듀서 함수들은 현재 상태와 액션 객체를 매개변수로 받아서, 새로운 상태를 반환한다.
예를 들어, CHANGE_INPUT 액션에 대한 리듀서 함수는 다음과 같다.
[CHANGE_INPUT]: (state, { payload: input }) =>
produce(state, (draft: Draft<TodosState>) => {
draft.input = input;
}),
이 리듀서 함수는 immer 라이브러리의 produce 함수를 사용해서 새로운 상태를 생성한다. produce 함수는 현재 상태를 변경하지 않고 새로운 상태를 반환한다. 그리고, 이 상태를 변경하기 위해서는 상태를 변경할 수 있는 draft 객체를 사용한다. 이 객체는 현재 상태를 가상으로 복사한 것으로, 이 객체를 변경하면 실제 상태는 변경되지 않는다.
이 리듀서 함수에서는 draft 객체의 input 속성을 액션에서 받은 payload 값으로 변경한다.
나머지 액션들에 대한 리듀서 함수들도 비슷한 방식으로 동작한다. 이렇게 만든 리듀서 함수 객체와 초기 상태를 handleActions 함수에 전달해서 리듀서 함수를 생성한다.
Hooks 사용한 컨테이너 컴포넌트 만들기
리덕스 스토어와 연동된 컨테이너 컴포넌트를 만들 때 connect 함수를 사용하는 대신 react-redux에서 제공하는 Hooks를 사용할 수도 있다.
useSelector로 상태 조회하기
useSelector를 사용하면 connect함수를 사용하지 않고도 리덕스의 상태를 조회할 수 있다. 리덕스 스토어의 상태값이 변경될 때마다 자동으로 컴포넌트가 리렌더링되며, useSelector를 사용하면 해당 컴포넌트가 필요로하는 상태값만 선택적으로 가져올 수 있다.
예를들어, 다음과 같이 상태값을 가져오는 코드를 작성할 수 있다.
import { useSelector } from 'react-redux';
import { RootState } from './store'; // RootState는 전역 상태의 타입
function TodoList() {
const todos = useSelector((state: RootState) => state.todos);
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input type="checkbox" checked={todo.done} />
<span>{todo.text}</span>
<button>삭제</button>
</li>
))}
</ul>
);
}
useSelector
의 인자로 전달되는 콜백 함수는 전역 상태에서 필요한 값을 선택적으로 가져오는 역할을 수행한다. 이 콜백 함수는 상태값을 반환하는 함수여야 하며, 이전 상태값과 다음 상태값을 비교해 변경될 때만 컴포넌트를 리렌더링한다.
위 코드에서는 useSelector 의 콜백 함수에서 전역 상태의 todos 배열만 가져오고 있다. 이 배열은 Todo 타입의 객체를 요소로 가지고 있으며, 이를 기반으로 Todo 컴포넌트를 렌더링한다.
이번에는 CounterContainer에서 connect 함수 대신 useSelector를 사용해 counter.number를 조회해서 Counter에게 props를 넘겨주자.
// src/containers/CounterContainer.tsx
import { useSelector } from "react-redux";
import Counter from "../components/Counter";
const CounterContainer = () => {
const number = useSelector((state) => state.counter.number);
return <Counter number={number} />;
};
export default CounterContainer;
useDispatch를 이용한 액션 디스패치
여기에 추가적으로 useDispatch라는 Hook을 사용할 것이다.
useDispatch
는 react-redux
라이브러리에서 제공하는 훅 중 하나로, dispatch 함수를 반환하여 리덕스 스토어에 액션을 전달할 수 있게 해준다. 이 함수는 컴포넌트 내부에서 호출되며, dispatch 함수를 반환한다. 이제 이 함수를 사용하여 액션을 디스패치할 수 있다.
CounterContainer 컴포넌트에 useDispatch 함수를 적용하면 INCREASE와 DECREASE 액션을 발생시킬 수 있다.
import { useDispatch, useSelector } from "react-redux";
import { combineReducers } from "redux";
import Counter from "../components/Counter";
import { decrease, increase } from "../modules/counter";
import counter from "../modules/counter";
const rootReducer = combineReducers({
counter,
});
type RootState = ReturnType<typeof rootReducer>;
const CounterContainer = () => {
const number = useSelector((state: RootState) => state.counter.number);
const dispatch = useDispatch();
return (
<Counter
number={number}
onIncrease={() => dispatch(increase())}
onDecrease={() => dispatch(decrease())}
/>
);
};
export default CounterContainer;
우선 combineReducers라는 함수를 통해 모든 리듀서를 하나의 트리로 결합하고, 결합된 리듀서를 rootReducer라는 변수에 할당해주었다.
ReturnType<typeof rootReducer>
를 사용하여 RootState 타입을 만들어 주며, RootState는 리덕스 스토어의 전체 상태를 나타내는 타입이다.
CounterContainer
컴포넌트 내에서는 useSelector hook을 사용하여 number 상태를 선택하고, useDispatch hook을 사용하여 increase와 decrease 액션을 디스패치할 수 있다. 그리고 Counter 컴포넌트에 number, onIncrease, onDecrease props를 전달한다.
이렇게 하면 Counter 컴포넌트에서는 number, onIncrease, onDecrease props를 사용하여 UI를 렌더링하고, onIncrease, onDecrease 함수를 호출하여 Redux 스토어의 상태를 업데이트할 수 있다.
여기까지 작성 후, 결과를 렌더링해보면, 결과 자체는 동일하게 나타나는 것을 확인할 수 있다. 하지만 숫자가 바뀔 때마다 onIncrease, onDecrease 함수가 새롭게 만들어지고 있는데, 더욱 최적화를 해야하는 상황이 온다면, useCallback 함수로 이 부분을 감싸주는 것이 좋다.
const CounterContainer = () => {
const number = useSelector((state: RootState) => state.counter.number);
const dispatch = useDispatch();
const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
const onDecrease = useCallback(() => dispatch(decrease()), [dispatch]);
return (
<Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />
);
};
useDispatch를 사용할 때는 위처럼 useCallback도 함께 사용해주는 것이 좋다.
TodosContainer 리팩토링
이번엔 TodosContainer의 connect를 useSelector와 useDispatch로 변환해보자.
// src/containers/TodosContainer.tsx
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import Todos from "../components/Todos";
import { changeInput, insert, toggle, remove } from "../modules/todos";
const TodosContainer = () => {
const { input, todos } = useSelector(
(state: {
todos: {
input: string;
todos: Array<{ id: number; text: string; done: boolean }>;
};
}) => ({
input: state.todos.input,
todos: state.todos.todos,
})
);
const dispatch = useDispatch();
const onChangeInput = useCallback(
(input: string) => dispatch(changeInput(input)),
[dispatch]
);
const onInsert = useCallback(
(text: string) => dispatch(insert(text)),
[dispatch]
);
const onToggle = useCallback(
(id: number) => dispatch(toggle(id)),
[dispatch]
);
const onRemove = useCallback(
(id: number) => dispatch(remove(id)),
[dispatch]
);
return (
<Todos
input={input}
todos={todos}
onChangeInput={onChangeInput}
onInsert={onInsert}
onToggle={onToggle}
onRemove={onRemove}
/>
);
};
export default TodosContainer;
위 코드는 useSelector
함수를 사용하여 리덕스 스토어에서 필요한 상태 값을 선택한다. 스토어 인자는 전체 스토어의 상태를 나타낸다. 여기에서는 state.todos 객체에서 input과 todos 프로퍼티를 추출하여 사용한다. 그리고, useDispatch
함수를 사용하여 Redux store로부터 dispatch 함수를 가져오며, useCallback
을 사용하여 콜백 함수들을 최적화해준다. onChangeInput, onInsert, onToggle, onRemove
함수는 각각 changeInput, insert, toggle, remove 액션을 디스패치하는 함수이다.
최종적으로 Todos 컴포넌트에 필요한 props들을 구성하여 반환한다.
위 컨테이너 컴포넌트를 사용하면 Todos 컴포넌트는 리덕스 스토어로부터 필요한 상태와 콜백 함수들을 props로 받을 수 있다. 이를 통해 Todos 컴포넌트는 상태 관리 로직을 외부로부터 분리하여 코드를 더욱 간결하게 유지할 수 있다.
'WEB > React' 카테고리의 다른 글
리액트 리덕스 상태관리 - 1 [react/redux/typescript] (0) | 2023.03.22 |
---|---|
리액트 Context API [react/typescript] (0) | 2023.03.21 |
리액트 외부 API 연동 실습 [react/API/typescript] (0) | 2023.03.20 |
리액트 라우터 써보기 [react/router/typescript] (0) | 2023.03.19 |
리액트 불변성 유지하기 [react/immer/typescript] (0) | 2023.03.18 |