Escape Hatches

Chaerin Kim·2023년 11월 23일

1. Referencing Values with Refs

컴포넌트가 특정 정보를 '기억'하도록 하고 싶지만 해당 정보가 새 렌더링을 트리거하지 않도록 하려는 경우 ref 사용

Adding a ref to your component

React에서 useRef Hook을 import하여 컴포넌트에 ref를 추가할 수 있음. 유일한 인수로 참조하려는 초기 값을 전달.

import { useRef } from 'react';
const ref = useRef(0);

useRef는 다음과 같은 객체를 반환

{ 
  current: 0 // The value you passed to useRef
}

ref.current 속성을 통해 현재 값에 액세스할 수 있음. 이 값은 읽기와 쓰기가 모두 가능. React가 추적하지 않는 컴포넌트의 비밀 주머니와 같음. (React의 단방향 데이터 흐름에서 ref가 "탈출구(escape hatch)"가 되는 이유)

버튼을 클릭할 때마다 ref.current 값이 증가하는 예시 코드:

import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('You clicked ' + ref.current + ' times!');
  }

  return (
    <button onClick={handleClick}>
      Click me!
    </button>
  );
}

ref는 state와 마찬가지로 숫자, 문자열, 객체, 함수 등 무엇이든 가리킬 수 있음. state와 달리 ref는 현재 속성을 읽고 수정할 수 있는 일반 JavaScript 객체임.

매 클릭마다 컴포넌트가 다시 렌더링되지 않는다는 점에 유의! state와 마찬가지로 ref는 리렌더링 사이에 React에 의해 유지됨. 하지만 state를 설정하면 컴포넌트가 다시 렌더링되는 반면, ref를 변경하면 그렇지 않음!

Example: building a stopwatch

한 컴포넌트 안에서 refs와 state를 결합할 수 있음. 사용자가 버튼을 눌러 시작하거나 중지할 수 있는 스톱워치를 만든다면, 사용자가 'Start'를 누른 후 얼마나 시간이 지났는지 표시하기위해 Start 버튼을 누른 시점과 현재 시간을 추적해야함. 이 정보는 렌더링에 사용되므로 state에 저장해야함:

const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);

사용자가 "Start"을 누르면 10ms마다 시간을 업데이트하기 위해 setInterval을 사용. 사용자가 "Stop" 버튼을 누르면 현재 state 변수의 업데이트를 중지하도록 기존 interveal을 취소해야함. 이 작업은 clearInterval을 호출하여 수행할 수 있지만, 사용자가 Start를 눌렀을 때 setInterval 호출에서 반환된 interval ID를 어딘가에 보관해야 함. interval ID는 렌더링에 사용되지 않으므로 ref에 저장해야함:

import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
      <button onClick={handleStop}>
        Stop
      </button>
    </>
  );
}

렌더링에 정보가 사용되는 경우 해당 정보를 state로 유지. 이벤트 핸들러만 정보를 필요로 하고 변경해도 다시 렌더링할 필요가 없는 경우, ref를 사용하는 것이 더 효율적일 수 있음.

Differences between refs and state

State setting function을 사용하지 않고도 변경할 수 있기 때문에 refs가 state보다 덜 '엄격'하다고 생각할 수 있음. 하지만 대부분의 경우 state를 사용하고 싶을 것. Refs는 자주 사용하지 않는 "탈출구(escape hatch)". 상태와 참조를 비교하는 방법은:

? You shouldn’t read (or write) the current value during rendering ?

State로 구현한된 카운터 버튼:

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      You clicked {count} times
    </button>
  );
}

카운트 값이 표시되므로 state 값을 사용하는 것이 합리적. 카운터 값이 setCount()로 설정되면 React는 컴포넌트를 다시 렌더링하고 화면이 새로운 카운트를 반영하도록 업데이트함.

이것을 ref로 구현한다면, React는 컴포넌트를 다시 렌더링하지 않으므로 카운트가 변경되는 것을 볼 수 없을 것!:

import { useRef } from 'react';

export default function Counter() {
  let countRef = useRef(0);

  function handleClick() {
    // This doesn't re-render the component!
    countRef.current = countRef.current + 1;
  }

  return (
    <button onClick={handleClick}>
      You clicked {countRef.current} times
    </button>
  );
}

이것이 렌더링 중에 ref.current를 읽으면 코드가 불안정해지는 이유! 필요하다면 대신 state를 사용할 것.

When to use refs

일반적으로 컴포넌트가 React "외부로 나가서" 외부 API, 즉 컴포넌트의 모양에 영향을 주지 않는 브라우저 API와 통신해야 할 때 ref를 사용함. 이러한 드문 상황의 예시:

  • timeout ID 저장
  • 다음 챕터에서 다룰 DOM 엘리먼트 저장 및 조작
  • JSX를 계산하는 데 필요하지 않은 다른 객체 저장

컴포넌트에 값을 저장해야 하지만 렌더링 로직에 영향을 미치지 않는 경우 ref를 사용.

Best practices for refs

다음 원칙을 따르면 컴포넌트의 예측 가능성을 높일 수 있음:

  • ref는 탈출구: ref는 외부 시스템이나 브라우저 API로 작업할 때 유용. 애플리케이션 로직과 데이터 흐름의 대부분이 ref에 의존하는 경우 접근 방식을 재고.
  • 렌더링 중에는 ref.current를 읽거나 쓰지 말 것: 렌더링 중에 일부 정보가 필요하다면 대신 state를 사용. React는 ref.current가 언제 변경되는지 모르기 때문에 렌더링 중에 읽어도 컴포넌트의 동작을 예측하기 어려움. (유일한 예외는 첫 번째 렌더링 중에 ref를 한 번만 설정하는 if (!ref.current) ref.current = new Thing()과 같은 코드)

React state의 제한은 ref에는 적용되지 않음. 예를 들어 state는 모든 렌더링에 대해 스냅샷처럼 작동하며 동기적으로 업데이트되지 않음. 하지만 ref의 현재 값을 변경하면 즉시 변경됨:

ref.current = 5;
console.log(ref.current); // 5

이는 ref 자체가 일반 자바스크립트 객체이므로 자바스크립트 객체처럼 동작하기 때문.

또한 ref로 작업할 때 mutation을 피하는 것에 대해 걱정할 필요가 없음. Mutating 하려는 객체가 렌더링에 사용되지 않는 한, React는 ref나 그 콘텐츠로 무엇을 하든 상관하지 않음.

Refs and the DOM

Ref는 모든 값을 가리킬 수 있음. 그러나 ref의 가장 일반적인 사용 사례는 DOM 요소에 액세스하는 것. 예를 들어 프로그래밍 방식으로 입력에 초점을 맞추고자 할 때 유용. <div ref={myRef}>와 같이 JSX의 ref 어트리뷰트에 ref를 전달하면 React는 해당 DOM 엘리먼트를 myRef.current에 넣음. 엘리먼트가 DOM에서 제거되면 React는 myRef.current를 null로 업데이트함.

2. Manipulating the DOM with Refs

React는 렌더링 출력과 일치하도록 DOM을 자동으로 업데이트하므로 컴포넌트가 DOM을 자주 조작할 필요가 없음. 하지만 때로는 노드에 포커스를 맞추거나 스크롤하거나 크기와 위치를 측정하기 위해 React가 관리하는 DOM 요소에 접근해야 할 수도 있음. React에는 이러한 작업을 수행할 수 있는 내장된 방법이 없으므로 DOM 노드에 대한 ref가 필요.

Getting a ref to the node

React가 관리하는 DOM 노드에 접근하려면, 먼저, useRef Hook을 import:

import { useRef } from 'react';

다음으로, 컴포넌트 내부에서 ref를 선언:

const myRef = useRef(null);

마지막으로, DOM 노드를 가져오고자 하는 JSX 태그에 ref 속성을 ref로 전달:

<div ref={myRef}>

useRef Hook은 current라는 단일 속성을 가진 객체를 반환. 처음에 myRef.current는 null이 될 것. React가 이 <div>에 대한 DOM 노드를 생성할 때, React는 이 노드에 대한 참조를 myRef.current에 넣음. 그런 다음 이벤트 핸들러에서 이 DOM 노드에 접근하고 여기에 정의된 내장 브라우저 API를 사용할 수 있음.:

// 모든 브라우저 API를 사용할 수 있음
myRef.current.scrollIntoView();

