<chan9yu />
홈포스트시리즈태그About


@chan9yu's dev blog

프론트엔드 개발의 아이디어와 경험을 기록하는 개발 블로그
코드와 디자인, 사용자 경험을 아우르는 인사이트를 담습니다.

RSSGitHubEmail
© 2026 chan9yu. All rights reserved.

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

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

2026년 3월 15일
 
ReactESLintuseEffectReact Compiler

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

Claude Code Agent Teams로 AI 뉴스봇 만들기

이런 글도 읽어보세요

리액트를 까본 사람 손 🙋 (Virtual DOM부터 Fiber까지)

리액트를 까본 사람 손 🙋 (Virtual DOM부터 Fiber까지)

2025년 11월 9일

React의 내부 동작 원리를 체계적으로 분석합니다. JSX에서 JavaScript로의 변환 과정, Virtual DOM의 작동 원리, Reconciliation 알고리즘, 그리고 Fiber Architecture의 핵심 개념을 다룹니다.

ReactVirtual DOM+3
1분

댓글

목차

  • 프롤로그
  • set-state-in-effect 규칙이란?
  • 에러 메시지가 말하는 것
  • 그런데 빈 배열이면?
  • 렌더링 흐름으로 이해하기
  • Effect 내부 setState의 렌더링 5단계
  • 애초에 필요 없는 렌더링이었다
  • React Compiler와 Rules of React
  • 이 규칙이 더 중요해진 이유
  • 예측 가능한 렌더링이 전제 조건
  • 상황별 해결 방법
  • 1. 파생 상태는 렌더 중에 계산하기
  • 2. 마운트 감지가 필요할 때 — useSyncExternalStore
  • 마무리
  • 참고자료

프롤로그

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).  

이것이 바로 새로운 리액트 코드 패러다임의 변화라고 볼 수 있습니다.

이 글에서 다룰 내용
  1. set-state-in-effect 규칙 — 이 규칙이 말하는 것
  2. 렌더링 흐름 분석 — 빈 의존성 배열에서도 에러가 나는 근본적 이유
  3. React Compiler — 이 규칙이 더 중요해진 배경
  4. 실전 대응 — 상황별 해결 방법

set-state-in-effect 규칙이란?

에러 메시지가 말하는 것

에러 메시지의 핵심은 이 문장입니다.

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

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

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

그런데 빈 배열이면?

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

왜일까요?


렌더링 흐름으로 이해하기

Effect 내부 setState의 렌더링 5단계

이유는 렌더링 흐름을 보면 이해할 수 있습니다.
Effect 내부에서 setState가 동기적으로 호출된 코드가 화면에 렌더링 될 때는 다음과 같은 5가지 단계를 거칩니다.

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

렌더링 흐름 다이어그램

문제는 여기서 3, 4, 5번 과정이 리액트 입장에서는 애초에 필요하지 않았던 추가 렌더링 과정이라는 점입니다.

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

이것을 좀 더 단순한 예시로 살펴볼게요.

서버에서 받아온 데이터를 필터링해서 보여주는 컴포넌트가 있다고 가정해 봅시다.

이 코드는 items가 변경될 때마다 Effect에서 필터링 후 상태를 업데이트하고 있습니다.
하지만 필터링은 렌더 과정에서 바로 계산할 수 있는 값입니다.

상태도 필요 없고, Effect도 필요 없습니다. 렌더링 중에 직접 계산하면 됩니다.
1, 2단계 과정만으로 동일한 결과를 렌더링할 수 있게 되는 것이죠.

💡 React가 말하고 싶은 것

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

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


React Compiler와 Rules of React

이 규칙이 더 중요해진 이유

이러한 규칙은 리액트 컴파일러가 도입되면서 더 중요해졌습니다.

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와 같은 규칙은 피해가도 되는 규칙이 아니라 앞으로의 리액트 코드에서 반드시 지켜야 할 기준에 가깝습니다.


상황별 해결 방법

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

앞서 본 것처럼 props나 다른 상태에서 계산할 수 있는 값이라면 Effect와 상태 없이 렌더 중에 직접 계산하면 됩니다.

비용이 큰 계산이라면 useMemo를 활용하면 됩니다.

2. 마운트 감지가 필요할 때 — useSyncExternalStore

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

이럴 때는 useSyncExternalStore를 사용하면 됩니다.

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

그렇다면 프롤로그에서 봤던 테마 예제도 같은 방식으로 해결할 수 있습니다.

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

⚠️ 모든 Effect 내부 setState가 나쁜 것은 아닙니다

외부 시스템의 변경에 반응하여 setState를 호출하는 것은 Effect의 올바른 사용법입니다.
예를 들어 WebSocket 메시지 수신, 브라우저 이벤트 구독 등에서 콜백 내의 setState는 문제가 되지 않습니다.
이 규칙이 경고하는 것은 Effect body에서 동기적으로 setState를 호출하는 패턴입니다.


마무리

결국 set-state-in-effect 규칙은 우리를 괴롭히기 위한 규칙이 아닙니다.

"렌더는 순수하게, Effect는 외부 시스템과의 동기화 용도로만 사용하라"

이것이 리액트가 이 규칙을 통해 강제하는 철학이며, React Compiler 시대를 준비하는 코드 기준이기도 합니다.

참고자료

  • set-state-in-effect — React 공식 문서
  • Effect가 필요하지 않은 경우 — React 공식 문서
  • React 컴파일러 — React 공식 문서
  • React의 규칙 — React 공식 문서

끝까지 읽어주셔서 감사합니다.

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