최근 Next 16(React 19) 환경에서 다음과 같은 에러를 처음 맞닥뜨렸다.
Error: Calling setState synchronously within an effect can trigger cascading renders
해당 에러가 발생한 코드는 다음과 같다.
컴포넌트의 prop이 바뀌면 상태 값이 변경되도록, useEffect의 의존성 배열에 prop을 넣고, 콜백 함수 안에서 setState를 사용하고 있었다.
function DynamicVirtualList<T>({
items,
estimatedItemHeight = 80,
}: DynamicVirtualListProps<T>) {
const [itemHeights, setItemHeights] = useState<number[]>(() =>
Array(items.length).fill(estimatedItemHeight)
);
useEffect(() => {
setItemHeights((prev) => {
// ...
});
}, [items.length, estimatedItemHeight]);
}
React 19부터 React는 effect 안에서 동기적인 상태 변경을 지양하도록 경고하기 시작했다.
그 이유는 useEffect 안에서 setState를 호출하면 바로 다시 리렌더링을 발생시키게 되기 때문이다.
최악의 경우 다음과 같은 무한 루프가 발생할 수 있다.
1. 컴포넌트 렌더
2. useEffect 실행
3. setState 호출 → 즉시 리렌더
4. useEffect 실행
5. 다시 setState...
이때 해결책으로 사용할 수 있는 것이 useEffectEvent라는 훅이다.
다음과 같이 setState를 하는 부분을 분리하면 에러는 더 이상 발생하지 않는다.
const updateItemHeights = useEffectEvent((estimatedItemHeight: number) => {
setItemHeights((prev) => {
// ...
});
});
useEffect(() => {
updateItemHeights(estimatedItemHeight);
}, [items.length, estimatedItemHeight]);
useEffectEvent는 상태를 변경시키는 로직을 Effect Event로 분리하여, 이벤트 핸들러처럼 안전한 흐름으로 동작하도록 만들어 주는 역할을 하게 된다.
다만 이건 React가 원하는 방법은 아니다. 리렌더링을 방지할 수 없기 때문에 임시 처리 방법 밖에 되지 않는다.
실제론 useMemo를 사용하여, 현재 변경하고자 하는 heights가 렌더링 중에 계산되도록 하여 해결했다.
결국 최종 코드에 반영되진 않았지만 useEffectEvent를 처음 사용해보게 되면서, 해당 훅이 실제 어떤 목적으로 사용되는지 알아보게 되었다.
useEffectEvent는 Effect에 의해 반응적(reactive)으로 다시 실행될 필요가 없는 비반응형 로직을 분리할 수 있도록 한다.
예제를 살펴보자.
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('연결됨!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]);
// ...
useEffect 콜백 함수 내부를 보면, connected라는 이벤트가 발생하면 알림을 표시(showNotification)을 하도록 되어있다. 그리고 알림 메시지에는 theme이라는 prop 값이 들어가도록 되어 있다.
theme은 변경될 수 있는 반응형 값이기 때문에, 알림 메시지에 theme의 최신 값이 반영되기 위해서는 의존성 배열에 포함시켜야 한다. 하지만 의도와 다르게, theme 값이 변경되면 채팅이 끊어졌다가 다시 연결되는 엉뚱한 결과가 발생하게 된다.
이 경우 어떻게 해결할 수 있을까?
theme 값이 바뀌면 ref에 값을 저장하는 방식으로 해결할 수도 있었을 것이다.
하지만 지금은 useEffectEvent를 사용하면 쉽게 해결할 수 있다.
Effect의 의존성과 직접적으로 연결되진 않지만 최신 값을 항상 바라봐야하는 비반응형 로직은 Effect Event로 분리한다.
예제에서는 theme 값을 참조해 알림을 표시하는 로직을 다음과 같이 분리해줄 수 있다.
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨!', theme);
});
전체 코드를 보게 되면, 더 이상 연결 관련 로직이 theme에 의존하지 않게 된다.
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
// ...
이제 Effect 내부에서는 onConnected를 이벤트 핸들러처럼 호출할 수 있고, connection의 연결과 끊어짐과 직결된 roomId로만 의존성 배열이 구성될 수 있다.
useEffectEvent의 특징은 다음과 같다.
콜백함수는 항상 최신 prop, state에 접근할 수 있다.
클로저로 인해 함수가 선언된 시점의 오래된(stale) 값에 접근하게 되는 상황을 방지할 수 있다.
리턴 함수는 렌더링될 때마다 재생성되지 않는다.
Effect의 의존성 배열에 해당 함수를 포함시킬 필요가 없다.
리턴 함수는 Effect 내부에서만 사용할 수 있다.
즉, useEffect, useLayoutEffect, useInsertionEffect 훅 내부에서만 사용할 수 있다.
에러로 발생시키면서까지 렌더링과 사이트 이펙트의 역할을 명확히 분리하고자 하는 React의 발전 방향이 흥미로웠다.
공식 문서를 읽어보며 Effect의 의미를 다시 명확히 짚어보게 된 것 같다.
컴포넌트에 Effect를 무작정 추가하지 마세요. Effect는 주로 React 코드를 벗어난 특정 외부 시스템과 동기화하기 위해 사용됩니다. 이는 브라우저 API, 서드 파티 위젯, 네트워크 등을 포함합니다. 만약 당신의 Effect가 단순히 다른 상태에 기반하여 일부 상태를 조정하는 경우에는 Effect가 필요하지 않을 수 있습니다.
외부와의 동기화가 필요한 상황이 아니라면, 정말 필요한가라는 생각을 의식적으로 하면서 Effect를 작성하는 습관을 들여야겠다. 최대한 렌더링 흐름 안에서 해결하는 방향을 생각해보자.