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
const ref = React.useRef(null)
React.useEffect(() => {
ref.current?.focus()
}, [])
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
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
입니다.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
<input
ref={(node) => {
ref.current = node;
}}
defaultValue="Hello world"
/>
한 번 더 강조하겠습니다.
모든 ref prop은 그저 함수다!
그리고 이 함수는 부작용을 실행해도 완전히 괜찮은 시점인 렌더링 이후에 실행됩니다. 이름이 ref
가 아니라 그냥 onAfterRender
같은 거였으면 더 나았을지도 모르겠네요.
그렇다면 우리가 콜백 ref 안에서 input에 focus 하는 걸 막는 건 뭘까요? 콜백 ref 안에서는 node에 직접 접근할 수 있는데 말이죠.
// focus-with-callback-ref
<input
ref={(node) => {
node?.focus()
}}
defaultValue="Hello world"
/>
음... 사소한 디테일인데, 바로 React가 렌더링할 때마다 매번 이 함수를 실행한다는 겁니다. 그러니까 input에 그 정도로 자주 focus 해도 괜찮은 게 아니라면(아닐 가능성이 높죠), 우리가 원할 때만 실행하라고 React에 알려줘야 합니다.
운 좋게도 React는 콜백 ref가 실행되어야 하는지 검사할 때 참조 안정성을 사용합니다. 같은 ref(erence)를 넘겨주면 실행이 생략된다는 뜻이죠.
그리고 여기서 함수가 불필요하게 생성되지 않도록 보장하는 useCallback이 등장합니다. 이게 callback-ref라고 이름 지은 이유일지도 모르겠네요. 왜냐하면 항상 useCallback으로 감싸야 하니까요. 😂
최종 해결책이 여기 있습니다.
// callback-ref-with-use-callback
const ref = React.useCallback((node) => {
node?.focus()
}, [])
return <input ref={ref} defaultValue="Hello world" />
초기 코드에 비해 코드가 적고 훅을 하나만 사용합니다. 그리고 콜백 ref는 DOM 노드를 마운트시키는 컴포넌트의 생명주기가 아니라, 해당 DOM 노드 자체의 생명주기에 바인딩 되어있기 때문에 모든 상황에서 잘 동작할 겁니다. 게다가 (개발 환경에서 실행될 때) 엄격 모드에서 두 번 실행되지도 않을 겁니다. 많은 사람에게 이 점이 중요해 보였어요.
그리고 (오래된) 리액트 공식 문서에 숨겨진 보석이 보여주듯, 모든 종류의 부작용을 실행할 때도 사용할 수 있습니다(예. 콜백 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>The above header is {Math.round(height)}px tall</h2>
</>
)
}
그러니 제발 렌더링 이후의 DOM 노드와 직접 상호작용해야 한다면, 성급하게 useRef + useEffect를 사용하려 들지 말고 콜백 ref를 고려해 보세요.
좋은 글 감사합니다