Photo by Annie Spratt
TkDodo의 Avoiding useEffect with callback refs을 번역한 글입니다.
주의: 이 글은 React의 ref에 대한 기본적인 이해를 전제합니다.
ref는 이론상 임의의 값을 저장할 수 있는 가변(mutable) 컨테이너이지만, DOM 노드에 접근할 때 주로 사용됩니다.
// a-basic-ref
const ref = React.useRef(null)
return <input ref={ref} defaultValue="Hello world" />
ref
는 내장 프리미티브의 예약된 속성으로서, React가 렌더링된 DOM 노드를 저장하는 공간입니다. 해당 컴포넌트가 언마운트되면 다시 null로 설정됩니다.
대부분의 상호작용은 React가 업데이트를 자동으로 처리해주기 때문에, 우리가 DOM 노드에 접근할 필요는 없습니다. ref가 필요할 만한 좋은 예시는 focus 관리입니다.
react-dom에 focusManagement를 추가할 것을 제안하는 Devon Govett의 훌륭한 RFC가 있긴 하지만, React가 우리의 focus 관리를 도울 방법이 지금 당장은 없습니다.
그렇다면 지금 당장, 렌더링 후의 input에 focus 하려면 어떻게 하시겠어요? (autofocus가 있는 건 알지만 그냥 예를 들어보는 겁니다. 이게 마음에 들지 않으면 노드에 애니메이션을 적용하는 경우를 상상해 보세요.)
제가 본 대부분의 코드는 아래와 같았습니다.
// focus-an-input
1 const ref = React.useRef(null)
2
> 3 React.useEffect(() => {
> 4 ref.current?.focus()
> 5 }, [])
6
7 return <input ref={ref} defaultValue="Hello world" />
위 코드는 대부분의 경우 괜찮으며 규칙을 어기지도 않습니다. useEffect 내부에서 사용되는 건 참조가 변하지 않는 ref뿐이라서 의존성 배열이 비어있어도 됩니다. 린터는 의존성 배열에 ref를 넣으라고 호소하지 않고, 또 ref는 렌더링 도중에 읽히지 않습니다(React의 동시성 기능에선 문제 될 수도 있음).
effect는 "마운트 시점"에 한 번(엄격 모드에선 두 번) 실행될 겁니다. 그때쯤이면 React는 이미 ref에 DOM 노드를 추가했을 테니, 우리는 해당 노드에 focus 할 수 있습니다.
하지만 이게 최선은 아니며, 더 나아간 일부 상황에선 몇 가지 주의 사항이 있습니다.
구체적으로 위 방식은 effect가 실행될 때 ref에 이미 값이 "존재한다고" 가정합니다. 하지만 예를 들어, ref를 컴포넌트에 전달했는데 그 컴포넌트의 렌더링이 지연되거나 또는 다른 상호작용이 있어야만 input을 보여주는 컴포넌트라고 하겠습니다. 그러면 effect가 실행될 때, ref는 아직 null이기 때문에 결국 아무것도 focus 되지 않습니다.
// custom-form
1 function App() {
2 const ref = React.useRef(null)
3
4 React.useEffect(() => {
> 5 // 🚨 이 코드가 실행될 때의 ref.current는 항상 null입니다.
> 6 ref.current?.focus()
7 }, [])
8
9 return <Form ref={ref} />
10 }
11
12 const Form = React.forwardRef((props, ref) => {
13 const [show, setShow] = React.useState(false)
14
15 return (
16 <form>
17 <button type="button" onClick={() => setShow(true)}>
18 표시
19 </button>
> 20 // 🧐 ref가 input에 부착돼있지만 조건부로 렌더링됩니다.
> 21 // 그러므로 위쪽의 effect가 실행될 때, ref는 비어있을 겁니다.
> 22 {show && <input ref={ref} />}
23 </form>
24 )
25 })
위 코드에서 무슨 일이 일어나냐면,
Form
컴포넌트가 렌더링됩니다.input
은 렌더링되지 않았고, ref
는 아직 null
입니다.input
이 표시되고 ref
에 값이 들어가지만, effect가 다시 실행되지 않기 때문에 input
에 focus 하지 않습니다.실제로 원하는 건 "form이 마운트 됐을 때"가 아니라 "input이 렌더링 됐을 때" input에 focus 하는 것인데, 문제는 effect가 Form의 render 함수에 "묶여(bind)있다"는 겁니다.
여기서 바로 콜백 ref가 등장합니다. ref의 타입 선언을 본 적 있다면, ref 객체 뿐만 아니라 함수도 전달이 가능하다는 걸 알 수 있습니다.
type Ref<T> = RefCallback<T> | RefObject<T> | null
저는 React 엘리먼트의 ref를 "컴포넌트가 렌더링 된 후에 호출되는 함수" 개념으로 생각합니다. 렌더링 된 DOM 노드를 인수로 받는 함수인 거죠. React 엘리먼트가 언마운트되면 인수로 null
을 받아서 한 번 더 호출되고요.
그러므로 useRef가 반환한 ref (RefObject)를 React 엘리먼트에 전달하는 건 아래 코드의 문법적 설탕(syntactic sugar)일 뿐입니다.
// callback-ref
1 <input
> 2 ref={(node) => {
> 3 ref.current = node;
> 4 }}
5 defaultValue="Hello world"
6 />
한 번 더 강조할게요.
모든 ref prop은 그저 함수다!
그리고 이 함수는 부작용을 실행해도 완전히 괜찮은 시점인 렌더링 이후에 실행됩니다. 이름이 ref
가 아니라 그냥 onAfterRender
같은 거였으면 더 나았을지도 모르겠네요.
그렇다면 우리가 콜백 ref 안에서 input에 focus 하는 걸 막는 건 뭘까요? 콜백 ref 안에서는 node에 직접 접근할 수 있는데 말이죠.
// focus-with-callback-ref
1 <input
> 2 ref={(node) => {
> 3 node?.focus()
> 4 }}
5 defaultValue="Hello world"
6 />
그게... 사소한 디테일인데, React가 렌더링마다 매번 이 함수를 실행한다는 겁니다. 그러니까 input에 그 정도로 자주 focus 해도 괜찮은 게 아니라면(아닐 가능성이 높죠), React에게 우리가 원할 때만 실행하라고 해야 합니다.
운 좋게도, React는 콜백 ref를 실행해야 할지 확인하는 데 참조 안정성을 사용합니다. 같은 ref(erence)를 전달하면 실행하지 않는다는 뜻이죠.
그리고 여기서, 함수가 불필요하게 생성되지 않게 보장하는 useCallback이 등장합니다. 이게 이름을 callback-ref라고 지은 이유일지도 모르겠네요. 왜냐면 항상 useCallback으로 감싸야 하니까요. 😂
최종 해결책은 이렇습니다.
// callback-ref-with-use-callback
> 1 const ref = React.useCallback((node) => {
> 2 node?.focus()
> 3 }, [])
4
5 return <input ref={ref} defaultValue="Hello world" />
처음 코드에 비해 양이 적고 훅을 하나만 사용합니다. 그리고 콜백 ref는 DOM 노드를 마운트하는 컴포넌트의 생명주기가 아니라, 해당 DOM 노드 자체의 생명주기에 묶여있기 때문에 모든 상황에서 잘 작동합니다. 게다가 (개발 환경에서 실행될 때) 엄격 모드에서 두 번 실행되지도 않을 겁니다. 이 점이 많은 사람들에게 중요한 거 같더라고요.
그리고 React의 (예전) 공식 문서에 숨겨진 보석이 보여주듯, 온갖 부작용을 실행할 때도 사용할 수 있습니다(예. 콜백 ref 안에서 setState 호출). 그냥 예시를 남겨드리겠습니다. 이게 꽤 좋은 예시라서요.
// measure-a-dom-node
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>위의 헤더는 높이는 {Math.round(height)}px입니다.</h2>
</>
)
}
그러니 제발 렌더링 후의 DOM 노드와 직접 상호작용해야 한다면, 곧장 useRef + useEffect를 사용하려 들 게 아니라 콜백 ref를 고려해 보세요.
좋은 글 감사합니다