Example: Focusing a text input

button을 클릭하면 input에 focus가 되는 예제:

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

이를 구현하려면:

  1. useRef Hook으로 inputRef를 선언.
  2. 이를 <input ref={inputRef}>처럼 전달. 즉, React가 이 <input>의 DOM 노드를 inputRef.current에 넣도록 지시.
  3. handleClick 함수에서 inputRef.current로부터 input DOM 노드를 읽고 inputRef.current.focus()로 이 노드에 focus()를 호출.
  4. handleClick 이벤트 핸들러를 onClick으로 <button>에 전달.

DOM 조작이 ref의 가장 일반적인 사용 사례이지만, useRef Hook은 타이머 ID와 같은 다른 것들을 React 외부에 저장하는 데 사용될 수 있음. state와 유사하게 ref는 렌더링 사이에 유지됨. Ref는 state 변수와 비슷하지만 설정할 때 재렌더링을 트리거하지 않음.

Example: Scrolling to an element

컴포넌트에는 하나 이상의 ref가 있을 수 있음.

세 개의 이미지로 구성된 캐러셀이 있고, 각 버튼을 클릭하면 브라우저의 scrollIntoView() 메서드를 호출하여 이미지를 중앙에 배치하는 예제:

import { useRef } from 'react';

export default function CatFriends() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Tom
        </button>
        <button onClick={handleScrollToSecondCat}>
          Maru
        </button>
        <button onClick={handleScrollToThirdCat}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              alt="Jellylorum"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}

Accessing another component’s DOM nodes

<input />과 같은 브라우저 엘리먼트를 출력하는 내장 컴포넌트에 ref를 넣으면 React는 해당 ref의 현재 프로퍼티를 해당 DOM 노드(예: 브라우저의 실제 <input />)로 설정.

하지만 <MyInput />과 같이 자체 컴포넌트에 ref를 넣으려고 하면 기본적으로 null이 반환됨.

다음은 이를 보여주는 예시. 버튼을 클릭해도 입력에 초점이 맞춰지지 않는 것을 확인할 수 있음:

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

문제를 알아차리는 데 도움이 되도록 React는 콘솔에 오류를 출력함:

기본적으로 React는 컴포넌트가 다른 컴포넌트(자식 컴포넌트 포함)의 DOM 노드에 접근하는 것을 허용하지 않기 때문에 이런 일이 발생함. 이것은 의도적인 것. Ref는 탈출구(escape hatch)이기 때문에 아껴서 사용해야함. 다른 컴포넌트의 DOM 노드를 수동으로 조작하면 코드가 훨씬 더 취약해짐.

대신, DOM 노드를 노출하려는 컴포넌트는 해당 동작을 opt in 해야함. 컴포넌트는 자신의 참조를 자식 중 하나에 '전달(forward)'하도록 지정할 수 있음. MyInput이 forwardRef API를 사용하는 방법은 다음과 같음:

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});
  1. <MyInput ref={inputRef} />는 React에게 해당 DOM 노드를 inputRef.current에 넣으라고 지시. 그러나 이를 선택할지는 MyInput 컴포넌트에 달려 있으며, 기본적으로 선택하지 않음.
  2. MyInput 컴포넌트는 forwardRef를 사용해 선언됨. 이렇게 하면 props 다음에 선언되는 두 번째 ref 인수로 위의 inputRef를 받음.
  3. MyInput 자체는 수신한 ref를 내부의 <input>에 전달.

이제 버튼을 클릭하면 입력에 초점이 맞춰짐:

import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

디자인 시스템에서 button, input 등과 같은 저수준 컴포넌트는 해당 레퍼런스를 DOM 노드로 전달(forwardd)하는 것이 일반적인 패턴. 반면에 form, list 또는 page section과 같은 상위 수준 컴포넌트는 일반적으로 DOM 구조에 대한 우발적인 종속성을 피하기 위해 해당 DOM 노드를 노출하지 않음.

When React attaches the refs

React에서는 모든 업데이트가 두 단계로 나뉨:

  1. 렌더링하는 동안, React는 컴포넌트를 호출하여 화면에 무엇이 표시되어야 하는지 파악.
  2. 커밋하는 동안, React는 DOM에 변경 사항을 적용.

일반적으로 당신은 렌더링하는 동안 ref에 접근하고 싶지 않을 것. 첫 번째 렌더링 동안에는 DOM 노드가 아직 생성되지 않았으므로 ref.current는 null. 그리고 업데이트를 렌더링하는 동안에는 DOM 노드가 아직 업데이트되지 않았기 때문에 이를 읽기에는 너무 이름.

React는 커밋하는 동안 ref.current를 설정. DOM을 업데이트하기 전에 React는 영향을 받는 ref.current 값을 null로 설정. DOM을 업데이트한 후 React는 즉시 해당 DOM 노드에 해당 값을 설정.

보통 이벤트 핸들러에서 ref에 접근함. ref로 무언가를 하고 싶지만 그 작업을 수행할 특정 이벤트가 없는 경우 Effect가 필요할 수 있음.

Best practices for DOM manipulation with refs

Ref는 탈출구(escape hatch)! "React 외부로 나가야" 할 때만 사용해야 함. 일반적인 예로는 포커스, 스크롤 위치를 관리하거나 React가 노출하지 않는 브라우저 API를 호출하는 상황이 있음.

포커스와 스크롤과 같은 비파괴적인 동작을 고수한다면 문제가 발생하지 않을 것. 하지만 DOM을 수동으로 수정하려고 하면 React가 수행하는 변경 사항과 충돌할 위험이 있음.

이 문제를 설명하기 위해 다음 예시에는 환영 메시지와 두 개의 버튼이 포함되어 있음. 첫 번째 버튼은 React에서 일반적으로 하는 것처럼 조건부 렌더링과 상태를 사용해 환영 메시지 존재 여부를 전환함. 두 번째 버튼은 remove() DOM API를 사용해 환영 메시지를 DOM에서 강제로 제거함.

import { useState, useRef } from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        Toggle with setState
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        Remove from the DOM
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

"Toggle with setState"를 몇 번 누르면 메시지가 사라졌다가 나타나기를 반복. 그런 다음 "Remove from the DOM"를 누르면 강제로 제거됨. 마지막으로 "Toggle with setState"을 누르면?:

DOM 엘리먼트를 수동으로 제거한 후 setState를 사용하여 다시 표시하려고 하면 충돌이 발생함. 이는 사용자가 DOM을 변경했고 React가 이를 계속 올바르게 관리하는 방법을 모르기 때문.

React가 관리하는 DOM 노드를 변경하지 말 것! React가 관리하는 엘리먼트를 수정하거나, 자식을 추가하거나, 자식을 제거하면 위와 같이 시각적 결과가 일관되지 않거나 충돌이 발생할 수 있음.

하지만 그렇다고 해서 전혀 할 수 없다는 의미는 아님. 주의가 필요. React가 업데이트할 이유가 없는 DOM의 부분은 안전하게 수정할 수 있음. 예를 들어, JSX에서 일부 <div>가 항상 비어있는 경우 React는 그 자식 목록을 건드릴 이유가 없음. 따라서 수동으로 요소를 추가하거나 제거해도 안전함.

3. Synchronizing with Effects

일부 컴포넌트는 외부 시스템과 동기화해야함. 예를 들어, React state에 따라 비 React 컴포넌트를 제어하거나, 서버 연결을 설정하거나, 컴포넌트가 화면에 나타날 때 분석 로그를 전송하고 싶을 수 있음. Effects를 사용하면 렌더링 후 일부 코드를 실행하여 컴포넌트를 React 외부의 시스템과 동기화할 수 있음.

What are Effects and how are they different from events?

Effects에 들어가기 전에 React 컴포넌트 내부에 있는 두 가지 유형의 로직에 익숙해져야함:

  • 렌더링 코드(Describing the UI에서 소개)는 컴포넌트의 최상위 레벨에 존재. 여기에서 props와 state를 가져와서 변형하고 화면에 표시할 JSX를 반환함. 렌더링 코드는 순수해야함. 수학 공식처럼 결과만 계산하고 다른 작업을 수행하지 않아야함.

  • 이벤트 핸들러(Adding Interactivity에서 소개)는 컴포넌트 내부에 중첩된 함수로, 계산만 하는 것이 아니라 다른 작업을 수행함. 이벤트 핸들러는 입력 필드를 업데이트하거나, 제품 구매를 위한 HTTP POST 요청을 제출하거나, 사용자를 다른 화면으로 안내할 수 있음. 이벤트 핸들러에는 특정 사용자 작업(예: 버튼 클릭 또는 입력)으로 인해 발생하는 'side effect'(프로그램의 상태를 변경)가 포함됨.

