React에서 불필요한 useState와 useEffect 줄이기
React로 개발하다 보면 useState()를 정말 많이 쓰게 됩니다.
값이 바뀌어야 하니까 state로 만들고, 그 state가 바뀔 때 뭔가 해야 하니까 useEffect()를 붙이고…
이러다 보면 컴포넌트에 state와 effect가 잔뜩 쌓이면서 코드가 복잡해지고 디버깅도 어려워지죠.
사실 이런 코드의 상당 부분은 state나 effect 없이도 작성할 수 있습니다.
이번 글에서는 React 개발자가 빠지기 쉬운 useState와 useEffect의 흔한 오용 패턴과 이를 개선하는 방법을 알아보겠습니다.
파생 값을 state로 관리하는 실수
가장 흔한 실수는 다른 state나 props에서 계산할 수 있는 값을 별도의 state로 관리하는 것입니다.
예를 들어 상품 목록에서 검색 기능을 만든다고 해볼게요.
function ProductList({ products }) {
const [query, setQuery] = useState("");
const [filteredProducts, setFilteredProducts] = useState(products);
useEffect(() => {
setFilteredProducts(products.filter((p) => p.name.includes(query)));
}, [products, query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{filteredProducts.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</>
);
}
filteredProducts는 products와 query에서 바로 계산할 수 있는 값인데 굳이 state로 만들었습니다.
그래서 query가 바뀔 때마다 useEffect로 filteredProducts를 동기화해야 하고요.
이 코드의 문제는 렌더링이 두 번 일어난다는 겁니다.
query가 바뀌면 먼저 query state 변경으로 한 번 렌더링되고, useEffect 안에서 setFilteredProducts가 호출되면서 또 한 번 렌더링됩니다.
이런 값은 그냥 렌더링 중에 계산하면 됩니다.
function ProductList({ products }) {
const [query, setQuery] = useState("");
const filteredProducts = products.filter((p) => p.name.includes(query));
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{filteredProducts.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</>
);
}
filteredProducts를 state 대신 일반 변수로 선언했습니다.
query나 products가 바뀌면 컴포넌트가 리렌더링되면서 filteredProducts도 자연스럽게 다시 계산됩니다.
state 하나와 effect 하나가 사라졌고 렌더링도 한 번으로 줄었어요.
props를 state에 복사하는 실수
props로 받은 값을 state에 그대로 복사하는 것도 흔한 실수입니다.
function Profile({ user }) {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
useEffect(() => {
setName(user.name);
setEmail(user.email);
}, [user]);
return (
<div>
<p>{name}</p>
<p>{email}</p>
</div>
);
}
user prop이 바뀌면 useEffect로 state를 동기화하고 있는데요.
단순히 보여주기만 하는 값이라면 props를 직접 쓰면 됩니다.
function Profile({ user }) {
return (
<div>
<p>{user.name}</p>
<p>{user.email}</p>
</div>
);
}
props를 state에 복사해야 하는 경우는 “초기값”으로만 쓸 때입니다. 폼에서 수정 가능한 필드의 초기값을 props에서 받는 경우가 대표적이에요.
function EditProfile({ user }) {
const [name, setName] = useState(user.name);
// name은 사용자가 수정할 수 있는 독립적인 state
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
이 경우에도 useEffect로 동기화하지 않습니다.
user prop이 바뀔 때 폼도 리셋하고 싶다면 key prop을 활용하세요.
<EditProfile key={user.id} user={user} />
key가 바뀌면 React가 컴포넌트를 아예 새로 마운트하기 때문에 state가 자연스럽게 초기화됩니다.
이벤트 처리를 useEffect로 하는 실수
버튼 클릭이나 폼 제출 같은 이벤트에 반응해야 할 때 useEffect를 쓰는 경우도 많은데요.
이것도 흔한 오용 패턴입니다.
function CartPage({ items }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);
const handleCheckout = () => {
// total state를 사용해서 결제 처리
submitOrder(total);
};
return <button onClick={handleCheckout}>결제하기 ({total}원)</button>;
}
total은 items에서 계산 가능한 파생 값이고, handleCheckout은 버튼 클릭 이벤트에서 호출됩니다.
state와 effect를 모두 제거할 수 있어요.
function CartPage({ items }) {
const total = items.reduce((sum, item) => sum + item.price, 0);
const handleCheckout = () => {
submitOrder(total);
};
return <button onClick={handleCheckout}>결제하기 ({total}원)</button>;
}
원칙은 간단합니다. “언제” 실행되는지가 명확한 코드는 이벤트 핸들러에, “무엇이 바뀔 때” 실행되는 코드만 effect에 넣으세요.
useEffect가 정말 필요한 경우는 외부 시스템과 동기화할 때입니다.
DOM을 직접 조작하거나, 타이머를 설정하거나, WebSocket 연결을 관리하는 것처럼 React 바깥의 무언가와 맞춰야 할 때요.
연쇄적인 state 업데이트
여러 state를 연쇄적으로 업데이트하는 것도 문제를 일으킵니다.
function ShippingForm() {
const [country, setCountry] = useState("KR");
const [city, setCity] = useState("Seoul");
const [zipCode, setZipCode] = useState("");
useEffect(() => {
// 나라가 바뀌면 도시 초기화
setCity(getDefaultCity(country));
}, [country]);
useEffect(() => {
// 도시가 바뀌면 우편번호 초기화
setZipCode("");
}, [city]);
// ...
}
country가 바뀌면 렌더링이 세 번 일어납니다.
country 변경 → 렌더링 → city 변경 → 렌더링 → zipCode 변경 → 렌더링.
이벤트 핸들러에서 한 번에 처리하면 렌더링 한 번으로 끝납니다.
function ShippingForm() {
const [country, setCountry] = useState("KR");
const [city, setCity] = useState("Seoul");
const [zipCode, setZipCode] = useState("");
const handleCountryChange = (newCountry) => {
setCountry(newCountry);
setCity(getDefaultCity(newCountry));
setZipCode("");
};
// ...
}
React는 이벤트 핸들러 안의 state 업데이트를 자동으로 배칭(batching)합니다.
setCountry, setCity, setZipCode가 모두 한 번의 렌더링으로 처리돼요.
비싼 계산에는 useMemo
렌더링 중에 계산하라고 했는데 그 계산이 느리면 어떡하죠?
function ProductList({ products, query }) {
// products가 10만 개라면?
const filtered = products.filter((p) => p.name.includes(query));
// ...
}
이럴 때 useMemo를 쓰면 됩니다.
function ProductList({ products, query }) {
const filtered = useMemo(
() => products.filter((p) => p.name.includes(query)),
[products, query],
);
// ...
}
useMemo는 의존성 배열([products, query])이 바뀔 때만 계산을 다시 수행합니다.
products와 query가 그대로라면 이전 결과를 재사용해요.
다만 useMemo를 모든 계산에 붙일 필요는 없습니다.
배열이 수천 개 이상이거나, 반복 계산이 체감될 정도로 느릴 때만 사용하세요.
대부분의 계산은 그냥 렌더링 중에 해도 충분히 빠릅니다.
정리: state와 effect가 정말 필요한가?
코드를 작성하기 전에 이렇게 자문해보세요.
props나 다른 state에서 계산할 수 있는 값인가요? 그렇다면 state 없이 렌더링 중에 계산하세요.
버튼 클릭이나 폼 제출처럼 특정 시점에 일어나는 동작인가요?
그렇다면 useEffect 대신 이벤트 핸들러를 쓰세요.
외부 시스템(DOM, 타이머, 네트워크)과 동기화해야 하나요?
그럴 때만 useEffect를 쓰세요.
마치며
useState와 useEffect는 React의 강력한 도구지만 꼭 필요한 곳에서만 써야 합니다.
파생 값을 state로 관리하면 동기화 버그가 생기고, 이벤트 처리를 effect로 하면 불필요한 렌더링이 발생합니다.
“이 값을 state로 만들어야 하나?”라는 질문을 습관적으로 던져보세요. 생각보다 많은 경우에 state 없이도 원하는 동작을 구현할 수 있습니다. 컴포넌트가 단순해지면 읽기도 쉬워지고 버그도 줄어들어요.
더 자세한 내용은 You Might Not Need an Effect와 Choosing the State Structure를 참고하세요.
This work is licensed under
CC BY 4.0