useReducer로 useState 지옥 벗어나기
const [state, dispatch] = useReducer(reducer, initialQrg, init);
useState 지옥에서 벗어나기
기존 코드
아래는 달력 이벤트를 업데이트하는 컴포넌트 예시입니다.
import { useState } from "react";
function EditCalendatEvent() {
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)} />
{/* ... */}
</>
);
}
useState 지옥에 빠졌을 때 문제점
- 한눈에 파악하기 어렵다.
- 세이프가드가 없다.
- 종료 날짜를 시작 날짜 이전으로 선택하는 모순을 막을 방법이 없다.
- 제목이나 설명이 너무 긴 경우에 대한 세이프가드도 없다.
useState를 대체할 상태관리 = useReducer를 써보자
useReducer를 사용한 코드
import {useReducer} from "react";
function EditCalendarEvenr () {
const [event, undateEvent] = useReducer(
(prev, next) => {
return { ...prev, ...next };
},
{ title: "", description: "", attendees: [] }
);
return (
<>
<input
value={event.title}
onChange={(e)=> updateEvent({ title: e.target.value })}
/>
{ /*...*/ }
</>)
);
}
위의 코드를 useState
로 비슷하게 한다면 아래와 같은 코드를 사용할 수 있지만 아래와 같은 포맷은 항상 ...event
로 전개연산자를 사용하여 직접 변경하지 않도록 해야한다는 단점이 있다.
또한 useReducer
의 중요한 이점인 상태 변환을 제어할 수 있는 함수를 추가할 수 있다는 점 또한 놓치게 된다.
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 })} />
{/* ... */}
</>
);
}
useReducer
의 유일한 차이점은 각 상태의 변화가 안전하고 유효할 것을 보장하는 함수를 추가로 인자에 전달하여 상태를 한 곳에서 관리하며 항상 보장한다는 장점을 가진다.
const [event, updateEvent] = useReducer(
(prev, next) => {
// 이벤트를 검증하고 변환하여 상태가 항상 유효할 것을 한 곳에서 관리하며 보장합니다
// ...
},
{ title: "", description: "", attendees: [] }
);
후에 다른 코드들이 추가 되더라도, 팀의 다른 팀원이 updateEvent()
를 유효하지 않은 데이터와 함께 호출하더라도 상태 값을 검증하는 콜백이 실행될 것이다.
첫 useState
코드의 문제였던 세이프가드를 useReducer
로 작성해보자.
import { useReducer } from "react";
function EditCalendatEvent() {
const [event, updateEvent] = useReducer(
(prev, next) => {
const newEvent = { ...prev, ...next };
// 시작 날짜가 종료 날짜 이후가 될 수 없음을 보장합니다.
if (newEvent.startDate > newEvent.endDate) {
newEvent.title = newEvnet.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>;
}
위 코드를 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스러운 것들
useReducer
에 대한 문서나 글들을 보면 대부분이 이 방법으로 useReducer 훅을 사용하는 방법으로 소개하고 있다. 하지만 이 방법은 useReducer 훅을 사용할 수 있는 다양한 방법 중 하나일 뿐이다.
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" })} />
{/*...*/}
</>
);
}
Redux와 위와 같은 패턴의 장점이 있지만 action에 대한 새로운 추상화를 레이어링하기 시작한다면 Mobx, Zustand, XState와 같은 라이브러리를 추천하고 싶다.
그래도 추가 종속성 없이 이 패턴을 활용할 때 더 우아하기 때문에 Redux와 같은 형식을 좋아하는 사람들을 위해 제안한다.
Reducer 공유하기
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, state)) {
case "incremented_age": {
// ✅ Correct : 새로운 객체를 생성합니다.
return {
...state,
age: state.age + 1,
};
}
case "changed_name": {
// ✅ Correct: 새로운 객체를 생성합니다.
return {
...state,
name: action.nextName,
};
}
// ...
}
}
만약에 이런 문제를 자주 만난다면 라이브러리로부터 도움을 받을 수도 있습니다.
(선택 사항) 흔히 빠질 수 있는 함정 해결법: Immer
Immer는 우아하고 가변적인 DX(digital transformation)를 가지고 있으면서도 데이터의 불변을 보장하는 매우 훌륭한 라이브러리이다.
use-immer패키지는 추가로 useImmerReducer
함수를 제공하는데 이 함수를 사용하면 직접적인 변경을 통한 상태 변경이 가능하다. 라이브러리 내부적으로 자바스크립트 [[new 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와 같은 훨씬 더 확장하기 쉬운 솔루션을 검토해보는 것이 좋다.
항상 잊지말아야할 것은 단순하게 시작하고 필요한 경우에만 복잡성을 추가하는 것이다.
☑️ 참고 페이지
'JavaScript. > React' 카테고리의 다른 글
JSX 유의사항 (0) | 2024.08.09 |
---|---|
Styled-components 태그에 속성 추가하는 법 (0) | 2024.07.10 |
🎯 Trouble Shooting 컴포넌트 리턴 후 발생하는 렌더링 에러 (0) | 2024.07.01 |