때로는 이것만으로는 충분하지 않음. 화면에 표시될 때마다 채팅 서버에 연결해야 하는 ChatRoom 컴포넌트를 생각해보면, 서버에 연결하는 것은 순수한 계산이 아니므로(side effect) 렌더링 중에 발생할 수 없음. 그러나 ChatRoom을 표시하는 클릭과 같은 특정 이벤트도 없음.

이펙트를 사용하면 특정 이벤트가 아닌 렌더링 자체로 인해 발생하는 부작용을 지정할 수 있음. 채팅에서 메시지를 보내는 것은 사용자가 특정 버튼을 클릭함으로써 직접 발생하므로 이벤트에 해당함. 그러나 서버 연결 설정은 컴포넌트 표시의 원인이 되는 상호작용에 관계없이 발생해야 하므로 Effect임. Effect는 화면이 업데이트된 후 커밋이 끝날 때 실행됨. 이때는 React 컴포넌트를 외부 시스템(예: 네트워크 또는 서드파티 라이브러리)과 동기화하기에 좋은 시기임.

You might not need an Effect

컴포넌트에 Effect를 추가하는 것을 서두르지 말 것! Effect는 일반적으로 React 코드에서 벗어나 외부 시스템과 동기화하는 데 사용된다는 점을 명심해야함. 여기에는 브라우저 API, 서드파티 위젯, 네트워크 등이 포함됨. 만약 Effect가 다른 state를 기반으로 일부 state만 조정한다면 Effect가 필요하지 않을 수 있음.

How to write an Effect

Effect를 작성하는 세 단계:

  1. Effect 선언: 기본적으로 Effect는 렌더링할 때마다 실행됨.
  2. Effect 종속성 지정: 대부분의 Effect는 매 렌더링 후가 아니라 필요할 때만 다시 실행해야함. 예를 들어 fade-in 애니메이션은 컴포넌트가 나타날 때만 트리거되어야 함. 대화방 연결 및 연결 해제는 컴포넌트가 나타났다가 사라질 때 또는 대화방이 변경될 때만 발생해야 함. 종속성을 지정하여 이를 제어하는 방법을 배우게 될 것.
  3. 필요한 경우 cleanup 추가: 일부 Effect는 수행 중이던 작업을 중지, 실행 취소 또는 cleanup 하는 방법을 지정해야 함. 예를 들어, "연결"에는 "연결 끊기", "구독"에는 "구독 취소", "가져오기"에는 "취소" 또는 "무시"가 필요함. cleanup 함수를 반환하여 이를 수행하는 방법을 배우게 될 것.

Step 1: Declare an Effect

컴포넌트에서 Effect를 선언하려면 React에서 useEffect Hook을 import:

import { useEffect } from 'react';

그런 다음 컴포넌트의 최상위 수준에서 호출하고 Effect 안에 코드 추가:

function MyComponent() {
  useEffect(() => {
    // Code here will run after *every* render
  });
  return <div />;
}

컴포넌트가 렌더링될 때마다 React는 화면을 업데이트한 다음 useEffect 내부에서 코드를 실행함. 즉, useEffect는 해당 렌더링이 화면에 반영될 때까지 코드 실행을 '지연'시킴.

Effect를 사용하여 외부 시스템과 동기화하려면? 예를 들어, isPlaying 프로퍼티를 전달하여 재생 또는 일시정지 여부를 제어하는 <VideoPlayer> React 컴포넌트가 있음:

<VideoPlayer isPlaying={isPlaying} />;

사용자 지정 비디오 플레이어 컴포넌트는 기본 제공 브라우저 <video> 태그를 렌더링:

function VideoPlayer({ src, isPlaying }) {
  // TODO: do something with isPlaying
  return <video src={src} />;
}

하지만 브라우저 <video> 태그에는 isPlaying 프로퍼티가 없음. 이를 제어할 수 있는 유일한 방법은 DOM 요소에서 play() 및 pause() 메서드를 수동으로 호출하는 것. 동영상이 현재 재생 중인지 여부를 알려주는 isPlaying prop의 값을 play() 및 pause() 등의 호출과 동기화해야 함.

먼저 <video> DOM 노드에 대한 ref를 가져와야 함.

렌더링 중에 play() 또는 payse()를 호출하고 싶을 수 있지만 이는 올바르지 않음:

// ❌
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play();  // Calling these while rendering isn't allowed.
  } else {
    ref.current.pause(); // Also, this crashes.
  }

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

이 코드가 올바르지 않은 이유는 렌더링 중에 DOM 노드로 무언가를 하려고 하기 때문. React에서 렌더링은 JSX의 순수한 계산이어야 하며 DOM 수정과 같은 부작용을 포함하지 않아야 함.

게다가 VideoPlayer가 처음 호출될 때, 그 DOM은 아직 존재하지 않음! play() 또는 pause()를 호출할 DOM 노드가 아직 존재하지 않는데, React는 JSX를 반환할 때까지 어떤 DOM을 생성할지 모르기 때문.

해결책은 side effect를 useEffect로 래핑하여 렌더링 계산에서 제외하는 것:

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

DOM 업데이트를 Effect에 래핑하면 React가 먼저 화면을 업데이트한 후 Effect가 실행됨.

VideoPlayer 컴포넌트가 렌더링될 때(처음 렌더링되거나 다시 렌더링되는 경우) 몇 가지 일이 일어남. 먼저, React가 화면을 업데이트하여 <video> 태그가 올바른 props와 함께 DOM에 있는지 확인함. 그런 다음 React가 Effect를 실행함. 마지막으로 Effect는 isPlaying의 값에 따라 play() 또는 pause()를 호출함.

동영상 플레이어가 isPlaying 값과 동기화되는 예제:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

위 예제에서 React state에 동기화된 "외부 시스템"은 브라우저 미디어 API였음. 비슷한 접근 방식을 사용하여 레거시 비 React 코드(예: jQuery 플러그인)를 선언적 React 컴포넌트로 래핑할 수 있음.

동영상 플레이어를 제어하는 것은 실제로는 훨씬 더 복잡하다는 점에 유의할 것! play() 호출이 실패할 수도 있고, 사용자가 내장된 브라우저 컨트롤을 사용해 재생하거나 일시정지할 수도 있음. 이 예시는 매우 단순하고 불완전함.

Step 2: Specify the Effect dependencies

기본적으로 효과는 모든 렌더링 후에 실행됨. 하지만 종종 이를 원하지 않는 경우가 있음:

  • 속도가 느린 경우: 외부 시스템과의 동기화가 항상 즉각적인 것은 아니므로 꼭 필요한 경우가 아니라면 동기화를 건너뛰는 것이 좋음. 예를 들어, 키 입력 시마다 채팅 서버에 다시 연결하고 싶지 않을 수도 있음.
  • 잘못된 경우: 예를 들어 키 입력 시마다 컴포넌트 페이드인 애니메이션을 트리거하고 싶지 않을 수 있음. 애니메이션은 컴포넌트가 처음 나타날 때 한 번만 재생되어야 함.

이 문제를 설명하기 위한, 몇 개의 console.log 호출과 부모 컴포넌트의 상태를 업데이트하는 텍스트 입력이 포함된 예시. 입력하면 이펙트가 다시 실행되는 것을 확인할 수 있음:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

useEffect 호출의 두 번째 인수로 의존성 배열(dependency array)을 지정하여 React가 불필요하게 Effect를 다시 실행하는 것을 건너뛰도록 지시할 수 있음. 의존성 배열로 [isPlaying]을 지정하면 React가 isPlaying이 이전 렌더링 때와 동일한 경우 Effect를 다시 실행하지 않도록 건너뜀. 이렇게 변경하면 input에 값을 입력해도 Effect가 실행되지 않지만 재생/일시정지를 누르면 실행됨:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

