리액트의 함정
리액트는 어렵다.
문이 너무 많아서, 어느 문을 열어야할 지 모르겠다.
잘못된 방향으로 문을 열고 들어가면, 예측이 안되는 코드를 작성하게 된다.
그런데 이보다 더 큰 문제는 잘못된 방향의 문으로 들어갔는데, 바로 발견하지 못하는 경우다.
경험했던 바로는, 훗날 에러가 발생해서 하나씩 디버깅하며 추적해보면 결국 발견하게 되는 대표적인 문제점들은 useState
와 useEffect
였다.
오늘은 Tkdodo의 블로그 useState pitfalls 글을 보면서 인사이트를 기록해두려고 한다.
useState
간과하기 쉬운 부분인데,
사용자가 제공하는 새 값이 현재 상태의 값과 동일하면 React는 리렌더링을 건너뛴다.
아래 예시는 아무리 버튼을 눌러도 리렌더링 되지 않는다.
초기 initialValue와 버튼을 눌렀을 때 제공하는 값이 동일하기 때문이다.
const App = () => {
const [value, setValue] = useState("geuni");
// 아무리 클릭해도, 컴포넌트는 리렌더링 되지 않는다.
return <button onClick={()=> setValue("geuni")}>{value}</button>;
};
Tkdodo blog, Things to know about useState
React 공식문서, useState
useEffect
useEffect는 동기화를 위해 사용되는 경우가 많다.
하지만 단순 동기화가 아닌, 외부의 무언가와 동기화시키기 위해 사용되어야한다.
여기서 말하는 외부의 무언가는 브라우저 API, 서드파티 라이브러리, 네트워크 등이다.
useEffect를 이용해서 두 개 이상의 state를 동기화 시키는데 이용하는 것은 대체로 옳지 않다.
A상태의 변경에 의해 B상태를 초기화해야하는 경우가 있다.
대표적인 두 상태를 동기화 시키는 예시이며 공식문서에선 이런 경우 useEffect를 사용하지 않는 것을 권장한다.
// App.tsx
export default function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={()=> setIsOpen(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
{/* ✅ 1. key를 props로 내려서 isOpen이 변경되면 리렌더링 */}
<ActiveList key={isOpen.toString()} isOpen={isOpen} />
</div>
);
}
// ActiveList.tsx
export const ActiveList = ({ isOpen }: { isOpen: boolean }) => {
const [activeTab, setActiveTab] = useState(0);
// ...
// ✅ 2. 렌더링 중에 조건부로 state 리셋
if (isOpen === false && activeTab !== 0) {
setActiveTab(0);
}
// ❌ useEffect로 초기화
useEffect(() => {
if (isOpen === false) {
setActiveTab(0);
}
}, [isOpen]);
return (
<div>
<ul
style={{
visibility: isOpen ? "visible" : "hidden",
}}
>
{LIST.map((item) => (
<li key={item.id} onClick={()=> onClick(item.id)}>
{item.name}
</li>
))}
</ul>
</div>
);
};
추가로 state에 변경에 따라 ActiveList를 렌더링 시키는 방법도 있다.
key를 사용하는 것과 유사하다.
{
isOpen && <ActiveList key={isOpen.toString()} isOpen={isOpen} />;
}
Tkdodo blog, Putting props to useState
Tkdodo blog, No-useless-state
React 공식문서, You might not need an Effect
useReducer
toggle 상태 관리
React를 사용하다보면 자주 boolean 타입의 상태를 만들어서 사용하게 된다.
대표적인 예가 아래의 토글버튼
이다.
보통은 useState를 사용해서 만들곤 했는데, 아래와 같이 useReducer를 통해서 만들 수 있다.
// not bad
const [value, setValue] = React.useState(true);
<Button onClick={()=> setValue((prev)=> !prev)}>Toggle</Button>;
// better
const [value, setValue] = React.useReducer((prev) => !prev, true);
<Button onClick={setValue}>Toggle</Button>;
toss에서 만든 react-simplikit의 useToggle 역시 useReducer로 구현되어있었다.
reducer 확장
// useReducer로 구현
const reducer = (amount: number) => (state: number, action: string) => {
switch (action) {
case "increment":
return state + amount;
case "decrement":
return state - amount;
default:
return state;
}
};
export const useCounterReducer = () => {
const { data, isLoading } = useQuery({
queryKey: ["amount"],
queryFn: fetchAmount,
});
const currentAmount = data ?? 1;
const [count, dispatch] = useReducer(reducer(currentAmount), 0);
return {
count,
dispatch,
isLoading,
amount: currentAmount,
};
};
useReducer는 확장에 유연하다.
만약 증가와 감소 이외에 다른 액션이 필요하다면, reducer
함수를 확장해서 사용할 수 있다.
// useState로 구현
export const useCounterState = () => {
const { data, isLoading } = useQuery({
queryKey: ["amount"],
queryFn: fetchAmount,
});
const currentAmount = data ?? 1;
const [count, setCount] = useState(0);
const handleIncrement = () => {
setCount((prev) => prev + currentAmount);
};
const handleDecrement = () => {
setCount((prev) => prev - currentAmount);
};
// ... 추가 로직
return {
count,
handleIncrement,
handleDecrement,
isLoading,
amount: currentAmount,
};
};
하지만 useState로 구현하면 다음과 같이 함수를 모두 생성해야한다.
또한 reducer를 사용하면, reducer 내부에서 서버 데이터에 접근할 수 있다.
// useReducer로 구현
const reducer = (amount: number) => (state: number, action: string) => {
// ✅ 서버에서 전달받은 amount를 사용할 수 있다.
return state;
};
// useState로 구현
const amount = serverAmount ?? 1;
// ❌ state는 서버에서 전달받은 amount를 state '내부에서' 핸들링하기 어렵다.
// 만약 필요하다면 '외부에서' 서버데이터를 핸들링 한 뒤 넘겨줘야한다.
const currentAmount = data ?? 1;
const [count, setCount] = useState(0);
Tkdodo blog, passing props to reducers