여러분은 리액트 useState 지옥에 빠져본 적이 있으신가요?
네 맞아요, 아래 예시와 같은 상황이죠.
import { useState } from "react";
function EditCalendarEvent() {
const [startDate, setStartDate] = useState();
const [endDate, setEndDate] = useState();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [location, setLocation] = useState();
const [attendees, setAttendees] = useState([]);
return (
<>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{/* ... */}
</>
);
}
위 예시는 달력 이벤트를 업데이트하는 컴포넌트입니다. 아쉽지만 몇 가지 문제가 있죠.
한눈에 읽기 어렵다는 점도 있지만 세이프가드가 없다는 문제도 있습니다. 종료 날짜를 시작 날짜 이전으로 선택하는 모순을 막을 방법이 없습니다.
제목이나 설명이 너무 긴 경우에 대한 세이프가드도 존재하지 않죠.
물론 set*()
를 호출하는 모든 곳에서 상태를 업데이트하기 전에 이 모든 조건을 검토할 것을 기억할 것이라고, 심지어는 알고 있을 것이라고 믿으며 노심초사하고 있을 수도 있습니다. 하지만 저는 깨지기 쉬운 상태에 놓여있다는 것을 알고도 마냥 안심하지는 못할 것 같습니다.
useState를 대체할 수 있는 생각보다 사용하기 쉽고 더 강력한 상태 훅이 있다는 것을 알고 계셨나요?
useReducer를 사용하면 위 예시 코드를 아래와 같이 변경할 수 있습니다.
import { useReducer } from "react";
function EditCalendarEvent() {
const [event, updateEvent] = useReducer(
(prev, next) => {
return { ...prev, ...next };
},
{ title: "", description: "", attendees: [] }
);
return (
<>
<input
value={event.title}
onChange={(e) => updateEvent({ title: e.target.value })}
/>
{/* ... */}
</>
);
}
useReducer
훅을 사용하면 상태 A에서 상태 B로의 변환을 제어할 수 있습니다.
누군가는 "useState를 사용해서도 가능해요"라고 말씀하시면서 아래와 같은 코드를 제시할 수 있습니다.
import { useState } from "react";
function EditCalendarEvent() {
const [event, setEvent] = useState({
title: "",
description: "",
attendees: [],
});
return (
<>
<input
value={event.title}
onChange={(e) => setEvent({ ...event, title: e.target.value })}
/>
{/* ... */}
</>
);
}
틀린 말은 아닙니다만, 여기서 놓친 중요한 포인트가 있습니다. 이러한 포맷은 항상 ...event
로 전개하여 객체를 직접 변경하지 않도록 해야 한다는 것입니다. 뿐만 아니라 useReducer
의 중요한 이점인 상태 변환을 제어할 수 있는 함수를 추가할 수 있다는 점 또한 여전히 놓치고 있습니다.
다시 useReducer
로 돌아가서, 유일한 차이점은 각 상태의 변화가 안전하고 유효할 것을 보장하는 함수를 추가로 인자에 전달한다는 점입니다.
const [event, updateEvent] = useReducer(
(prev, next) => {
// 이벤트를 검증하고 변환하여 상태가 항상 유효할 것을 한 곳에서 관리하며 보장합니다
// ...
},
{ title: "", description: "", attendees: [] }
);
이는 상태를 한 곳에서 관리하며 언제나 유효하다는 것을 보장한다는 이점을 갖습니다.
따라서 이러한 모델을 사용하면 이후에 다른 코드들이 추가되더라도, 팀의 새로운 멤버가 updateEvent()
를 유효하지 않은 데이터와 함께 호출하더라도 상태 값을 검증하는 콜백이 실행될 것입니다.
예를 들어 언제 어디서 상태가 업데이트되든 절대 종료 날짜가 시작 날짜 이전일 수 없고 (이는 말이 안 되기 때문에), 제목의 길이가 최대 100자를 넘지 않도록 보장하고자 한다고 가정해봅시다.
import { useReducer } from "react";
function EditCalendarEvent() {
const [event, updateEvent] = useReducer(
(prev, next) => {
const newEvent = { ...prev, ...next };
// 시작 날짜가 종료 날짜 이후가 될 수 없음을 보장합니다
if (newEvent.startDate > newEvent.endDate) {
newEvent.endDate = newEvent.startDate;
}
// 제목이 100자를 넘을 수 없음을 보장합니다
if (newEvent.title.length > 100) {
newEvent.title = newEvent.title.substring(0, 100);
}
return newEvent;
},
{ title: "", description: "", attendees: [] }
);
return (
<>
<input
value={event.title}
onChange={(e) => updateEvent({ title: e.target.value })}
/>
{/* ... */}
</>
);
}
상태를 바로 변경할 수 없도록 방지하는 이 기능은 특히 코드가 방대해질수록 중요한 안전망을 제공합니다.
UI로도 입력값이 유효한지 여부를 표시해주어야 합니다. 데이터베이스에 ORM 처럼 안전성을 보장하는 하나의 세트로 생각하면 상태 값이 항상 유효하다는 것을 완전히 확신할 수 있습니다. 이를 통해 향후 이상하고 디버깅하기 힘든 문제가 발생하지 않도록 방지할 수 있습니다.
useState
를 사용하는 거의 모든 곳에서 useReducer
를 사용할 수 있습니다.세상에서 가장 간단한 컴포넌트인 카운터 컴포넌트가 있고 해당 컴포넌트에서 useState
훅을 사용한다고 가정해봅시다.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}
이런 간단한 예시에서도, count
는 무한대로 커질 수 있을까요? 음수가 되어야 하는 경우는 없을까요?
네 물론 이 예시에서 음수 값이 나오는 경우는 없겠지만 카운트에 제한을 설정하고자 하는 경우 useReducer
를 사용하면 간단하게 처리할 수 있습니다. 또 상태가 언제 어떻게 사용되든 항상 유효할 것임을 자신할 수 있습니다.
import { useReducer } from "react";
function Counter() {
const [count, setCount] = useReducer((prev, next) => Math.min(next, 10), 0);
return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}
상황이 더욱 복잡해지면 redux 스타일의 action 기반 패턴을 사용할 수도 있습니다.
위에서 언급했던 달력 예시로 돌아가 봅시다. 아래와 같이 코드를 다시 작성해 볼 수 있습니다.
import { useReducer } from "react";
function EditCalendarEvent() {
const [event, updateEvent] = useReducer(
(state, action) => {
const newEvent = { ...state };
switch (action.type) {
case "updateTitle":
newEvent.title = action.title;
break;
// action들...
}
return newEvent;
},
{ title: "", description: "", attendees: [] }
);
return (
<>
<input
value={event.title}
onChange={(e) => updateEvent({ type: "updateTitle", title: "Hello" })}
/>
{/* ... */}
</>
);
}
useReducer
에 대한 문서나 글들을 보면 모두 이 방법이 useReducer 훅을 사용하는 유일한 방법처럼 설명합니다.
하지만 이 방법은 useReducer 훅을 사용할 수 있는 다양한 방법 중 하나의 방법일 뿐이라는 점을 강조하고 싶습니다. 주관적인 의견이지만, 개인적으로 Redux와 이러한 패턴을 좋아하진 않습니다.
물론 장점은 있지만 action에 대한 새로운 추상화를 레이어링하기 시작한다면 Mobx, Zustand, XState와 같은 라이브러리를 추천하고 싶습니다.
그래도 추가 종속성 없이 이 패턴을 활용할 때 더 우아하기 때문에 그러한 형식을 좋아하는 사람들을 위해 제안합니다.
useReducer
의 또 다른 좋은 점은 이 훅에 의해 컨트롤되는 데이터를 자식 컴포넌트에서 업데이트하고자 할 때 편리하다는 것입니다. useState
에서는 여러 개의 함수들을 전달해야 했지만 useReducer
에서는 reducer 함수만을 전달하면 됩니다.
리액트 문서에서 설명하고 있는 예시는 다음과 같습니다.
const TodosDispatch = React.createContext(null);
function TodosApp() {
// 참고: `dispatch` 는 리렌더 간에 변하지 않습니다
const [todos, updateTodos] = useReducer(todosReducer);
return (
<TodosDispatch.Provider value={updateTodos}>
<DeepTree todos={todos} />
</TodosDispatch.Provider>
);
}
자식 컴포넌트에서는 아래와 같이 사용하면 됩니다.
function DeepChild(props) {
// action을 수행하고 싶다면 context로부터 dispatch를 전달받으면 됩니다.
const updateTodos = useContext(TodosDispatch);
function handleClick() {
updateTodos({ type: "add", text: "hello" });
}
return <button onClick={handleClick}>Add todo</button>;
}
이렇게 하면 통일된 하나의 업데이트 함수를 가질 수 있을 뿐만 아니라 자식 컴포넌트로부터 상태가 업데이트되어도 요구 사항에 부합하도록 안전성을 보장할 수 있습니다.
useReducer
훅의 상태 값은 항상 불변해야 함에 주의해야 합니다. 만약 reducer 함수에서 실수로 객체를 직접 변경시켰다면 몇 가지 문제가 발생할 수 있습니다.
리액트 문서에서 설명하고 있는 아래 예시를 살펴보겠습니다.
function reducer(state, action) {
switch (action.type) {
case "incremented_age": {
// 🚩 Wrong: 기존 객체를 변경시켰습니다
state.age++;
return state;
}
case "changed_name": {
// 🚩 Wrong: 기존 객체를 변경시켰습니다
state.name = action.nextName;
return state;
}
// ...
}
}
이를 올바르게 고치면 다음과 같습니다.
function reducer(state, action) {
switch (action.type) {
case "incremented_age": {
// ✅ Correct: 새로운 객체를 생성합니다
return {
...state,
age: state.age + 1,
};
}
case "changed_name": {
// ✅ Correct: 새로운 객체를 생성합니다
return {
...state,
name: action.nextName,
};
}
// ...
}
}
만약에 이런 문제를 자주 만난다면 라이브러리로부터 도움을 받을 수도 있습니다.
Immer는 우아하고 가변적인 DX를 가지고 있으면서도 데이터의 불변을 보장하는 매우 훌륭한 라이브러리입니다.
use-immer 패키지는 추가로 useImmerReducer
함수를 제공하는데 이 함수를 사용하면 직접적인 변경을 통한 상태 변경이 가능합니다. 라이브러리 내부적으로 자바스크립트 Proxy를 사용해서 불변한 복사본을 만들어주는 것이죠.
import { useImmerReducer } from "use-immer";
function reducer(draft, action) {
switch (action.type) {
case "increment":
draft.count++;
break;
case "decrement":
draft.count--;
break;
}
}
function Counter() {
const [state, dispatch] = useImmerReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</>
);
}
물론 이는 완전히 선택 사항이며, 이러한 문제를 겪고 있다면 해결법으로 사용해 볼 수 있겠습니다.
useState
를 사용하고 언제 useReducer
를 사용해야 하나요?useReducer
에 대한 저의 열정과 이를 사용할 수 있는 다양한 경우를 설명했지만, 성급하게 추상화하지 않겠습니다.
보통의 경우 useState
를 사용해도 괜찮습니다. 상태와 검증 조건들이 복잡해지기 시작하며 추가적인 노력이 들어가기 시작한다고 느껴지면 그때 점진적으로 useReducer
를 고려해도 좋습니다.
그 후, 복잡한 객체들에 useReducer
를 사용하기 시작하고 상태 변경에 따른 위험에 자주 직면할 때 Immer
의 사용을 고려해 볼 수 있습니다.
혹은 상태 관리가 복잡해진 시점에 도달했다면 Mobx, Zustand, XState와 같은 훨씬 더 확장하기 쉬운 솔루션을 검토해보는 것이 좋습니다.
언제나 잊지 마세요. 단순하게 시작하고 필요한 경우에만 복잡성을 추가하세요.
DooFlix might just be the perfect app for you. Available as a free Android video streaming app, dooflix apk brings endless Hindi entertainment right to your fingertips.
너무 좋은 글이네요! state가 많아지면 관리가 어려워졌었는데 많은 도움이 됐습니다. :D