의존성 배열은 여러 개의 의존성을 포함할 수 있음. React는 지정한 모든 종속성 값이 이전 렌더링에서 가졌던 값과 정확히 동일한 경우에만 Effect를 다시 실행하는 것을 건너뜀. React는 Object.is 비교를 사용하여 의존성 값을 비교함.

의존성을 "선택"할 수 없다는 점에 유의! 지정한 종속성이 React가 Effect 내부의 코드에 따라 예상하는 것과 일치하지 않으면 린트 오류가 발생함. 이는 코드에서 많은 버그를 잡는 데 도움이 됨. 일부 코드가 다시 실행되는 것을 원하지 않는다면 해당 종속성을 "필요"하지 않도록 Effect 코드 자체를 수정할 것. (참고: What to do when you don’t want to re-synchronize)

Step 3: Add cleanup if needed

connect() 및 disconnect() 메서드가 있는 객체를 반환하는 createConnection() API가 주어지고, 채팅 서버가 나타날 때 채팅 서버에 연결해야 하는 ChatRoom 컴포넌트를 구현해야 한다면? 컴포넌트가 사용자에게 표시되는 동안 연결 상태를 유지하는 방법?

// ❌
import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

Effect 내부의 코드는 프롭이나 state를 사용하지 않으므로 의존성 배열은 [](비어 있음)임. 이는 컴포넌트가 "mount"될 때, 즉 화면에 처음 나타날 때만 이 코드를 실행하도록 React에 지시함.

이 효과는 컴포넌트가 mount될 때만 실행되므로 콘솔에서 "✅ 연결 중..."이 한 번 출력될 것으로 예상할 수 있음. 그러나 콘솔을 확인하면 "✅ 연결 중..."이 두 번 출력됨.

ChatRoom 컴포넌트가 다양한 화면이 있는 큰 앱의 일부라면, 컴포넌트가 마운트되고 connection.connect()를 호출함. 그런 다음 사용자가 다른 화면(예: 설정 페이지)으로 이동하면 ChatRoom 컴포넌트가 마운트 해제됨. 마지막으로 사용자가 뒤로(Back)를 클릭하면 ChatRoom이 다시 마운트됨. 이렇게 하면 두 번째 연결이 설정되지만 첫 번째 연결은 파괴되지 않음! 사용자가 앱을 탐색할 때 연결은 계속 쌓이게 됨.

이와 같은 버그는 광범위한 수동 테스트 없이는 놓치기 쉬움. 버그를 빠르게 발견할 수 있도록 개발 환경에서 React는 모든 컴포넌트를 최초 마운트 직후에 한 번씩 다시 마운트함.

"✅ 연결 중..." 로그를 두 번 확인하면 컴포넌트가 마운트 해제될 때 코드가 연결을 닫지 않는 실제 문제를 파악하는 데 도움이 됨.

이 문제를 해결하기 위해서는 Effect에서 cleanup 함수를 반환해야함. React는 Effect가 다시 실행되기 전에 매번 cleanup 함수를 호출하고, 컴포넌트가 마운트 해제(제거)될 때 한 번 더 호출함:

// ✅
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

이제 콘솔 로그 3개가 생김:

  1. "✅ 연결 중..."
  2. "❌ 연결 끊김."
  3. "✅ 연결 중..."

이것은 올바른 동작! 컴포넌트를 다시 마운트함으로써 React는 멀리 이동했다가 다시 돌아와도 코드가 깨지지 않는지 확인함. 연결을 끊었다가 다시 연결하는 것은 정확히 일어나야 하는 일! Cleanup을 잘 구현하면 Effect를 한 번 실행하는 것과 실행하고 정리한 후 다시 실행하는 것 사이에 사용자가 볼 수 있는 차이가 없어야 함. React가 개발 환경에서 코드에 버그가 있는지 검사하기 때문에 연결/연결 해제 호출 쌍이 추가됨. 이것은 정상적인 현상이니 없애려고 하지 말 것!

프로덕션 환경에서는 "✅ 연결 중..."이 한 번만 출력됨. 컴포넌트를 다시 마운트하는 것은 cleanup이 필요한 이펙트를 찾는 데 도움을 주기 위해 개발 환경에서만 발생함. Strict Mode를 해제하여 개발 동작을 선택 해제할 수 있지만, 계속 켜두는 것이 좋음. 이를 통해 위와 같은 많은 버그를 찾을 수 있음.

How to handle the Effect firing twice in development?

React는 지난 예제에서와 같이 버그를 찾기 위해 개발 중에 컴포넌트를 의도적으로 다시 마운트함. 올바른 질문은 "어떻게 이펙트를 한 번 실행하는가"가 아니라 "어떻게 이펙트를 다시 마운트한 후 작동하도록 수정하는가"임.

일반적으로 정답은 cleanup 기능을 구현하는 것. 정리 기능은 이펙트가 수행 중이던 작업을 중지하거나 실행 취소해야 함. 사용자는 프로덕션 환경에서처럼 이펙트를 한 번 실행하는 것과 개발 환경에서처럼 설정 → 정리 → 설정 과정을 거치는 것을 구분할 수 없어야 함.

앞으로 작성할 대부분의 Effect는 아래의 일반적인 패턴 중 하나에 해당함.

Controlling non-React widgets

때때로 React로 작성되지 않은 UI 위젯을 추가해야 할 때가 있음. 예를 들어 페이지에 지도 컴포넌트를 추가한다고 가정. 이 컴포넌트에는 setZoomLevel() 메서드가 있고, React 코드의 zoomLevel 상태 변수와 줌 레벨을 동기화하고자 함. 이펙트는 다음과 비슷하게 보일 것:

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

이 경우 cleanup이 필요하지 않음. 개발 환경에서 React는 Effect를 두 번 호출하지만, 같은 값으로 setZoomLevel을 두 번 호출해도 아무 일도 일어나지 않으므로 문제가 되지 않음. 속도가 약간 느려질 수 있지만 프로덕션 환경에서는 불필요하게 다시 마운트되지 않으므로 문제가 되지 않음.

일부 API는 연속으로 두 번 호출하지 못할 수도 있음. 예를 들어, 내장된 <dialog> 요소의 showModal 메서드는 두 번 호출하면 throw됨. 따라서 cleanup 함수를 구현하여 대화 상자를 닫아야 함:

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

개발 환경에서 Effect는 showModal()을 호출한 다음 즉시 close()를 호출하고, 다시 showModal()을 호출함. 이는 프로덕션 환경에서 showModal()을 한 번 호출하는 것과 동일한 사용자 표시 동작을 가짐.

Subscribing to events

Effect가 무언가를 구독하는 경우 cleanup 함수에서 구독을 취소해야 함:

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

개발 환경에서 Effect는 addEventListener()를 호출한 다음 즉시 removeEventListener()를 호출하고, 동일한 핸들러로 다시 addEventListener()를 호출함. 따라서 한 번에 하나의 활성 구독만 있을 수 있음. 이는 프로덕션 환경에서 addEventListener()를 한 번 호출하는 것과 동일한 사용자 가시적 동작을 가짐.

Triggering animations

Effect에 애니메이션이 적용된 경우 cleanup 함수을 사용하면 애니메이션이 초기 값으로 재설정됨:

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);

개발 환경에서에서는 불투명도를 1로 설정한 다음 0으로 설정한 다음 다시 1로 설정함. 이렇게 하면 프로덕션 환경에서 불투명도를 1로 설정하는 것과 동일한 사용자 표시 동작이 나타남. tweening을 지원하는 타사 애니메이션 라이브러리를 사용하는 경우 정리 함수를 통해 타임라인을 초기 상태로 재설정해야 함.

Fetching data

Effect가 무언가를 가져오는(fetch) 경우 cleanup 함수는 가져오기를 중단하거나 결과를 무시해야 함:

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

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

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

이미 발생한 네트워크 요청을 '실행 취소'할 수는 없지만, cleanup 함수를 사용하면 더 이상 관련이 없는 fetch가 애플리케이션에 계속 영향을 미치지 않도록 할 수 있음. userId가 'Alice'에서 'Bob'으로 변경된 경우, cleanup 함수를 사용하면 'Bob' 이후에 도착한 'Alice' 응답을 무시할 수 있음.

개발 환경에서는 네트워크 탭에 두 개의 fetch가 표시됨. 이는 잘못된 것이 아님. 첫 번째 Effect가 즉시 cleanup 되어 ignore 변수의 복사본이 true로 설정됨. 따라서 추가 요청이 있더라도 if(!ignore) 검사 덕분에 상태에 영향을 미치지 않음.

