[번역] callback refs 사용으로 useEffect 방지하기

cnsrn1874·2022년 8월 15일
1

이 글은 TkDodo원글을 번역한 것입니다. 의역이 있으며 오타, 오역 피드백은 댓글로 부탁드립니다.


주의: 이 글은 리액트의 ref가 무엇인지에 대한 기본적인 이해를 전제하고 있습니다.

이론적으로, ref는 임의의 값을 저장할 수 있는 변경 가능한 컨테이너지만, DOM 노드에 접근하는 데에 가장 자주 사용됩니다.

const ref = React.useRef(null)

return <input ref={ref} defaultValue="Hello world" />

ref는 리액트 내장 기능의 예약된 속성으로, 렌더링 이후 해당 DOM 노드를 저장합니다. 그리고 컴포넌트가 언마운트 되면 다시 null로 설정됩니다.

refs와 상호작용

상호작용 하는 대부분의 경우, 리액트가 업데이트를 자동으로 처리하기 때문에 여러분은 DOM 노드에 접근할 필요가 없습니다. 여러분이 ref를 필요로 하는 좋은 예는 focus 관리입니다.

react-dom에 focusManagement를 추가할 것을 제안하는 Devon Govett훌륭한 RFC가 있지만, 우리의 focus 관리를 도와줄 수 있는 방법이 리액트에 지금 당장은 없습니다.

effect를 이용한 focus

그렇다면 지금 당장, 렌더링 이후에 input에 focus 하려면 어떻게 하시겠습니까? (autofocus가 있는 건 알지만, 그냥 예를 들어보는 겁니다. 이게 별로라면, 노드에 애니메이션을 적용한다고 상상해보세요.)

제가 봐온 대부분의 코드는 이렇게 했습니다.

const ref = React.useRef(null)

