본문 바로가기

useEffect 안에서 setState 하지 마세요, React가 말하는 이유

eslint-plugin-react-hooks의 set-state-in-effect 규칙이 존재하는 근본적인 이유를 렌더링 흐름 분석과 React Compiler 관점에서 알아봅니다. 불필요한 재렌더링을 제거하고, 예측 가능한 렌더링을 작성하는 방법을 정리합니다.

읽는 시간 8
useEffect 안에서 setState 하지 마세요, React가 말하는 이유

eslint-plugin-react-hooks가 업데이트되면서 React Compiler 기반의 린트 규칙이 새로 들어왔습니다.

그중에서 유독 적응하기 어려운 규칙이 하나 있는데, set-state-in-effect예요.

테마를 다루는 컴포넌트를 예로 들어볼게요.

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>;
}

React를 오래 써 봤다면 전혀 문제가 없어 보일 거예요. 마운트 때 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).

이 에러는 React가 권하는 코드 기준이 바뀌었다는 신호예요.

이 글은 이 규칙이 무엇을 문제 삼는지, 빈 의존성 배열에서도 에러가 나는 이유, React Compiler가 들어오며 이 규칙이 중요해진 배경, 상황별 해결 방법을 다뤄요.

set-state-in-effect가 문제 삼는 것

에러 메시지가 말하는 것

에러 메시지의 핵심은 이 문장이에요.

"Effect 내부에서 setState를 동기적으로 호출하면 연쇄적인 렌더링(cascading renders)을 유발할 수 있다."

맞는 말입니다. 만약 아까 코드에서 의존성 배열에 theme이 들어가 있다면 어떨까요?

useEffect(() => {
	const saved = localStorage.getItem("theme");
	if (saved) setTheme(saved);
}, [theme]); // theme이 변경될 때마다 다시 실행

theme이 변경될 때마다 Effect가 다시 실행되고, setTheme이 호출되면서 또 theme이 변경되고... 이때는 에러 메시지에 나온 것처럼 연쇄적인 렌더링을 유발할 수 있는 코드가 됩니다.

그런데 빈 배열이어도 에러가 나요

의존성 배열을 비운 처음 코드로 돌아가 볼게요.

useEffect(() => {
	const saved = localStorage.getItem("theme");
	if (saved) setTheme(saved);
}, []); // 마운트 시 단 한 번만 실행

의존성 배열이 비어 있으면 Effect 안의 함수는 컴포넌트가 마운트될 때 한 번만 실행돼요. 연쇄적인 렌더링을 유발하지 않는 코드인데도 에러가 발생해요.

왜일까요?

렌더링 흐름으로 이해하기

Effect 내부 setState의 렌더링 5단계

이유는 렌더링 흐름을 보면 보여요. Effect 안에서 setState를 동기로 부르는 코드는 화면에 그려질 때 다섯 단계를 거칩니다.

  1. 컴포넌트가 렌더링됩니다. (theme = "light")
  2. DOM이 업데이트됩니다.
  3. Effect가 실행되고 setTheme("dark")이 호출됩니다.
  4. 상태가 변경되었기 때문에 컴포넌트가 다시 렌더링됩니다. (theme = "dark")
  5. DOM이 또 한 번 업데이트됩니다.

문제는 Effect 실행부터 다시 그리기까지의 뒤쪽 세 단계가 React 입장에서는 애초에 필요 없었던 추가 렌더링이라는 점이에요.

애초에 필요 없는 렌더링이었어요

더 단순한 예시가 있어요. 서버에서 받아온 데이터를 필터링해서 보여주는 컴포넌트입니다.

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>
	);
}

이 코드는 items가 바뀔 때마다 Effect에서 필터링해 상태를 업데이트해요. 그런데 필터링은 렌더 과정에서 바로 계산할 수 있는 값입니다.

상태와 Effect를 걷어내면 이렇게 돼요.

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도 필요 없습니다. 렌더링 중에 직접 계산하면 돼요. 처음 렌더링과 DOM 업데이트만으로 같은 결과가 나옵니다.

React가 말하고 싶은 것은 이 한 문장이에요.

"렌더는 계산이고, Effect는 외부 시스템과의 동기화를 위해서만 사용해라."

렌더 과정에서 계산할 수 있는 값은 Effect 없이 직접 계산하고, Effect는 DOM 조작이나 외부 API 구독 같은 진짜 부수 효과에만 쓰라는 것입니다.

React Compiler와 Rules of React

이 규칙이 더 중요해진 이유

이 규칙은 React Compiler (새 창에서 열림)가 들어오면서 더 중요해졌어요.