프로덕션 환경에서는 네트워크 탭에 하나의 fetch가 표시됨. ?개발 환경에서 두 번째 요청이 귀찮다면 요청을 중복 제거하고 컴포넌트 간에 응답을 캐시하는 솔루션을 사용하는 것이 가장 좋은 방법:?

function TodoList() {
  const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
  // ...

이렇게 하면 개발 경험이 개선될 뿐만 아니라 애플리케이션이 더 빠르게 느껴짐. 예를 들어, 사용자가 뒤로 버튼을 누르면 일부 데이터가 캐시되므로 다시 로드될 때까지 기다릴 필요가 없음. 이러한 캐시를 직접 빌드하거나 Effects에서 수동 fetching의 여러 대안 중 하나를 사용할 수 있음.

Sending analytics

페이지 방문 시 애널리틱스 이벤트를 전송하는 코드:

useEffect(() => {
  logVisit(url); // Sends a POST request
}, [url]);

개발 환경에서는 모든 URL에 대해 logVisit이 두 번 호출되므로 이를 수정하고 싶을 수 있지만, 이 코드는 그대로 유지하는 것이 좋음. 이전 예제에서와 마찬가지로 한 번 실행하는 것과 두 번 실행하는 것 사이에 사용자가 볼 수 있는 동작 차이는 없음. 실용적인 관점에서 볼 때, 개발 머신의 로그가 생산 지표(production metrics)를 왜곡하는 것을 원하지 않기 때문에 개발 머신에서는 logVisit이 아무 작업도 하지 않아야 함. 컴포넌트는 파일을 저장할 때마다 다시 마운트되므로 어쨌든 개발 단계에서 추가 방문을 기록함.

프로덕션 환경에서는 중복 방문 로그가 발생하지 않음.

전송하는 분석 이벤트를 디버깅하려면 앱을 스테이징 환경(프로덕션 모드에서 실행)에 배포하거나 Strict Mode 및 개발 전용 리마운트 검사를 일시적으로 선택 해제할 수 있음. 또한 Effects 대신 경로 변경 이벤트 핸들러에서 분석을 전송할 수도 있음. 보다 정확한 분석을 위해 교차점 옵저버를 사용하면 뷰포트에 어떤 컴포넌트가 있는지, 얼마나 오래 표시되는지 추적할 수 있음.

Not an Effect: Initializing the application

일부 로직은 애플리케이션이 시작될 때 한 번만 실행되어야 함. 이런 로직은 컴포넌트 외부에 배치할 수 있음:

if (typeof window !== 'undefined') { // Check if we're running in the browser.
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...

이렇게 하면 브라우저가 페이지를 로드한 후 해당 로직이 한 번만 실행되도록 보장할 수 있음.

Not an Effect: Buying a product

cleanup 함수를 작성하더라도 사용자가 Effect를 두 번 실행하는 결과를 방지할 방법이 없는 경우가 있음. 예를 들어 Effect가 제품 구매와 같은 POST 요청을 전송할 수 있음:

useEffect(() => {
  // ❌ Wrong: This Effect fires twice in development, exposing a problem in the code.
  fetch('/api/buy', { method: 'POST' });
}, []);

사용자는 제품을 두 번 구매하고 싶지 않을 것. 하지만 이 로직을 Effect에 넣으면 안 되는 이유이기도 함. 사용자가 다른 페이지로 이동한 후 뒤로 버튼을 누르면? Effect가 다시 실행될 것. 사용자는 페이지를 방문했을 때 제품을 구매하는 것이 아니라 사용자가 구매 버튼을 클릭했을 때 제품을 구매하기를 원함.

구매는 렌더링이 아니라 특정 상호 작용으로 인해 발생하므로 사용자가 버튼을 누를 때만 실행되어야 함. Effect를 삭제하고 /api/buy 요청을 구매 버튼 이벤트 핸들러로 이동시킬 것!:

function handleClick() {
  // ✅ Buying is an event because it is caused by a particular interaction.
  fetch('/api/buy', { method: 'POST' });
}

이는 remount가 애플리케이션의 로직을 깨뜨리는 경우 일반적으로 버그가 발견된다는 것을 보여줌. 사용자 관점에서 보면 페이지를 방문하고 링크를 클릭한 다음 뒤로(Back)를 눌러 페이지를 다시 보는 것과 다르지 않아야 함. React는 개발 환경에서 컴포넌트를 한 번 다시 마운트하여 이 원칙을 준수하는지 확인함.

4. You Mignt Not Need an Effect

Effect는 React 패러다임에서 벗어날 수 있는 탈출구(escape hatch)임. Effect를 사용하면 React "외부로" 나가서 컴포넌트를 React가 아닌 위젯, 네트워크 또는 브라우저 DOM과 같은 외부 시스템과 동기화할 수 있음. 외부 시스템이 관여하지 않는 경우(예: 일부 props나 state가 변경될 때 컴포넌트의 상태를 업데이트하려는 경우)에는 Effect가 필요하지 않음. 불필요한 Effect를 제거하면 코드를 더 쉽게 따라갈 수 있고, 실행 속도가 빨라지며, 오류 발생률이 줄어듦.

How to remove unnecessary Effects

Effect가 필요하지 않은 대표적인 경우 두 가지:

  • 렌더링을 위해 데이터를 변환하는 경우: 예를 들어 목록을 표시하기 전에 필터링하고 싶은 경우, 목록이 변경될 때 state 변수를 업데이트하는 Effect를 작성하고 싶을 수 있음. 하지만 이는 비효율적임. State를 업데이트할 때 React는 먼저 컴포넌트 함수를 호출해 화면에 무엇이 표시되어야 하는지 계산함. 그런 다음 React는 이러한 변경 사항을 DOM에 "커밋"하여 화면을 업데이트함. 그런 다음 Effect를 실행함. 만약 Effect도 즉시 state를 업데이트한다면 전체 프로세스가 처음부터 다시 시작됨! 불필요한 렌더 패스를 피하려면 컴포넌트의 최상위 수준에서 모든 데이터를 변환해야 함. 그러면 props나 state가 변경될 때마다 해당 코드가 자동으로 다시 실행됨.
  • 사용자 이벤트를 처리하는 경우: 예를 들어 사용자가 제품을 구매할 때 /api/buy POST 요청을 전송하고 알림을 표시하고 싶다면, 구매 버튼 클릭 이벤트 핸들러에서 정확히 어떤 일이 일어났는지 알 수 있음. Effect가 실행될 때까지는 사용자가 무엇을 했는지(예: 어떤 버튼을 클릭했는지) 알 수 없음. 그렇기 때문에 일반적으로 해당 이벤트 핸들러에서 사용자 이벤트를 처리함.

외부 시스템과 동기화하려면 Effect가 필요함. 예를 들어 jQuery 위젯을 React 상태와 동기화하는 Effect를 작성할 수 있음. 또, 예를 들어 검색 결과를 현재 검색 쿼리와 동기화할 수 있음. 최신 프레임워크는 컴포넌트에 직접 Effect를 작성하는 것보다 더 효율적인 내장 데이터 fetching 메커니즘을 제공한다는 점을 명심할 것.

올바른 직관을 얻기 위한 대표적인 몇 가지 예:

Updating state based on props or state

firstName과 lastName이라는 두 개의 state 변수가 있는 컴포넌트가 있고, 두 변수를 연결하여 fullName을 계산하고 싶음. 또한, firstName이나 lastName이 변경될 때마다 fullName이 업데이트되기를 원함. 가장 먼저 생각나는 것은 fullName state 변수를 추가하고 Effect에서 업데이트하는 것:

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // ❌ Avoid: redundant state and unnecessary Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

이는 필요 이상으로 복잡함. fullName 값으로 전체 렌더링 패스를 수행한 다음 업데이트된 값으로 즉시 다시 렌더링하기 때문에 비효율적. state 변수와 Effect를 제거하면?:

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  const fullName = firstName + ' ' + lastName;
  // ...
}

기존 props나 state에서 계산할 수 있는 것이 있으면 state에 넣지 말고, 대신 렌더링 중에 계산할 것. 이렇게 하면 코드가 더 빨라지고(추가적인 "cascading" 업데이트를 피할 수 있으며), 더 간단해지고(일부 코드를 제거할 수 있으며), 오류가 덜 발생함(서로 다른 상태 변수가 서로 동기화되지 않아 발생하는 버그를 피할 수 있음). 이 접근 방식이 생소하게 느껴진다면, Thinking in React에서 무엇이 state에 들어가야 하는지 설명함.

Caching expensive calculations

이 컴포넌트는 props로 받은 todos를 가져와 filter prop에 따라 필터링하여 visibleTodos를 계산함. 결과를 state에 저장하고 Effect에서 업데이트하고 싶을 수도 있습니다:

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // ❌ Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

앞의 예시에서와 마찬가지로 이것은 불필요하고 비효율적. 먼저 스테이트와 이펙트를 제거:

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ This is fine if getFilteredTodos() is not slow.
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

보통 이 코드는 괜찮음! 하지만 getFilteredTodos()가 느리거나 할 일이 많은 경우 newTodo과 같이 관련 없는 state 변수가 변경된 경우 getFilteredTodos()를 다시 계산하고 싶지 않음.

이럴 때 useMemo 훅으로 감싸서 값비싼 계산을 cache(또는 "Memoize")할 수 있음:

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    // ✅ Does not re-run unless todos or filter change
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

이렇게 하면 todos나 filter가 변경되지 않는 한 내부 함수가 다시 실행되지 않기를 원한다는 것을 React에 알림. React는 초기 렌더링 중에 getFilteredTodos()의 반환값을 기억함. 다음 렌더링 중에 todos나 filter가 다른지 확인함. 지난번과 동일하다면, useMemo는 마지막으로 저장한 결과를 반환함. 만약 다르다면 React는 내부 함수를 다시 호출하고 그 결과를 저장함.

useMemo로 감싸는 함수는 렌더링 중에 실행되므로 순수한 계산에만 작동함.

Resetting all state when a prop changes

이 ProfilePage 컴포넌트는 userId prop을 받음. 페이지에는 댓글 입력이 포함되어 있으며, comment state 변수를 사용하여 해당 값을 보관함. 어느 날 한 프로필에서 다른 프로필로 이동할 때 comment state가 재설정되지 않는 문제를 발견함. 그 결과 실수로 잘못된 사용자의 프로필에 댓글을 게시하기 쉬움. 이 문제를 해결하기 위해 사용자 아이디가 변경될 때마다 comment state 변수를 지우려고 함:

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // ❌ Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

이는 비효율적인데, ProfilePage와 그 자식들이 먼저 오래된 값으로 렌더링한 다음 다시 렌더링하기 때문. 또한 ProfilePage 내부에 어떤 state가 있는 모든 컴포넌트에서 이 작업을 수행해야 하므로 복잡함. 예를 들어 댓글 UI가 중첩된 경우 중첩된 comment state도 지워야 함.

대신, 명시적인 key를 전달하여 각 사용자의 프로필이 개념적으로 다른 프로필임을 React에 알릴 수 있음. 컴포넌트를 둘로 분할하고 외부 컴포넌트에서 내부 컴포넌트로 key 속성을 전달:

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

일반적으로 React는 동일한 컴포넌트가 같은 위치에 렌더링될 때 상태를 보존함. Profile 컴포넌트에 userId를 key로 전달하면, React가 userId가 다른 두 개의 Profile 컴포넌트를 상태를 공유해서는 안 되는 두 개의 다른 컴포넌트로 취급하도록 요청하는 것. userId로 설정한 key가 변경될 때마다 React는 DOM을 다시 생성하고 Profile 컴포넌트와 그 모든 자식들의 상태를 재설정함. 이제 프로필 사이를 탐색할 때 댓글 필드가 자동으로 지워짐.

이 예제에서는 외부 ProfilePage 컴포넌트만 내보내 프로젝트의 다른 파일에 표시된다는 점에 유의! ProfilePage를 렌더링하는 컴포넌트는 key를 전달할 필요 없이 일반 프로퍼티로 userId를 전달함. ProfilePage가 이를 내부 Profile 컴포넌트에 key로 전달한다는 사실은 구현 세부 사항.

Adjusting some state when a prop changes

때로는 prop 변경 시 상태의 일부를 재설정하거나 조정하고 싶지만 전부를 재설정하고 싶지는 않을 수 있음.

이 List 컴포넌트는 아이템 목록을 prop으로 받고 selection state 변수에 선택된 아이템을 유지함. items prop이 다른 배열을 받을 때마다 선택 항목을 null로 재설정하고 싶음:

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // ❌ Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

이것 역시 이상적이지 않음. 항목이 변경될 때마다 목록과 그 하위 컴포넌트는 오래된 selection 값으로 렌더링됨. 그런 다음 React는 DOM을 업데이트하고 Effects를 실행함. 마지막으로, setSelection(null) 호출은 List와 그 하위 컴포넌트를 다시 렌더링하여 이 전체 프로세스를 다시 시작하게 됨.

이펙트를 삭제하고, 대신 렌더링 중에 직접 상태를 조정:

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

이렇게 이전 렌더링의 정보를 저장하는 것은 이해하기 어려울 수 있지만 Effect에서 동일한 상태를 업데이트하는 것보다 나음. 위 예시에서는 렌더링 도중 setSelection이 직접 호출됨. React는 반환문과 함께 종료된 직후에 List를 다시 렌더링함. React는 아직 List 자식들을 렌더링하거나 DOM을 업데이트하지 않았기 때문에 오래된 selection 값의 렌더링을 건너뛸 수 있음.

렌더링 도중 컴포넌트를 업데이트하면 React는 반환된 JSX를 버리고 즉시 렌더링을 다시 시도함. 매우 느린 계단식(cascading) 재시도를 피하기 위해 React는 렌더링 중에 동일한 컴포넌트의 상태만 업데이트할 수 있도록 함. 렌더링 도중 다른 컴포넌트의 상태를 업데이트하면 오류가 발생함. 루프를 피하려면 items !== prevItems와 같은 조건이 필요함. 이런 식으로 상태를 조정할 수 있지만, 컴포넌트를 순수하게 유지하기 위해 DOM 변경이나 타임아웃 설정과 같은 다른 부작용은 이벤트 핸들러나 Effect에 남겨둬야 함.

이 패턴은 Effect보다 더 효율적이지만 대부분의 컴포넌트에는 필요하지 않음. 어떻게 하든 props나 다른 state에 따라 state를 조정하면 데이터 흐름을 이해하고 디버깅하기가 더 어려워짐. key를 사용하여 모든 상태를 재설정하거나 렌더링 중에 모든 상태를 계산할 수 있는지 항상 확인할 것. 예를 들어, 선택한 항목을 저장(및 재설정)하는 대신 선택한 항목 ID를 저장할 수 있음:

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: Calculate everything during rendering
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

이제 상태를 "조정"할 필요가 전혀 없음. 선택한 ID를 가진 항목이 목록에 있으면 선택된 상태로 유지됨. 그렇지 않은 경우 일치하는 항목을 찾을 수 없으므로 렌더링 중에 계산된 선택 항목은 null이 됨. 이 동작은 다르지만 대부분의 항목 변경이 선택 내용을 유지하므로 더 나은 방법이라고 할 수 있음.

Sharing logic between event handlers

제품을 구매할 수 있는 두 개의 버튼(Buy, Checkout)이 있는 제품 페이지에서 사용자가 제품을 장바구니에 넣을 때마다 알림을 표시하고 싶다면? 두 버튼의 클릭 핸들러에서 모두 showNotification()을 호출하는 것은 반복적으로 느껴지므로 이 로직을 Effect에 배치하고 싶을 수 있음:

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an Effect
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

이 효과는 불필요하고 버그를 유발할 가능성이 높음. 예를 들어 페이지가 새로 고쳐질 때마다 앱이 장바구니를 "기억"한다고 가정하면, 카트에 제품을 한 번 추가하고 페이지를 새로 고치면 알림이 다시 표시됨. 해당 제품 페이지를 새로 고칠 때마다 알림이 계속 표시됨. 이는 페이지 로드 시 product.isInCart가 이미 true이므로 위의 Effect는 showNotification()을 호출하기 때문.

어떤 코드가 Effect에 있어야 하는지 이벤트 핸들러에 있어야 하는지 확실하지 않은 경우 이 코드가 실행되어야 하는 이유를 자문해 볼 것. 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 이펙트를 사용할 것. 이 예제에서는 페이지가 표시되었기 때문이 아니라 사용자가 버튼을 눌렀기 때문에 알림이 표시되어야 함! Effect를 삭제하고 공유 로직을 두 이벤트 핸들러에서 호출되는 함수에 넣어야 함:

function ProductPage({ product, addToCart }) {
  // ✅ Good: Event-specific logic is called from event handlers
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

이렇게 하면 불필요한 효과가 제거되고 버그가 수정됨.

Sending a POST request

다음 form 컴포넌트는 두 가지 종류의 POST 요청을 전송하고, mount할 때 분석 이벤트를 보냄. form을 작성하고 제출 버튼을 클릭하면 /api/register 엔드포인트로 POST 요청을 보냄:

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic should run because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Event-specific logic inside an Effect
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}

앞의 예와 동일한 기준을 적용하면,

애널리틱스 POST 요청은 Effect에 남아 있어야 함. 분석 이벤트를 전송하는 이유는 폼이 표시되었기 때문. (개발 환경에서는 두 번 실행됨. 이를 처리하는 방법)

그러나 /api/register POST 요청은 폼이 표시되어서 발생하는 것이 아님. 사용자가 버튼을 누를 때라는 특정 시점에만 요청을 보내려고 함. 이 요청은 해당 특정 상호작용에서만 발생해야 함. 두 번째 Effect를 삭제하고 해당 POST 요청을 이벤트 핸들러로 이동해야 함:

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic runs because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: Event-specific logic is in the event handler
    post('/api/register', { firstName, lastName });
  }
  // ...
}