React.useEffect(() => {
  ref.current?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />

위 코드는 대부분의 경우에 괜찮고, 규칙을 어기지도 않습니다. useEffect 내부에서 사용되는 건 불변하는 ref 뿐이라서 의존성 배열이 비어있는 것도 괜찮습니다. linter는 ref를 의존성 배열에 추가하라고 불평하지 않을테고, ref는 렌더링 도중에 읽히지 않을 겁니다. (concurrent한 리액트의 기능에선 문제가 될 수도 있습니다.)

effect는 마운트 시에 한 번 (엄격 모드에선 두 번) 실행될 겁니다. 그 때쯤이면 리액트는 이미 ref에 DOM 노드를 추가했을 것이므로, 우리는 해당 노드에 focus 할 수 있습니다.

하지만 이게 최선의 방법은 아니며, 더 발전된 일부 상황에선 몇 가지 주의 사항이 있습니다.

구체적으로 말하자면, 위 코드에서 effect가 실행될 때, ref에는 이미 값이 있는 것으로 가정됩니다.
하지만, 예를 들어, 여러분이 ref를 커스텀 컴포넌트에 넘겨서 렌더링이 지연되는 경우나, 사용자가 다른 상호작용을 마친 뒤에 input을 보여주는 경우와 같이 ref에 노드 값이 들어가지 않았을 경우에, effect가 실행될 때의 ref는 여전히 null일 것이고 그래서 아무 것도 focus 되지 않을 겁니다.

function App() {
  const ref = React.useRef(null)

  React.useEffect(() => {
    // 🚨 이게 실행될 때, ref.current는 항상 null입니다.
    ref.current?.focus()
  }, [])

  return <Form ref={ref} />
}

const Form = React.forwardRef((props, ref) => {
  const [show, setShow] = React.useState(false)

  return (
    <form>
      <button type="button" onClick={() => setShow(true)}>
        show
      </button>
      // 🧐 ref가 input에 붙어있지만, 조건부 렌더링 됩니다.
      // 그러므로 위의 effect가 실행될 때, ref는 비어있을 겁니다.
      {show && <input ref={ref} />}
    </form>
  )
})

위 코드에서 무슨 일이 일어나냐면

  • Form 컴포넌트가 렌더링 됩니다.
  • input은 렌더링 되지 않고, ref는 아직 null입니다.
  • effect가 실행되지만, 아무 것도 하지 않습니다.
  • input이 렌더링 되고, ref에 값이 들어가도, effect가 다시 실행되지 않으므로 input에 focus 되지 않을 겁니다.

우리가 실제로 원하는 건 "form이 마운트 됐을 때"가 아니라 "input이 렌더링 됐을 때" input에 focus 하는 것인데, 문제는 effect가 Form의 render 함수에 "바인드 되어있다"는 겁니다.

Callback refs

이 때가 바로 콜백 ref가 선수입장~하는 때입니다. ref의 타입 선언을 본 적이 있다면, 우리는 ref 객체 뿐만 아니라 함수도 전달할 수 있다는 걸 알 수 있습니다.

type Ref<T> = RefCallback<T> | RefObject<T> | null

개념적으로, 저는 리액트 요소의 ref를 컴포넌트가 렌더링 된 이후에 호출되는 함수라고 생각하길 좋아합니다. 이 함수는 렌더링 된 DOM 노드를 인자로 전달합니다. 리액트 요소가 언마운트 되면, 이 함수는 한 번 더 호출되어 null을 전달합니다.

그러니까 useRef(RefObject)로부터 리액트 요소에게 ref를 전달하는 건 단순히 아래 코드의 문법적 설탕(syntactic sugar)입니다.

<input
  ref={(node) => {
    ref.current = node;
  }}
  defaultValue="Hello world"
/>

한 번 더 강조하겠습니다.

모든 ref props는 그냥 함수다!

그리고 이 함수는 사이드 이펙트가 실행되어도 완전히 괜찮은 시기인 렌더링 이후에 실행됩니다. ref가 그냥 onAfterRender에 호출되었으면 더 나았을 겁니다.

우리가 노드에 직접 접근할 수 있다는 걸 알게 되었는데, 콜백 ref 내부에서 input에 바로 focus 하는 걸 누가 막을 수 있을까요?

<input
  ref={(node) => {
    node?.focus()
  }}
  defaultValue="Hello world"
/>

그... 사소한 디테일이 막을 수 있습니다. 리액트는 렌더링 할 때마다 매번 이 함수를 실행할 겁니다. 그러니 input을 그 정도로 자주 focus 하길 원하는 게 아니라면 (원하지 않을 가능성이 높죠), 우리는 리액트에게 우리가 원할 때만 실행하라고 말해줘야 합니다.

useCallback 출동!

운 좋게도, 리액트는 콜백 ref가 실행되어야 하는지 확인하기 위해 참조 불변성을 사용합니다. 우리가 동일한 ref(erence)를 전달하면 실행이 생략된다는 뜻이죠.

그리고 이 때, 함수가 불필요하게 생성되지 않도록 보장하는 방법인 useCallback이 등장합니다. 아마도 이게 callback-refs라는 이름의 이유일지도 모릅니다. 왜냐하면 여러분은 callback refs를 항상 useCallback으로 감싸야 하니까요. 😂

여기 최종 해결책이 있습니다.

const ref = React.useCallback((node) => {
  node?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />

위 코드를 처음 버전이랑 비교해보면, 코드가 적고 훅을 하나만 사용합니다. 그리고 콜백 ref는 DOM 노드를 마운트 시키는 컴포넌트의 생명주기가 아니라, DOM 노드의 생명주기에 바인드 되어있기 때문에 모든 상황에서 작동할 겁니다. 게다가 (개발 환경에서 실행될 때의) 엄격 모드에서 두 번 실행되지도 않을 겁니다. 많은 사람들에게 이 점이 중요해보이죠.

그리고 (오래된) 리액트 공식문서에 숨겨진 보석이 보여주듯이, 모든 종류의 사이드 이펙트를 실행할 때 이걸 사용할 수 있습니다 (ex. 콜백 ref 내부에서 setState를 호출). 그냥 예시를 남겨드리겠습니다. 이게 꽤 좋은 예시라서요.

function MeasureExample() {
  const [height, setHeight] = React.useState(0)

  const measuredRef = React.useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height)
    }
  }, [])

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  )
}

그러니까 제발, 렌더링 이후에 DOM 노드와 직접 상호작용 해야한다면, 성급하게 useRef + useEffect 사용하지 마시고, 대신 콜백 ref 사용을 고려해보세요.

profile
프론트엔드 개발자

0개의 댓글