참고 너무 꼼꼼하게 읽으시면 안됨 내용이 좀 쉬울 수 있어서 (생각보다 쉬웟는데 다른거 완전 찾기엔 늦어서 ㅎ..) 꼼꼼하게 다 읽으면 제 발표 시간이 무의미 할 수 잇음..!!! 허허
최근 프로젝트에서 검색 기능을 구현하던 중, 콘솔창에 ESLint 경고가 하나 떴습니다.
“Warning: Calling setState synchronously within an effect can trigger cascading renders.”
단순한 경고로 생각하고 넘기고자 했지만, Cascading(폭포수)라는 단어가 보였습니다. 이 단어는 제가 작성한 코드가 불필요한 렌더링을 연쇄적으로 일으키고 있다는 걸 말해주는 것 같았습니다.
오늘은 이 경고의 원인인 폭포수 렌더링이 무엇인지, 그리고 리액트 공식 문서가 왜 useEffect를 최대한 걷어내라고 말하는지 알아보려고 합니다.
작성중이던 코드는 제가 검색과 관련된 작업에서 많이 사용하던 패턴이었습니다.
아래와 같이 특정 input 입력 값을 useState 로 관리하며, query 의 변경이 이루어질 때마다 useEffect 내부의 debouncedSearch 라는 API 호출 로직을 실행시키고자 했습니다.
const [query, setQuery] = useState('');const [results, setResults] = useState([]);const [isLoading, setIsLoading] = useState(false);useEffect(() => {if (!query.trim()) {setIsLoading(false);setResults([]);return;}setIsLoading(true);debouncedSearch(query, searchType);}, [query, searchType]);return (...<inputtype="text"placeholder="이름을 입력하세요"value={query}onChange={(e) => setQuery(e.target.value)}/>)
하지만, 이 코드에는 eslint가 경고하는 문제 지점이 있습니다.
바로, useEffect 내부에서 작동하는 setIsLoading 입니다.
리액트는 알다시피 useState 등의 상태값을 기반으로 재렌더링이 일어납니다.
그렇다면 위 코드의 로직은
사용자의 입력 → onChange의 setQuery를 통한 query 변경↓query 변경에 따른
effect는 렌더 → 커밋 → 페인트 이후 실행됩니다.
즉, effect 안에서 즉시 state를 변경하면 React는 useEffect 내부 작업이 끝난 후 전체 렌더 사이클을 다시 시작해야 합니다.
이게 바로 Cascading Render 가 일어날 수 있다는 경고의 원인입니다
단순히 두 번 렌더링 된다는 것은 또 다른 아래와 같은 부작용을 불러일으킬 수 있습니다.
이렇듯 한 번의 렌더링으로 얻을 수 있는 결과를 한 번 더 실행시키게 됩니다.
이러한 문제들을 발생시킬 수 있어 리액트는 “실제로는 Effect 가 필요하지 않을 수 있다.” 고 말하고 있습니다.
그렇다면 리액트에서 말하는 잘못 사용되고 있는 useEffect 예시는 뭐일지,
useEffect 없이 어떻게 리액트를 잘 활용할지를 한 번 살펴보겠습니다
function Component({ data }) {const [items, setItems] = useState([]);useEffect(() => {setItems(data)
→ 아무 의미 없는 렌더 한 번 추가
functionComponent({ data }) {const items = data;}
useEffect(() => {setLoading(true);fetchData().then(() =>setLoading(false));}, []);
렌더 → commit → paint 다시 발생 : 불필요한 중간 상태 렌더링
useEffect(() => {setProcessed(rawData.map(transform));}, [rawData]);
const processed = rawData.map(transform);
function SearchList({ items, query }) {const [filteredItems, setFilteredItems] = useState([]);// 불필요한 연쇄 렌더링useEffect(() => {
function SearchList({ items, query }) {// 별도의 State와 Effect 없이 직접 계산const filteredItems = items.filter(item => item.name.includes(query));return (<
리액트에서 Effect는 리액트가 관리하지 않는 외부 시스템과 컴포넌트를 연결할 때만 써야 합니다.
functionTooltip() {const ref = useRef(null);const [tooltipHeight, setTooltipHeight] = useState(0);useLayoutEffect(() => {
→ 실제 DOM이 화면에 존재해야만 측정 가능
→ 렌더 이후(커밋 이후)에만 얻을 수 있는 값
이런 경우가 리액트가 권장하는 effect의 사용 목적 → 외부 시스템(DOM, 브라우저 API) 과 동기화 하는 사례!
여기서 추가로 위 예시는 공식문서에 있는 내용을 그대로 가져온 건데,
→ 높이를 측정하고 state를 바꿔도 사용자는 중간 상태(깜빡임)를 보지 않음
→ 그럼 항상 useLayoutEffect 가 좋을까?
useLayoutEffect는 paint를 막기 때문에(브라우저가 화면 그리는 걸 멈춰놓은 상태)
무거운 연산, 네트워크 요청, 반복적인 setState 는 하면 안됨
기존 props나 state로부터 “계산할 수 있는 값”이라면 state로 저장하지 말고, 렌더링 중에 계산해야 한다.반대로, DOM 측정처럼 “렌더 이후에만 알 수 있는 값”은 effect + setState가 정당하다
아래 내용들은 실제 프로젝트에서 Effect를 제거하거나 최소화할 수 있는 대표적인 패턴을 가져와봤습니다
검색 예제에서 setIsLoading 보다 중요한 문제는 응답 순서 보장(Race Condition) 입니다
useEffect(() => {let ignore = false;async function fetchResults() {const data = await searchMedia(query);if (!
Effect는 실행이 아니라 외부 시스템과의 동기화다. 그러니 사용 시 항상 이 외부 작업은 언제 무효가 되는가?를 함께 생각해야 함
특정 prop이 바뀔 때 모든 상태를 초기화해야 하는 상황이 있습니다.
export default function ProfilePage({ userId }) {return (<ProfileuserId={userId}key={userId}/>);}
→ Effect 없이 상태 리셋 완료
function Profile({ userId }) {// 이 state 및 아래의 다른 state는 key 변경 시 자동으로 재설정됨const [prevItems, setPrevItems] = useState(userId); //동균// ..if (userId !== prevItems) {
가장 처음에 본 검색 예시를 단계별로 개선해 보겠습니다.
useEffect(() => {let ignore = false;async function fetchResults() {const data = await searchMedia(query);if (!ignore
컴포넌트 안에 지저분한 useEffect를 최대한 없애는 것입니다.
로직을 분리하면 컴포넌트는 무엇을 할지만에 대해 역할이 명확해집니다.
// 커스텀 훅function useSearch(query) {const [data, setData] = useState(null);const [isLoading, setIsLoading] = useState(false);
사실 위에서 만든 useSearch의 기능을 완벽하게 구현해 둔 것이 바로 TanStack Query(React Query)나 SWR입니다.
// 라이브러리를 사용하면 Effect와 State 관리 필요가 없음const { data, isLoading } = useQuery({queryKey: ['search', query],queryFn: () => fetchSearch(query)});