어떤 로직을 이벤트 핸들러에 넣을지 Effect에 넣을지 선택할 때, 사용자 관점에서 어떤 종류의 로직인지에 대한 답을 찾아야 함. 이 로직이 특정 상호작용으로 인해 발생하는 것이라면 이벤트 핸들러에 넣고, 사용자가 화면에서 컴포넌트를 보는 것이 원인이라면 이펙트에 넣어야 함.

Chains of computations

때때로 다른 상태에 따라 각각 상태를 조정하는 체인 Effect를 사용하고 싶을 때가 있음:

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

이 코드에는 두 가지 문제가 있음.

  1. 비효율적: 컴포넌트(및 그 자식)는 체인의 각 세트 호출 사이에 다시 렌더링해야 함. 위의 예시에서 최악의 경우(setCard → 렌더링 → setGoldCardCount → 렌더링 → setRound → 렌더링 → setIsGameOver → 렌더링)에는 아래 트리의 불필요한 재렌더링이 세 번 발생함.

  2. 속도가 느리지 않더라도 코드가 발전함에 따라 작성한 '체인'이 새로운 요구 사항에 맞지 않는 경우가 발생할 수 있음: 게임 이동의 기록을 단계별로 살펴볼 수 있는 방법을 추가한다고 가정하면, 각 state 변수를 과거의 값으로 업데이트하여 이를 수행할 수 있음. 하지만 카드 상태를 과거의 값으로 설정하면 Effect 체인이 다시 트리거되고 표시되는 데이터가 변경됨. 이러한 코드는 딱딱하고 취약한 경우가 많음.

