DOM에 React element가 mount 또는 unmount되는 것에 반응하기 위해, useRef를 사용하여 해당 요소에 대한 참조를 얻고, useEffect를 사용하는 것을 생각해 볼 수 있습니다. 하지만 실제로 useRef와 useEffect를 사용하는 방법은 작동하지 않죠.
이는 컴포넌트가 (un)mount되고 useRef
를 통해 ref.current
에 연결될 때 콜백이나 리렌더가 없기 때문입니다.
eslint의 react-hooks
룰은 이에 대해 warning을 줍니다. useEffect
의 deps로서 ref
나 ref.current
를 사용할 때 어떤 warning이 나오는지를 확인해보죠.
아래 코드를 보면 useRef를 useEffect의 deps로 사용할 때 제대로 trigger되지 않음을 알 수 있습니다.
import React, { useEffect, useRef, useState } from "react";
import "./styles.css";
import catImageUrl from "./cat.png";
/*
for more info see:
https://medium.com/welldone-software/usecallback-might-be-what-you-meant-by-useref-useeffect-773bc0278ae
*/
export default function App() {
const [count, setCount] = useState(1);
const shouldShowImageOfCat = count % 3 === 0;
const [catInfo, setCatInfo] = useState(false);
// notice how none of the deps of useEffect
// manages to trigger the hook in time
const catImageRef = useRef();
useEffect(() => {
console.log(catImageRef.current);
setCatInfo(catImageRef.current?.getBoundingClientRect());
// notice the warning below
}, [catImageRef, catImageRef.current]);
return (
<div className="App">
<h1>useEffect & useRef vs useCallback</h1>
<p>
An image of a cat would appear on every 3rd render.
<br />
<br />
Would our hook be able to make the emoji see it?
<br />
<br />
{catInfo ? "😂" : "😩"} - I {catInfo ? "" : "don't"} see the cat 🐈
{catInfo ? `, it's height is ${catInfo.height}` : ""}!
</p>
<input disabled value={`render #${count}`} />
<button onClick={() => setCount((c) => c + 1)}>next render</button>
<br />
{shouldShowImageOfCat ? (
<img
ref={catImageRef}
src={catImageUrl}
alt="cat"
width="50%"
style={{ padding: 10 }}
/>
) : (
""
)}
</div>
);
}
그럼 우리가 무엇을 할 수 있을까요?
예전 버전이라 지금도 이게 맞는지는 추가적인 확인이 필요해보입니다..
useCallback
으로 래핑된 함수를 ref
로 전달하여 반환되는 최신 DOM 노드를 참조할 수 있습니다.
- 역시 원문에서는 코드 샌드박스로 바로 확인할 수 있습니다.
- 실행 결과
import React, { useCallback, useState } from "react";
import "./styles.css";
import catImageUrl from "./cat.png";
/*
for more info see:
https://medium.com/welldone-software/usecallback-might-be-what-you-meant-by-useref-useeffect-773bc0278ae
*/
export default function App() {
const [count, setCount] = useState(1);
const shouldShowImageOfCat = count % 3 === 0;
const [catInfo, setCatInfo] = useState(false);
// notice how this is a useCallback
// that's used as the "ref" of the image below
const catImageRef = useCallback((catImageNode) => {
console.log(catImageNode);
setCatInfo(catImageNode?.getBoundingClientRect());
}, []);
return (
<div className="App">
<h1>useEffect & useRef vs useCallback</h1>
<p>
An image of a cat would appear on every 3rd render.
<br />
<br />
Would our hook be able to make the emoji see it?
<br />
<br />
{catInfo ? "😂" : "😩"} - I {catInfo ? "" : "don't"} see the cat 🐈
{catInfo ? `, it's height is ${catInfo.height}` : ""}!
</p>
<input disabled value={`render #${count}`} />
<button onClick={() => setCount((c) => c + 1)}>next render</button>
<br />
{shouldShowImageOfCat ? (
<img
ref={catImageRef}
src={catImageUrl}
alt="cat"
width="50%"
style={{ padding: 10 }}
/>
) : (
""
)}
</div>
);
}
주의 사항1: 엘리먼트 mount 및 unmount 시, 심지어 첫 번째 mount, 그리고 unmount가 상위 엘리먼트 unmount의 결과인 경우
ref
함수가 호출되는 것이 보장됩니다.주의 사항2:
ref
콜백을useCallback
으로 래핑해야 합니다.
useCallback
이 없으면ref
콜백에서 렌더링을 발생시키면ref
콜백이null
로 다시 트리거되어 React 내부로 인해 잠재적으로 무한 루프가 발생할 수 있습니다.실험해보려면 위의 코드에서
useCallback
을 제거해보거나 이 샌드박스를 보십시오.
이 패턴은 여러 가지 방법으로 사용될 수 있습니다.
useState
는 렌더링 사이에도 일관성을 유지하는 함수이므로 ref
로도 사용할 수 있습니다.
이 경우 전체 노드
는 state
로 저장됩니다.
state
로서, 변경되면 리렌더링을 일으키고, state
는 렌더링 결과 및 useEffect
의 deps로 안전하게 사용될 수 있습니다.
const [node, setRef] = useState(null);
useEffect(() => {
if (!node) {
console.log('unmounted!');
return null;
}
console.log('mounted');
const fn = e => console.log(e);
node.addEventListener('mousedown', fn);
return () => node.removeEventListener('mousedown', fn);
}, [node])
// <div ref={setRef}....
DOM에 접근하는 것은 비용이 많이 들기 때문에 우리는 이 작업을 가능한 한 적게 하고 싶습니다.
이전 훅에서와 같이 전체 node
가 필요하지 않은 경우 상태에 일부만 저장하는 것이 좋습니다.
// the hook
function useStateRef(processNode) {
const [node, setNode] = useState(null);
const setRef = useCallback(newNode => {
setNode(processNode(newNode));
}, [processNode]);
return [node, setRef];
}
// how it's used
const [clientHeight, setRef] = useStateRef(node => (node?.clientHeight || 0));
useEffect(() => {
console.log(`the new clientHeight is: ${clientHeight}`);
}, [clientHeight])
// <div ref={setRef}....
// <div>the current height is: {clientHeight}</div>
보시다시피 우리는 ref
를 전달하는 요소가 mount될 때만 DOM에 액세스하고 이 단계에서는 clientHeight
만 저장합니다.
그러나 때로는, 성능을 위해 ref
로 사용하는 요소의 mount 및 unmount 시 다시 렌더링을 트리거하지 않고 수행할 수 있습니다.
다음 훅은 상태에 대한 노드를 저장하지 않습니다. 상태를 사용하는 대신, mount 및 unmount에 직접 응답하므로 다시 렌더링을 트리거하지 않습니다.
// the hook
function useRefWithCallback(onMount, onUnmount) {
const nodeRef = useRef(null);
const setRef = useCallback(node => {
if (nodeRef.current) {
onUnmount(nodeRef.current);
}
nodeRef.current = node;
if (nodeRef.current) {
onMount(nodeRef.current);
}
}, [onMount, onUnmount]);
return setRef;
}
const onMouseDown = useCallback(e => console.log('hi!', e.target.clientHeight), []);
const setDivRef = useRefWithCallback(
node => node.addEventListener("mousedown", onMouseDown),
node => node.removeEventListener("mousedown", onMouseDown)
);
// <div ref={setDivRef}
결국, useCallback
을 ref
로 사용하는 원리를 이해하면, 특정 요구 사항에 맞는 아이디어를 얻을 수 있습니다.
Next.js에서 첫 마운트 시 엘리먼트의 크기를 계산해서 스크롤 해야하는 일이 있었는데, useRef
, useEffect
로 사부작대도 잘 안 되는 일이 있었다. 이걸로 시도를 해봐야겠다.
근데 새로운 버전의 공식문서에는 없는 걸 보니 다른 좋은 방법이 있는 걸까..?