학습 자료

useEffect — 화면을 그린 뒤에 할 일


Article

13강에서 렌더 함수는 순수해야 한다고 했다. 바깥 세상을 건드리면 안 된다.

그런데 실제 앱은 바깥 세상과 대화해야 한다. 서버에서 데이터를 받고, 타이머를 걸고, 브라우저 이벤트를 듣는다.

그 일을 화면을 다 그린 뒤에 하는 곳이 useEffect다.

바깥 세상이란

React의 세계는 좁다. state를 받아 화면을 반환하는 것까지다. 그 밖은 전부 바깥이다.

React 가 모르는 것들
  • 바깥 세상side effect — 부수 효과
    • 서버 통신fetch
    • 타이머setInterval · setTimeout
    • 브라우저 APIlocalStorage · document.title
    • 직접 DOM 조작스크롤 · 포커스
    • 외부 라이브러리차트 · 지도

이런 것들을 렌더 중에 하면 안 된다. React가 함수를 몇 번 실행할지 모르기 때문이다. 13강의 StrictMode가 두 번 부르는 것만 봐도 그렇다.

기본 형태

import { useEffect } from "react";

function Title({ count }) {
  useEffect(() => {
    document.title = `${count}개`;
  });

  return <p>{count}</p>;
}

첫 인자는 렌더링이 끝난 뒤 실행할 함수다.

언제 실행되나
렌더
함수 실행 → JSX
커밋
DOM 수정
화면
사용자가 본다
useEffect
이제 실행
렌더 시작화면 표시effect 실행
화면이 보인 뒤에 실행되므로 effect 안에서 setState 하면 화면이 두 번 그려진다

화면이 사용자에게 보인 뒤에 실행된다. 그래서 무거운 일을 해도 화면이 늦게 뜨지 않는다.

의존성 배열 — 언제 다시 실행할지

두 번째 인자로 배열을 준다. 이 배열이 전부를 결정한다.

형태언제 실행되나쓰는 곳
useEffect(fn)매 렌더링마다거의 안 쓴다
useEffect(fn, [])첫 렌더링에 한 번초기 설정 · 이벤트 등록
useEffect(fn, [a, b])a 나 b 가 바뀔 때대부분의 경우
useEffect(() => {
  document.title = `${count}개`;
}, [count]);

count가 바뀔 때만 실행된다. name이 바뀌어 리렌더링돼도 count가 그대로면 건너뛴다.

비교 방법은 12강에서 본 **Object.is**다. 객체를 넣으면 매 렌더링마다 새 주소라서 매번 실행된다.

빈 배열이 만드는 것

useEffect(() => {
  console.log("한 번만");
}, []);

의존하는 값이 없으니 다시 실행할 이유도 없다. 첫 렌더링에서 딱 한 번 실행된다.

StrictMode 개발 환경에서는 두 번 실행된다. 13강에서 본 그 검사다. 정리 함수를 제대로 썼는지 확인하는 것이다.

정리 함수 — 뒷정리

effect가 함수를 반환하면, React는 다음 effect를 실행하기 전과 컴포넌트가 사라질 때 그 함수를 부른다.

useEffect(() => {
  const id = setInterval(() => {
    console.log("1초마다");
  }, 1000);

  return () => clearInterval(id);
}, []);

return () => clearInterval(id)가 정리 함수다. 없으면 어떻게 될까.

정리하지 않으면
  1. 01

    컴포넌트가 화면에서 사라진다

    다른 페이지로 이동했다.

  2. 02

    타이머는 계속 돈다

    React 는 setInterval 을 모른다. 멈추라고 시킨 적이 없다.

  3. 03

    메모리가 새고 에러가 난다

    없어진 컴포넌트의 state 를 바꾸려 들면 경고가 뜬다. 페이지를 오갈수록 타이머가 쌓인다.

자바스크립트 편 14강에서 "클로저가 메모리를 붙잡는다"고 했던 이야기다. 등록한 것은 반드시 해제한다.