set-state-in-effect는 원래 React Compiler 내부의 린팅 규칙이었지만, 현재는 eslint-plugin-react-hooks@latest에 통합되어 컴파일러를 설치하지 않아도 이 규칙이 동작해요.

React Compiler는 컴포넌트가 Rules of React (새 창에서 열림)를 정확히 지킨다고 가정하고 자동으로 최적화해요.

Rules of React는 컴포넌트와 Hook이 순수하게 동작하고, 부수 효과가 렌더링과 분리되어야 한다는 React의 핵심 규칙이에요. 이 규칙을 따르면 코드의 패턴 이해가 쉬워지고 고품질의 애플리케이션을 만들 수 있습니다.

예측 가능해야 컴파일러가 최적화해요

렌더 과정이 예측 가능해야 React Compiler가 안전하게 최적화를 적용할 수 있어요.

불필요한 재렌더링을 제거하고 자동 메모이제이션 같은 최적화를 안전하게 적용하려면, 렌더링 결과가 동일한 입력에 대해 항상 동일한 출력을 보장해야 합니다.

Effect 안에서 setState를 호출하면 렌더링 결과가 Effect 실행 여부에 따라 달라져서 컴파일러가 최적화하기 어려운 코드가 돼요.

그래서 set-state-in-effect와 같은 규칙은 피해가도 되는 규칙이 아니라 앞으로의 React 코드에서 반드시 지켜야 할 기준에 가깝습니다.

Effect를 걷어내는 두 가지 방법

파생 상태는 렌더 중에 계산하기

props나 다른 상태에서 계산할 수 있는 값이라면 Effect와 상태 없이 렌더 중에 직접 계산하면 돼요.

// 나쁜 예: Effect에서 파생 상태 설정
const [fullName, setFullName] = useState("");
useEffect(() => {
	setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// 좋은 예: 렌더 중에 직접 계산
const fullName = `${firstName} ${lastName}`;

상태 선언과 Effect가 사라지고 한 줄이 남았어요.

비용이 큰 계산이라면 useMemo를 쓰면 돼요.

const filtered = useMemo(() => {
	return items.filter((item) => item.isActive);
}, [items]);

필터링을 useMemo로 감싸고 의존성 배열에 items를 넣은 모양이에요.

마운트 감지는 useSyncExternalStore로 해요

SSR 환경에서 "클라이언트에서만 렌더링"하는 패턴이 필요할 때가 있어요.

// 나쁜 예: 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}</>;
}

마운트 여부를 상태로 들고 Effect에서 켜는 구조라 이 규칙에 걸려요.

이럴 때는 useSyncExternalStore를 쓰면 돼요.

// 좋은 예: useSyncExternalStore로 마운트 감지
import { useSyncExternalStore } from "react";

function ClientOnly({ children }: { children: React.ReactNode }) {
	const mounted = useSyncExternalStore(
		() => () => {},
		() => true,
		() => false
	);

	if (!mounted) return null;
	return <>{children}</>;
}

useSyncExternalStore의 세 번째 인자는 서버에서 반환할 값이에요. 서버에서는 false, 클라이언트에서는 true를 반환하기 때문에 Effect 없이 마운트 여부를 판단할 수 있습니다.

그렇다면 맨 처음 봤던 테마 예제도 같은 방식으로 해결할 수 있어요.

// 좋은 예: 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>;
}

서버에서는 "light"를 반환하고, 클라이언트에서는 localStorage에서 읽어옵니다. 하이드레이션 불일치 없이, Effect 없이, 에러 없이 동일한 결과를 얻을 수 있어요.

Effect 안의 setState가 전부 나쁜 것은 아니에요. 외부 시스템의 변경에 반응해서 setState를 호출하는 것은 Effect의 올바른 사용법입니다. WebSocket 메시지 수신이나 브라우저 이벤트 구독처럼 콜백 안에서 부르는 setState는 문제가 되지 않습니다. 이 규칙이 경고하는 것은 Effect body에서 동기적으로 setState를 호출하는 패턴입니다.

렌더는 계산이고 Effect는 동기화예요

set-state-in-effect는 우리를 괴롭히려는 규칙이 아니에요. 렌더는 순수하게 두고 Effect는 외부 시스템과의 동기화에만 쓰라는 React의 방향을 코드에 강제하는 규칙입니다. React Compiler가 기본이 되는 시대에는 이 방향을 따르는 코드만 최적화의 혜택을 받아요.

참고자료

댓글

스크롤하면 댓글이 로드됩니다.