React(리액트)의 useState, useReducer 차이점 비교
Reducer 란?
Reducer(리듀서)는 기본적으로 두 개의 인수(current state와 action)를 받고, 이렇게 받은 두 인수를 기반으로 해서 새 상태를 반환하는 함수입니다.
그래서 reducer(리듀서)는 다음과 같은 코드로 표현할 수 있습니다.
const reducer = (state, action) ⇒ newState;
예전에 React와 Redux를 사용해서 코딩하셨다면 reducer(리듀서)란 말을 들어 보셨을 겁니다.
우리가 reducer(리듀서)를 사용해서 얻는 이점은,
- 예측 가능하고 일관되게 작동하며, 그래서 복잡한 상태를 관리하는 데 적합합니다.
- 일반적으로 테스트하기 쉽습니다.
- 실행 취소/다시 실행, 상태 지속성 등과 같은 헬퍼 함수는 reducer(리듀셔)로 구현하기가 더 쉽습니다.
reducer(리듀서)의 이점에 대해 알아봤지만 항상 리듀서를 사용해야한다는 것은 아닙니다.
특히 작은 앱일 경우, 또는 복잡하지 않은 상태(state)를 관리해야 할때는 복잡한 reducer(리듀서)가 득보다는 실이 더 많을 수 있습니다.
useReducer 란?
React(리액트)에서는 useReducer란 훅(Hook)을 제공하는데요. 이 reducer(리듀서)로 우리는 상태 관리(state management)를 할 수 있습니다.
useReducer 문법은 간단합니다. 그냥 reducer(리듀서)와 initialState(초기 상태)를 전달하면 useReducer란 훅(Hook)이 새로운 상태(state)와 dispatch(디스패치)함수를 리턴해 줍니다.
코드 모양은 다음과 같습니다.
const [state, dispatch] = useReducer(reducer, initialState);
이렇게 하고 나면 우리는 액션 타입(action type)과 데이터(data)를 이용해서 dispatch(디스패치) 함수를 사용할 수 있습니다.
React(리액트) 코드에서 예제로 유명한 카운터 앱을 한 번 볼까요?
const reducer = (state, action) => {
switch (action.type) {
case "INCREASE_COUNTER":
return state + 1;
case "SET_COUNTER":
return action.data;
default:
return state;
}
}
const Component = () => {
const [state, dispatch] = useReducer(reducer, 0); //initial counter state: 0
return <div>
<button onClick={()=> dispatch({type: "INCREASE_COUNTER"})}>Increase counter</button>
<button onClick={()=> dispatch({type: "SET_COUNTER", data: 5})}>Set counter to 5</button>
</div>}
dispatch란 “보내다"란 뜻이 있습니다.
그래서 위 코드에서 보듯이 dispatch를 이용해서 액션(action)을 보내는데요.
액션의 형태는 type: “INCREASE_COUNTER”와 같이 사용해서 보내버린 겁니다.
이렇게 하면 reducer(리듀서) 코드에 따라서 상태를 적절하게 변환시키게 됩니다.
반환된 디스패치 함수는 기본적으로 메모화되어 있으며 또한 하위 구성 요소(children components)에 쉽게 전달할 수 있습니다.
그러나 useReducer에서 반환된 state(상태) 부분을 사용하는 모든 구성 요소는 해당 state(상태)가 변경될 때마다 계속 다시 렌더링됩니다.
이번 강좌에서는 이 부분에 대해서는 더 심도 있게 알아보지 않을 예정입니다.
왜 useReducer를 사용할까요?
왜 이렇게 복잡한 reducer(리듀서)를 작성하고, 이상한 dispatch(디스패치) 기능을 사용하여 간단해 보일 수 있는 state(상태) 업데이트를 하는지 곰곰히 생각해 볼 수 있는데요.
위 코드에 있는 카운터 예제는 useState Hook을 사용하여 간단하게 몇 줄로 코드를 만들 수 있기 때문에 굳이 useReducer를 사용할 필요가 없을 수 있습니다.
useState 코드를 이용하면 다음과 같습니다.
const Component = () => {
const [counter, setCounter] = useState(0); // initial counter state: 0
return <div>
<button onClick={()=> setCounter(c => c + 1)}>Increase counter</button>
<button onClick={()=> setCounter(5)}>Set counter to 5</button>
</div>}
일반적으로 state(상태)가 previous state(이전 상태)에 크게 의존하거나 state(상태)가 매우 복잡한 경우 useReducer를 사용하는 것이 좋습니다.
그러나 위의 카운터 예제에서 보듯이 상태가 간단할 경우는 reducer를 사용하는것은 뭔가 코드를 더 복잡하게 할 가능성이 있습니다.
Todo App 만들기
Todo 앱을 이용해서 useState와 useReducer 훅의 사용법을 알아 보겠습니다.
useState 훅(hook)을 사용한 경우
// App.tsx
import useItems from "./use-items";
import Items from "./Items";
import styled from "styled-components";
import Actions from "./Actions";
const AppContainer = styled("div")`
font-family: sans-serif;
margin: 16px;
`;
const TitleContainer = styled("div")`
display: flex;
align-items: center;
margin-bottom: 8px;
`;
const CompletedContainer = styled("div")`
text-align: right;
font-weight: 500;
`;
const Empty = styled("p")`
font-style: italic;
`;
export const App = () => {
const {
items,
addItem,
setItemCompleted,
toggleAllItemsCompleted
} = useItems();
const totalCompleted =
items.filter(({ completed }) => completed)?.length ?? 0;
return (
<AppContainer>
<TitleContainer>
<h3>Your Items</h3>
<Actions
totalItems={items.length}
addItem={addItem}
toggleAllItemsCompleted={toggleAllItemsCompleted}
/>
</TitleContainer>
<CompletedContainer>
Completed: {totalCompleted}/{items.length}
</CompletedContainer>
{items.length === 0 ? (
<Empty>Add items by clicking the button above...</Empty>
) : (
<Items items={items} setItemCompleted={setItemCompleted} />
)}
</AppContainer>
);
}
// useItems.ts
import { useState, useCallback } from "react";
export type ItemType = {
text: string;
completed?: boolean;
};
const useItems = () => {
const [items, setItems] = useState<ItemType[]>([]);
const addItem = useCallback(
(item: Pick<ItemType, "text">) =>
setItems((prevItems) => [item, ...prevItems]),
[]
);
const setItemCompleted = useCallback(
(itemIndex: number, completed: boolean) =>
setItems((prevItems) =>
prevItems.map((item, i) =>
i === itemIndex ? { ...item, completed } : item
)
),
[]
);
const toggleAllItemsCompleted = () => {
const areAllCompleted =
items.length > 0 &&
items.filter(({ completed }) => !completed).length === 0;
setItems((prevItems) =>
prevItems.map((item) => ({ ...item, completed: !areAllCompleted }));
);
};
return {
items,
addItem,
setItemCompleted,
toggleAllItemsCompleted
};
};
export default useItems;
useItems 훅은 4개를 리턴하는데요.
- items 는 배열로서 각각의 아이템을 가지고 있는 배열입니다.
- addItem 함수는 items 배열에 아이템을 추가하는 함수입니다.
- setItemCompleted 함수는 아이템을 completed인지 not completed인지 상태를 변환하게 해주는 함수이며,
- toggleAllItemsCompleted 함수는 모든 아이템을 동일하게 completed인지 not completed 상태로 한꺼번에 바꿀 수 있는 함수입니다.
// Actions.tsx
import styled from "styled-components";
import { ItemType } from "./use-items";
import { LoremIpsum } from "lorem-ipsum";
const lorem = new LoremIpsum({
wordsPerSentence: {
max: 6,
min: 2
}
});
type Props = {
totalItems: number;
addItem: (item: Pick<ItemType, "text">) => void;
toggleAllItemsCompleted: () => void;
};
const Container = styled("div")`
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 0px 16px;
`;
const Button = styled("button")`
border-radius: 4px;
border: none;
font-size: 14px;
height: 30px;
background-color: #3453e5;
color: white;
padding: 0px 12px;
margin-right: 8px;
cursor: pointer;
:active {
opacity: 0.9;
transform: scale(1.05);
}
&.outlined {
border: 1px solid #3453e5;
background: none;
color: #3453e5;
}
`;
const Actions = (props: Props) => {
const { totalItems, addItem, toggleAllItemsCompleted } = props;
return (
<Container>
<ButtononClick={() =>
addItem({
text: lorem.generateWords()
})
}>
Add Item
</Button>
{totalItems > 1 && (
<Button className="outlined" onClick={() => toggleAllItemsCompleted()}>
Toggle All
</Button>)}
</Container>);
};
export default Actions;
// Items.tsx
import { ItemType } from "./use-items";
import styled from "styled-components";
type Props = {
items: ItemType[];
setItemCompleted: (itemIndex: number, completed: boolean) => void;
};
const Container = styled("div")`
display: flex;
flex-wrap: wrap;
`;
const Item = styled("div")`
margin: 8px;
padding: 8px;
border: 1px solid #a7a7a7;
border-radius: 8px;
cursor: pointer;
color: #001a3a;
font-size: 18px;
text-transform: capitalize;
&:hover {
box-shadow: 0px 0px 3px 1px #b9b9b9;
}
&.completed {
opacity: 0.75;
text-decoration: line-through;
color: #9f9f9f;
}
`;
const Items = (props: Props) => {
const { items, setItemCompleted } = props;
return (
<Container>
{items.map(({ text, completed }, i) => (
<Itemkey={i}className={completed ? "completed" : ""}onClick={() => setItemCompleted(i, !completed)}>
{text}
</Item>))}
</Container>);
};
export default Items;
지금까지 useState 훅(hook)을 이용해서 만들어 봤는데요. 잘 작동하고 있습니다.
useReducer 훅(Hook)을 사용한 경우
아래 코드에서는 위에서 쓰였던 styled components 부분을 빼고 보여줄 예정입니다.
왜냐하면 스타일 부분을 동일하게 작동하기 때문입니다.
useState 훅(hook)을 사용한 코드와 달리 주요 변경 사항은 useItems 훅이 구현되는 방식(useReducer 훅 사용)과 디스패치 기능을 활용하는 방식입니다.
useItems 훅을 살펴보겠습니다.
ADD_ITEM, SET_ITEM_COMPLETED 및 TOGGLE_ALL_ITEMS_COMPLETED와 같이 액션(action) 타입을 사용했으며, useReducer 훅은 두개를 리턴합니다. 하나는 state이고 다른 하나는 dispatch입니다.
// useItems.ts
import { useReducer } from "react";
export type ItemType = {
text: string;
completed?: boolean;
};
type State = {
items: ItemType[];
};
export type Action =
| {
type: "ADD_ITEM";
data: {
item: Pick<ItemType, "text">;
};
}
| {
type: "SET_ITEM_COMPLETED";
data: { itemIndex: number; completed: boolean };
}
| { type: "TOGGLE_ALL_ITEMS_COMPLETED" };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_ITEM": {
const { item } = action?.data;
return {
...state,
items: [item, ...state.items]
};
}
case "SET_ITEM_COMPLETED":
const { itemIndex, completed } = action?.data;
return {
...state,
items: state?.items.map((item, i) =>
i === itemIndex ? { ...item, completed } : item
)
};
case "TOGGLE_ALL_ITEMS_COMPLETED": {
const currentItems = state?.items ?? [];
const areAllCompleted =
currentItems.length > 0 &&
currentItems.filter(({ completed }) => !completed).length === 0;
return {
...state,
items: currentItems.map((item) => ({
...item,
completed: !areAllCompleted
}))
};
}
default:
throw new Error();
}
};
const useItems = () => {
const [state, dispatch] = useReducer(reducer, { items: [] });
return {
state,
dispatch
};
};
export default useItems;
// App.tsx
import useItems from "./use-items";
import Items from "./Items";
import Actions from "./Actions";
import styled from "styled-components";
/*
...
styled components here
...
*/
export default function App() {
const { state, dispatch } = useItems();
const { items } = state;
const totalCompleted =
items.filter(({ completed }) => completed)?.length ?? 0;
return (
<AppContainer>
<TitleContainer>
<h3>Your Items</h3>
<Actions totalItems={items.length} dispatch={dispatch} />
</TitleContainer>
<CompletedContainer>
Completed: {totalCompleted}/{items.length}
</CompletedContainer>
{items.length === 0 ? (
<Empty>Add items by clicking the button above...</Empty>) : (
<Items items={items} dispatch={dispatch} />)}
</AppContainer>);
}
App 컴포넌트를 보시면 이전 예제와는 다르게 그냥 dispatch 함수를 자식 컴포넌트에 전달만 해주면 됩니다.
그래서 Actions과 Items 컴포넌트는 단순하게 dispatch 함수만 필요하게 됩니다.
// Actions.tsx
import styled from "styled-components";
import { Action } from "./use-items";
import { LoremIpsum } from "lorem-ipsum";
const lorem = new LoremIpsum({
wordsPerSentence: {
max: 6,
min: 2
}
});
type Props = {
totalItems: number;
dispatch: React.Dispatch<Action>;
};
/*
...
styled components here
...
*/
const Actions = (props: Props) => {
const { totalItems, dispatch } = props;
return (
<Container>
<Button
onClick={() =>
dispatch({
type: "ADD_ITEM",
data: {
item: {
text: lorem.generateWords()
}
}
})
}
>
Add Item
</Button>
{totalItems > 1 && (
<ButtonclassName="outlined"onClick={() => dispatch({ type: "TOGGLE_ALL_ITEMS_COMPLETED" })}>
Toggle All
</Button>)}
</Container>);
};
export default Actions;
// Items.tsx
import { Action, ItemType } from "./use-items";
import styled from "styled-components";
type Props = {
items: ItemType[];
dispatch: React.Dispatch<Action>;
};
/*
...
styled components here
...
*/
const Items = (props: Props) => {
const { items, dispatch } = props;
return (
<Container>
{items.map(({ text, completed }, i) => (
<Itemkey={i}className={completed ? "completed" : ""}onClick={() =>
dispatch({
type: "SET_ITEM_COMPLETED",
data: { itemIndex: i, completed: !completed }
})
}>
{text}
</Item>))}
</Container>);
};
export default Items;
지금까지 useState와 useReducer를 이용해서 상태관리를 해 봤는데요.
결론
앱이 복잡하고 확장성이 있을 경우 useReducer를 이용한 상태 관리가 좋을 듯 싶습니다.
왜냐하면 useReducer로 구현하면 나중에 action(액션)만 추가하고 그리고 dispatch함수만 자식 컴포넌트에 전달하면 되기 때문이죠.
그러나 간단한 상태 관리일 경우는 useState를 이용해서도 충분히 앱을 구현할 수 있기 때문에 굳이 useReducer를 사용할 필요가 없을 듯 싶습니다.