등록정리
setIntervalclearInterval
setTimeoutclearTimeout
addEventListenerremoveEventListener
구독 (subscribe)구독 해제 (unsubscribe)
fetchAbortController 로 취소

이벤트 리스너를 예로 들면 이렇다.

useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

데이터 가져오기

가장 흔한 쓰임이다. 자바스크립트 편 20강의 async/await를 쓴다.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    let ignore = false;

    async function load() {
      setIsLoading(true);
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();

      if (!ignore) {
        setUser(data);
        setIsLoading(false);
      }
    }

    load();
    return () => {
      ignore = true;
    };
  }, [userId]);

  if (isLoading) return <Spinner />;
  return <h2>{user.name}</h2>;
}

두 가지를 짚는다.

ignore 플래그가 두 번째 포인트다. userId가 빠르게 바뀌면 요청 두 개가 겹치고, 먼저 보낸 요청이 나중에 도착할 수 있다. 그러면 옛 데이터가 화면에 남는다. 정리 함수에서 ignore = true로 표시해 낡은 응답을 무시한다.

이걸 **경쟁 상태(race condition)**라 부른다.

안 써도 되는 경우

useEffect를 남용하는 게 초보자의 가장 흔한 실수다.

effect 가 필요한가

필요하다

바깥 세상과 동기화

  • 서버에서 데이터 받기
  • 타이머 · 구독 등록
  • document.title 변경
  • 외부 라이브러리 연결

필요 없다

React 안에서 끝나는 일

  • props 로 state 계산하기
  • 이벤트에 반응하기
  • 다른 state 로 값 만들기
  • 렌더링 직전 데이터 가공

가장 흔한 잘못은 이것이다.

function Cart({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((acc, i) => acc + i.price, 0));
  }, [items]);
}

렌더링 → effect 실행 → setState → 또 렌더링. 화면을 두 번 그린다. 8강에서 말한 그대로, 계산할 수 있는 값은 그냥 계산한다.

function Cart({ items }) {
  const total = items.reduce((acc, i) => acc + i.price, 0);
  return <p>{total}원</p>;
}

이벤트에 반응하는 것도 effect가 아니다.

useEffect(() => {
  if (isSubmitted) sendAnalytics();
}, [isSubmitted]);

function handleSubmit() {
  sendAnalytics();
}

버튼을 눌러서 일어난 일이면 핸들러에 쓴다. effect는 "화면에 나타났기 때문에" 해야 하는 일에 쓴다.

무한 루프

가장 자주 만나는 사고다.

useEffect(() => {
  setCount(count + 1);
}, [count]);

effect가 count를 바꾸고, count가 바뀌어 effect가 또 돈다. 끝이 없다.

의존성 배열을 빠뜨려도 같다.

useEffect(() => {
  setUser({ name: "지원" });
});

매 렌더링마다 실행되고, setUser가 새 객체를 넣어 또 렌더링한다. 12강에서 본 주소 비교 때문에 내용이 같아도 새 객체다.

정리하면

  • useEffect화면을 그린 뒤에 바깥 세상과 대화하는 곳이다.
  • 두 번째 인자인 의존성 배열이 실행 시점을 정한다. []는 한 번, [a]a가 바뀔 때.
  • 의존성 비교는 Object.is다. 객체와 함수를 넣으면 매번 실행된다.
  • 등록한 것은 정리 함수로 해제한다. 타이머, 리스너, 구독 전부.
  • effect 함수에 async를 붙이지 않는다. 안에 async 함수를 만들어 부른다.
  • 데이터를 받아올 때는 경쟁 상태를 막는다. 정리 함수에서 낡은 응답을 무시한다.
  • 계산할 수 있는 값과 이벤트 반응에는 effect를 쓰지 않는다.

다음 강의는 훅의 규칙이다. 왜 if 문 안에서 훅을 부르면 안 되는지, 그 이유가 놀랍도록 단순하다.

useEffect — 화면을 그린 뒤에 할 일 — 디코드랩(DCODELAB)