eslint-plugin-react-hooks의 set-state-in-effect 규칙이 존재하는 근본적인 이유를 렌더링 흐름 분석과 React Compiler 관점에서 알아봅니다. 불필요한 재렌더링을 제거하고, 예측 가능한 렌더링을 작성하는 방법을 정리합니다.
eslint-plugin-react-hooks가 업데이트되면서 React Compiler 기반의 린팅 규칙이 새롭게 추가되었습니다.
그 중에서도 많은 분들이 가장 적응하기 어려워하는 규칙이 하나 있는데요,
바로 set-state-in-effect 규칙입니다.
리액트를 오래 사용해 본 분들이라면 전혀 문제가 없어 보인다고 생각하실 겁니다.
마운트 시 localStorage에서 저장된 테마를 불러오는, 아주 흔한 패턴이죠.
그런데 이 코드에서 ESLint 에러가 발생합니다.
Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems
such as manually updating the DOM, state management libraries, or other
platform APIs. In general, the body of an effect should do one or both of
the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a
callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders
that can hurt performance, and is not recommended.
(https://react.dev/learn/you-might-not-need-an-effect).
이것이 바로 새로운 리액트 코드 패러다임의 변화라고 볼 수 있습니다.
이 글에서 다룰 내용에러 메시지의 핵심은 이 문장입니다.
"Effect 내부에서 setState를 동기적으로 호출하면 연쇄적인 렌더링(cascading renders)을 유발할 수 있다."맞는 말입니다. 만약 아까 코드에서 의존성 배열에 theme이 들어가 있다면 어떨까요?
theme이 변경될 때마다 Effect가 다시 실행되고, setTheme이 호출되면서 또 theme이 변경되고... 이때는 에러 메시지에 나온 것처럼 연쇄적인 렌더링을 유발할 수 있는 코드가 됩니다.
의존성 배열이 비어 있으면 Effect 내부의 함수는 컴포넌트가 마운트 될 때 단 한 번만 실행됩니다.
연쇄적인 렌더링을 유발하지 않는 코드인데도 에러가 발생합니다.
왜일까요?
이유는 렌더링 흐름을 보면 이해할 수 있습니다.
Effect 내부에서 setState가 동기적으로 호출된 코드가 화면에 렌더링 될 때는 다음과 같은 5가지 단계를 거칩니다.
theme = "light")setTheme("dark")이 호출됩니다.theme = "dark")
문제는 여기서 3, 4, 5번 과정이 리액트 입장에서는 애초에 필요하지 않았던 추가 렌더링 과정이라는 점입니다.
이것을 좀 더 단순한 예시로 살펴볼게요.
서버에서 받아온 데이터를 필터링해서 보여주는 컴포넌트가 있다고 가정해 봅시다.
이 코드는 items가 변경될 때마다 Effect에서 필터링 후 상태를 업데이트하고 있습니다.
하지만 필터링은 렌더 과정에서 바로 계산할 수 있는 값입니다.
상태도 필요 없고, Effect도 필요 없습니다. 렌더링 중에 직접 계산하면 됩니다.
1, 2단계 과정만으로 동일한 결과를 렌더링할 수 있게 되는 것이죠.
💡 React가 말하고 싶은 것
"렌더는 계산이고, Effect는 외부 시스템과의 동기화를 위해서만 사용해라."렌더 과정에서 계산할 수 있는 값은 Effect 없이 직접 계산하고,
Effect는 DOM 조작이나 외부 API 구독 같은 진짜 부수 효과에만 쓰라는 것입니다.
이러한 규칙은 리액트 컴파일러가 도입되면서 더 중요해졌습니다.
set-state-in-effect는 원래 React Compiler 내부의 린팅 규칙이었지만, 현재는 eslint-plugin-react-hooks@latest에 통합되어 컴파일러를 설치하지 않아도 이 규칙이 동작합니다.
리액트 컴파일러는 컴포넌트가 Rules of React를 정확히 준수한다고 가정하고 자동 최적화를 수행합니다.
💡 Rules of React란?
컴포넌트와 Hook이 순수하게 동작하고, 부수 효과가 렌더링과 분리되어야 한다는 React의 핵심 규칙입니다.
이 규칙을 따르면 코드의 패턴 이해가 쉬워지고 고품질의 애플리케이션을 만들 수 있게 됩니다.
렌더 과정이 예측 가능해야 리액트 컴파일러가 안전하게 최적화를 적용할 수 있습니다.
Effect 안에서 setState를 호출하면 렌더링 결과가 Effect 실행 여부에 따라 달라지기 때문에, 컴파일러 입장에서 최적화하기 어려운 코드가 됩니다.
따라서 set-state-in-effect와 같은 규칙은 피해가도 되는 규칙이 아니라 앞으로의 리액트 코드에서 반드시 지켜야 할 기준에 가깝습니다.
앞서 본 것처럼 props나 다른 상태에서 계산할 수 있는 값이라면 Effect와 상태 없이 렌더 중에 직접 계산하면 됩니다.
비용이 큰 계산이라면 useMemo를 활용하면 됩니다.
SSR 환경에서 "클라이언트에서만 렌더링"하는 패턴이 필요할 때가 있습니다.
이럴 때는 useSyncExternalStore를 사용하면 됩니다.
useSyncExternalStore의 세 번째 인자는 서버에서 반환할 값입니다.
서버에서는 false, 클라이언트에서는 true를 반환하기 때문에 Effect 없이 마운트 여부를 판단할 수 있습니다.
그렇다면 프롤로그에서 봤던 테마 예제도 같은 방식으로 해결할 수 있습니다.
서버에서는 "light"를 반환하고, 클라이언트에서는 localStorage에서 읽어옵니다.
하이드레이션 불일치 없이, Effect 없이, 에러 없이 동일한 결과를 얻을 수 있습니다.
⚠️ 모든 Effect 내부 setState가 나쁜 것은 아닙니다
외부 시스템의 변경에 반응하여 setState를 호출하는 것은 Effect의 올바른 사용법입니다.
예를 들어 WebSocket 메시지 수신, 브라우저 이벤트 구독 등에서 콜백 내의 setState는 문제가 되지 않습니다.
이 규칙이 경고하는 것은 Effect body에서 동기적으로 setState를 호출하는 패턴입니다.
결국 set-state-in-effect 규칙은 우리를 괴롭히기 위한 규칙이 아닙니다.
이것이 리액트가 이 규칙을 통해 강제하는 철학이며, React Compiler 시대를 준비하는 코드 기준이기도 합니다.
끝까지 읽어주셔서 감사합니다.
import { useEffect, useState } from "react";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("light");
useEffect(() => {
const saved = localStorage.getItem("theme");
if (saved) setTheme(saved);
}, []);
return <div data-theme={theme}>{children}</div>;
}
useEffect(() => {
const saved = localStorage.getItem("theme");
if (saved) setTheme(saved);
}, [theme]); // ⚠️ theme이 변경될 때마다 다시 실행
useEffect(() => {
const saved = localStorage.getItem("theme");
if (saved) setTheme(saved);
}, []); // 마운트 시 단 한 번만 실행
function FilteredList({ items }: { items: Item[] }) {
const [filtered, setFiltered] = useState<Item[]>([]);
useEffect(() => {
setFiltered(items.filter((item) => item.isActive));
}, [items]);
return (
<ul>
{filtered.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
function FilteredList({ items }: { items: Item[] }) {
const filtered = items.filter((item) => item.isActive);
return (
<ul>
{filtered.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
// ❌ Effect에서 파생 상태 설정
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ 렌더 중에 직접 계산
const fullName = `${firstName} ${lastName}`;
const filtered = useMemo(() => {
return items.filter((item) => item.isActive);
}, [items]);
// ❌ useEffect로 마운트 감지 — set-state-in-effect 에러
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <>{children}</>;
}
// ✅ useSyncExternalStore로 마운트 감지
import { useSyncExternalStore } from "react";
function ClientOnly({ children }: { children: React.ReactNode }) {
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
if (!mounted) return null;
return <>{children}</>;
}
// ✅ useSyncExternalStore로 테마 불러오기
import { useSyncExternalStore } from "react";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useSyncExternalStore(
() => () => {},
() => localStorage.getItem("theme") ?? "light",
() => "light"
);
return <div data-theme={theme}>{children}</div>;
}