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

이춘구·2022년 8월 15일
23

translation

목록 보기
2/11

Photo by Annie Spratt

TkDodoAvoiding useEffect with callback refs을 번역한 글입니다.


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

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

const ref = React.useRef(null)

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

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

ref와 상호작용 하기

대부분의 상호작용에 대해 리액트가 업데이트를 자동으로 처리하므로 여러분이 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 함수에 "결속(bind) 되어있다"는 겁니다.

Callback ref

이 때 바로 콜백 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 prop은 그냥 함수다!

그리고 이 함수는 사이드 이펙트가 실행되어도 완전히 괜찮은 때인 렌더링 이후에 실행됩니다. ref가 그냥 onAfterRender 같은 이름이었으면 더 나았을지도 모르겠네요.

노드에 직접 접근할 수 있는 콜백 ref의 내부에서 input에 바로 focus 하는 걸 그 누가 막을 수 있겠어요?

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

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

useCallback 출동!

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

그리고 이 때, 함수가 불필요하게 생성되지 않도록 보장하는 방법인 useCallback이 등장합니다. 아마도 이게 callback-ref라는 이름의 이유일지도 모릅니다. 왜냐하면 여러분은 callback ref를 항상 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
프런트엔드 개발자

2개의 댓글

comment-user-thumbnail
2023년 6월 6일

좋은 글 감사합니다

답글 달기
comment-user-thumbnail
2023년 8월 7일

react 공홈에 있는 예제의 상세 설명서네요. 완전!
잘 읽었습니다. 번역 감사해요~

답글 달기