이 경우 렌더링 중에 계산 가능한 것을 계산하고 이벤트 핸들러에서 상태를 조정하는 것이 좋음:

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ Calculate what you can during rendering
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    }

    // ✅ Calculate all the next state in the event handler
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

위 코드가 훨씬 더 효율적임. 또한 게임 기록을 볼 수 있는 방법을 구현하면 이제 다른 모든 값을 조정하는 Effect 체인을 트리거하지 않고도 각 상태 변수를 과거의 움직임으로 설정할 수 있음. 여러 이벤트 핸들러 간에 로직을 재사용해야 하는 경우 함수를 추출하여 해당 핸들러에서 호출할 수 있음.

이벤트 핸들러 내부에서 state는 스냅샷처럼 동작함. 예를 들어 setRound(round + 1)를 호출한 후에도 라운드 변수는 사용자가 버튼을 클릭한 시점의 값을 반영합니다. 계산에 다음 값을 사용해야 하는 경우 const nextRound = round + 1처럼 수동으로 정의해야 함.

이벤트 핸들러에서 직접 다음 상태를 계산할 수 없는 경우도 있음. 예를 들어 여러 개의 드롭다운이 있는 form에서 다음 드롭다운의 옵션이 이전 드롭다운의 선택된 값에 따라 달라진다고 가정하면, 이 경우 네트워크와 동기화하기 때문에 Effect 체인이 적절함.

Initializing the application

일부 로직은 앱이 로드될 때 한 번만 실행되어야 함.

이러한 로직을 최상위 컴포넌트의 Effect에 배치하고 싶을 수도 있음:

function App() {
  // 🔴 Avoid: Effects with logic that should only ever run once
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

하지만 개발 환경에서 이 함수가 두 번 실행된다는 사실을 금방 알게 될 것. 함수가 두 번 호출되도록 설계되지 않았기 때문에 인증 토큰이 무효화되는 등의 문제가 발생할 수 있음. 일반적으로 컴포넌트는 다시 마운트될 때 복원력이 있어야 함. 여기에는 최상위 앱 컴포넌트가 포함됨.

프로덕션 환경에서 실제로 다시 마운트되지 않을 수도 있지만 모든 컴포넌트에서 동일한 제약 조건을 따르면 코드를 이동하고 재사용하기가 더 쉬워짐. 일부 로직이 컴포넌트 마운트당 한 번이 아니라 앱 로드당 한 번 실행되어야 하는 경우 최상위 변수를 추가하여 이미 실행되었는지 여부를 추적해야 함:

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ Only runs once per app load
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

모듈 초기화 중이나 앱이 렌더링되기 전에 실행할 수도 있음:

if (typeof window !== 'undefined') { // Check if we're running in the browser.
   // ✅ Only runs once per app load
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

컴포넌트를 import할 때 최상위 레벨의 코드는 렌더링되지 않더라도 한 번 실행됨. 임의의 컴포넌트를 import할 때 속도 저하나 예상치 못한 동작을 방지하려면 이 패턴을 과도하게 사용하지 말 것. 앱 전체 초기화 로직은 App.js와 같은 루트 컴포넌트 모듈이나 애플리케이션의 엔트리 포인트에 유지할 것.

Notifying parent components about state changes

참 또는 거짓이 될 수 있는 내부 isOn 상태를 가진 Toggle 컴포넌트에서 클릭 또는 드래그를 통해 토글하는 방법에는 몇 가지가 있음. Toggle 내부 상태가 변경될 때마다 부모 컴포넌트에 알리고 싶을 때, onChange 이벤트를 노출하고 Effect에서 호출함:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 Avoid: The onChange handler runs too late
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

앞서와 마찬가지로 위 코드는 이상적이지 않음. Toggle이 먼저 상태를 업데이트하고 React가 화면을 업데이트함. 그런 다음 React는 Effect를 실행하고 부모 컴포넌트에서 전달된 onChange 함수를 호출함. 이제 부모 컴포넌트는 자신의 상태를 업데이트하고 다른 렌더 패스를 시작함. 모든 것을 한 번의 패스로 처리하는 것이 좋음.

Effect를 삭제하고 대신 동일한 이벤트 핸들러 내에서 두 컴포넌트의 상태를 업데이트해야 함:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ Good: Perform all updates during the event that caused them
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

이 접근 방식을 사용하면 Toggle 컴포넌트와 그 부모 컴포넌트 모두 이벤트가 진행되는 동안 상태를 업데이트함. React는 서로 다른 컴포넌트의 업데이트를 일괄 처리하므로 렌더링 패스는 한 번만 발생함.

상태를 완전히 제거하고 대신 부모 컴포넌트로부터 isOn을 받을 수도 있음:

// ✅ Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }

  // ...
}

"상태 끌어 올리기"는 부모 컴포넌트가 부모 자체의 상태를 토글하여 토글을 완전히 제어할 수 있게 해줌. 즉, 부모 컴포넌트에 더 많은 로직을 포함해야 하지만 전체적으로 걱정해야 할 상태는 줄어듦. 두 개의 서로 다른 상태 변수를 동기화하려고 할 때마다 대신 상태 끌어올리기를 사용해 볼 것!

Passing data to the parent

이 Child 컴포넌트는 일부 데이터를 가져온 다음 Effect의 부모 컴포넌트에 전달함:

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 Avoid: Passing data to the parent in an Effect
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

React에서 데이터는 부모 컴포넌트에서 자식 컴포넌트로 흐름. 화면에 뭔가 잘못된 것이 보이면 컴포넌트 체인을 따라 올라가서 어떤 컴포넌트가 잘못된 props를 전달하거나 잘못된 state를 가지고 있는지 찾아내면 정보의 출처를 추적할 수 있음. 자식 컴포넌트가 Effects에서 부모 컴포넌트의 state를 업데이트하면 데이터 흐름을 추적하기가 매우 어려워짐. 자식과 부모 모두 동일한 데이터가 필요하므로 부모 컴포넌트가 해당 데이터를 가져와 자식에게 대신 전달하도록 해야함:

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ Good: Passing data down to the child
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

이렇게 하면 데이터가 부모에서 자식으로 내려오기 때문에 데이터 흐름이 더 간단하고 예측 가능하게 유지됨.

Subscribing to an external store

때로는 컴포넌트가 React 상태 외부의 일부 데이터를 구독해야 할 수도 있음. 이 데이터는 타사 라이브러리 또는 내장 브라우저 API에서 가져올 수 있음. 이 데이터는 React가 모르는 사이에 변경될 수 있으므로 컴포넌트를 수동으로 구독해야함. 예를 들어 이 작업은 종종 Effect를 통해 수행됨:

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

여기서 컴포넌트는 외부 데이터 저장소(이 경우 브라우저 navigator.onLine API)를 구독함. 이 API는 서버에 존재하지 않으므로(초기 HTML에 사용할 수 없으므로) 처음에 상태는 true로 설정됨. 브라우저에서 해당 데이터 저장소의 값이 변경될 때마다 컴포넌트는 해당 상태를 업데이트함.

이를 위해 Effect를 사용하는 것이 일반적이지만, React에는 외부 저장소를 구독하기 위해 특별히 제작된 Hook이 있음. Effect를 삭제하고 useSyncExternalStore에 대한 호출로 대체한 코드:

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

이 접근 방식은 변경 가능한 데이터를 Effect를 사용해 React state에 수동으로 동기화하는 것보다 오류가 덜 발생함. 일반적으로 위의 useOnlineStatus()와 같은 사용자 정의 Hook을 작성하여 개별 컴포넌트에서 이 코드를 반복할 필요가 없도록 함.

참고: React 컴포넌트에서 외부 store를 구독하는 방법

Fetching data

많은 앱이 데이터 가져오기를 시작하기 위해 Effect를 사용함. 이와 같은 데이터 불러오기 Effect를 작성하는 것은 매우 일반적임:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

이 fetch를 이벤트 핸들러로 옮길 필요는 없음.

이벤트 핸들러에 로직을 넣어야 했던 앞선 예제와 모순되는 것처럼 보일 수 있지만, fetch를 해야 하는 주된 이유가 입력 이벤트가 아니라는 점을 고려해야함. 검색 입력은 URL에서 미리 채워지는 경우가 많으며, 사용자는 입력을 건드리지 않고 앞뒤로 탐색할 수도 있음.

페이지와 쿼리의 출처가 어디인지는 중요하지 않음. 이 구성 요소가 표시되는 동안에는 현재 페이지 및 쿼리에 대한 네트워크의 데이터와 결과를 동기화하려고 함. 이것이 바로 Effect!

하지만 위의 코드에는 버그가 있음. "hello"를 빠르게 입력한다면, 쿼리가 "h"에서 "he", "hel", "hell", "hello"로 바뀜. 이렇게 하면 별도의 fetch가 시작되지만 응답이 어떤 순서로 도착할지는 보장할 수 없음. 예를 들어, "hello" 응답 이후에 "hell" 응답이 도착할 수 있음. 이 경우 setResults()를 마지막으로 호출하므로 잘못된 검색 결과가 표시될 수 있음. 이를 "경쟁 조건"이라고 하는데, 서로 다른 두 요청이 서로 "경쟁"하여 예상과 다른 순서로 도착하는 것을 의미함.

경쟁 조건을 수정하려면 오래된 응답을 무시하는 cleanup 함수를 추가해야함:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

이렇게 하면 Effect가 데이터를 가져올 때 마지막으로 요청된 응답을 제외한 모든 응답이 무시됨.

데이터 fetch를 구현할 때 경쟁 조건을 처리하는 것만이 어려운 것은 아님. 응답 캐싱(사용자가 뒤로 버튼을 클릭하여 이전 화면을 즉시 볼 수 있도록), 서버에서 데이터를 가져오는 방법(초기 서버 렌더링 HTML에 스피너 대신 가져온 콘텐츠가 포함되도록), 네트워크 워터폴을 피하는 방법(자식이 모든 부모를 기다리지 않고 데이터를 가져올 수 있도록) 등도 고려해야함.

이러한 문제는 React뿐만 아니라 모든 UI 라이브러리에 적용됨. 이러한 문제를 해결하는 것은 간단하지 않기 때문에 최신 프레임워크는 Effects에서 데이터를 불러오는 것보다 더 효율적인 내장 데이터 불러오기 메커니즘을 제공함.

프레임워크를 사용하지 않고(직접 빌드하고 싶지 않다면) Effects에서 데이터 불러오기를 보다 인체공학적으로 만들고 싶다면 이 예제에서처럼 불러오기 로직을 커스텀 Hook으로 추출하는 것을 고려해 볼 것:

function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  const results = useData(`/api/search?${params}`);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(url)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setData(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return data;
}

또한 오류 처리와 콘텐츠 로딩 여부를 추적하기 위한 로직을 추가하고 싶다면, 이와 같은 Hook을 직접 빌드하거나 React 에코시스템에서 이미 사용 가능한 많은 솔루션 중 하나를 사용할 수 있음. 이 방법만으로는 프레임워크에 내장된 데이터 fetch 메커니즘을 사용하는 것만큼 효율적이지는 않지만, 데이터 불러오기 로직을 사용자 정의 Hook으로 옮기면 나중에 효율적인 데이터 fetch 전략을 채택하기가 더 쉬워짐.

일반적으로 Effects를 작성해야 할 때마다 위의 useData와 같이 보다 선언적이고 목적에 맞게 구축된 API를 사용하여 일부 기능을 커스텀 Hook으로 추출할 수 있음에 주목할 것. 컴포넌트에서 useEffect 호출이 적을수록 애플리케이션을 유지 관리하기가 더 쉬워짐.

5. Lifecycle of Reactive Effects

Effect는 컴포넌트와 다른 라이프사이클을 가짐. 컴포넌트는 mount, update 또는 unmount될 수 있음. Effect는 동기화를 시작하고 나중에 동기화를 중지하는 두 가지 작업만 할 수 있음. 이 사이클은 시간이 지남에 따라 변하는 props와 state에 의존하는 Effect의 경우 여러 번 발생할 수 있음. React는 Effect의 종속성을 올바르게 지정했는지 확인하는 linter 규칙을 제공함. 이렇게 하면 Effect가 최신 props와 state에 동기화됨.

The lifecycle of an Effect

모든 React 컴포넌트는 동일한 라이프사이클을 거침:

  • 컴포넌트가 화면에 추가되면 mount 됨.
  • 컴포넌트는 일반적으로 상호작용에 대한 응답으로 새로운 props나 state를 받으면 update 됨.
  • 컴포넌트가 화면에서 제거되면 unmount 됨.

컴포넌트에 대해 생각하기에는 좋지만 Effect에 대해서는 생각하지 않는 것이 좋음. 대신 컴포넌트의 라이프사이클과 독립적으로 각 Effect를 생각할 것. Effect는 외부 시스템을 현재 props 및 state와 동기화하는 방법을 설명함. 코드가 변경되면 동기화를 더 자주 또는 덜 자주 수행해야 함.

Why synchronization may need to happen more than once

How React re-synchronizes your Effect

Thinking from the Effect’s perspective

How React verifies that your Effect can re-synchronize

How React knows that it needs to re-synchronize the Effect

Each Effect represents a separate synchronization process

Effects “react” to reactive values

What an Effect with empty dependencies means

All variables declared in the component body are reactive

React verifies that you specified every reactive value as a dependency

What to do when you don’t want to re-synchronize

0개의 댓글