https://react.dev/learn/manipulating-the-dom-with-refs 를 읽고 정리한다.
리액트는 렌더링 된 결과물을 매칭하기 위해 DOM을 자동적으로 업데이트한다. 그래서 대부분의 컴포넌트는 따로 조작할 필요가 없다. 그러나 가끔, 리액트가 관리하는 DOM 요소를 접근하는 경우도 있다.
- 예를 들어 node에 focus, scroll, 또는 node의 사이즈나 위치를 측정하는 일이 있다.
리액트에서는 이런 것들을 할 수 있는 내장된 방법이 없다, 그래서 DOM node에 ref
가 필요하다.
node의 ref 얻기
1. useRef
훅을 import한다.
import { useRef } from 'react';
2. 컴포넌트 내부에서 useRef
훅을 사용해 ref
를 정의한다.
const myRef = useRef(null);
3. DOM node에 ref
어트리뷰트로 전달한다.
<div ref={myRef}>
useRef
훅은 current
로 불리는 단일 속성을 가지는 객체를 반환한다.
- 초기에
myRef.current
는 null
이다.
- 그 후 리액트가 DOM node를 생성할 때, 리액트는 node의 참조값을
myRef.current
에 넣는다. 그런 다음 DOM node에 접근하여 이벤트 핸들러나 내재된 browser API를 사용할 수 있다.
// You can use any browser APIs, for example:
myRef.current.scrollIntoView();
예: text input에 focus 주기
버튼을 누르면 text input에 focus를 주도록 구현해 보자.
1. useRef
훅을 사용해 inputRef
를 정의한다.
2. <input ref={inputRef}>
로 전달한다. 이는 리액트에게 inputRef.current
에 <input>
의 DOM 노드를 전달하라는 의미이다.
3. handleClick
함수에서, inputRef.current
를 사용해 input DOM을 읽고 inputReft.current.focus()
로 focus()
를 호출한다.
4. handleClick
이벤트 핸들러를 <button>
의 onClick
으로 전달한다.
DOM 조작은 ref를 사용하는 가장 흔한 경우이지만, timer ID와 같은 리액트 밖의 것을 저장하기 위해 사용되기도 한다. state과 유사하게 ref는 렌더링사이에 유지된다. Ref는 state 변수와 비슷하지만 설정 시 리렌더링을 트리거하지 않는다. 이는 Referencing Values with Refs에서 확인할 수 있다.
예: 요소에 Scroll 하기
컴포넌트에서 ref는 여러 개가 있을 수 있다. 예에서는 3개 이미지가 있다. 각 버튼은 DOM node에서 브라우저의 scrollIntoView()
를 호출하여 이미지를 중앙에 배치한다.
DEEP DIVE: ref 콜백을 사용해 ref 목록을 관리하기
위의 예를 보면 refs의 수가 사전 정의되어 있다. 그러나, 가끔 목록의 각 아이템에 ref가 필요한데 아이템의 총개수를 모를 수 있다. 이때 아래의 코드는 동작하지 않는다.
<ul>
{items.map((item) => {
// Doesn't work!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
그 이유로 훅은 컴포넌트의 최상위 레벨에서만 호출할 수 있기 때문이다. 그래서 루프 내부나 조건 또는 map()
호출 내부에서 useRef
를 호출할 수 없다.
1. 한 가지 가능한 방법은 부모 요소의 ref를 얻고, querySelectorAll
과 같은 DOM 조작 메서드를 사용하여 개별 자식 노드를 "찾는" 것이다. 그러나 이 방법은 깨지기 쉬우며, DOM 구조가 변경되면 중단될 수 있다.
2. 다른 해결책으로는 ref
어트리뷰트에 함수를 전달하는 것이다. 이는 ref callback으로 불린다. 리액트는 ref가 설정되면 DOM node의 ref callback을 호출하고, 지울 때가 되면 null
로 호출한다. 이를 통해 자신의 배열이나 맵(Map)을 유지 관리하고 index나 ID로 모든 참조에 접근할 수 있다.
이 예제는 이 접근 방법을 통해 긴 목록에서 임의의 노드를 스크롤하는 방법을 보여준다.
이 예에서 itemsRef
는 단일 DOM node를 가지지 않는다. 대신 item ID에서 DOM node로 접근할 수 있는 Map을 가진다.(Ref는 모든 값을 가질 수 있다!) 모든 list item의 ref
callback은 Map을 업데이트한다.
<li
key={cat.id}
ref={node => {
const map = getMap();
if (node) {
// Add to the Map
map.set(cat.id, node);
} else {
// Remove from the Map
map.delete(cat.id);
}
}}
>
이렇게 하면 나중에 Map에서 개별적인 DOM node를 읽을 수 있다.
다른 컴포넌트의 DOM node로 접근하기
내장 컴포넌트(<input />
)에 ref를 넣을 경우, 리액트는 해당 ref의 current
프로퍼티를 해당 DOM 노드(브라우저 내 실제 <input />
)로 설정한다.
그러나 <MyInput />
과 같이 자신의 컴포넌트에 ref를 넣고 싶은 경우, 기본적으로 null
을 얻게 된다. 다음은 이를 보여주는 예시이다. 버튼을 클릭해도 input에 focus가 되지 않는 것을 확인할 수 있다.
이슈 확인을 돕기 위해, 리액트는 콘솔에 아래와 같은 에러를 출력한다.
Warning: Function components cannot be given refs.
Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
리액트가 기본적으로 다른 컴포넌트의 DOM node에 접근하는 것을 허용하지 않기 때문에 발생한다. 심지어 자식도 마찬가지이다! 이는 의도적이다.
ref는 탈출구이기 때문에 아주 조금만 사용해야 한다. 다른 컴포넌트의 DOM node를 수동으로 조작하면 코드가 훨씬 더 깨지기 쉬워진다.(취약해진다.)
만약 자신의 DOM 노드를 내보내길(export) 원하는 컴포넌트들은 forwardRef
API를 사용해 구현할 수 있다.
- 이때, 컴포넌트는 자신의 ref를 자식 중 하나만 "전달" 가능하다. 따라서 내보낼 자식을 선택해야 한다.
MyInput
이 forwardRef
API를 사용하는 방법은 다음과 같다.
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
이는 아래와 같이 동작한다.
1. <MyInput ref={inputRef} />
는 리액트가 inputRef.current
에 해당 DOM node에 넣으라고 지시한다. 그러나 이를 선택할지는 MyInput
컴포넌트의 선택에 달려있으며, 기본적으로 그렇지 않다.
2. MyInput
컴포넌트는 forwardRef
를 사용해 정의되어 있다. 이는 props
다음에 선언되는 두 번째 ref
인수로 inputRef
를 받도록 선택된다.
3. MyInput
은 받은 ref
를 그 안에 있는 <input>
으로 전달한다.
이제 버튼을 클릭하면 input에 focus가 동작한다.
디자인 시스템에서 button, input 등과 같은 낮은 수준의 컴포넌트는 해당 ref를 DOM node로 전달하는 것은 흔한 패턴이다. 반면에, 고수준의 컴포넌트는 DOM 구조에 대한 우발적인 종속성을 피하기 위해 일반적으로 해당 DOM node를 노출(export) 하지 않는다.
DEEP DIVE : imperative handle를 사용해 API의 하위 집합 노출하기
위 예에서 MyInput
은 원본 DOM input 요소를 노출한다. 이는 부모 컴포넌트가 focus()
를 호출할 수 있도록 허락한다. 이는 또한 부모 컴포넌트가 다른 것들도 할 수 있다. 예를 들어, CSS style도 변경할 수 있다. 드문 경우로, 노출되는 기능을 제한하고 싶을 수도 있다. useImperativeHandle
로 이를 수행할 수 있다.
여기 MyInput
내 realInputRef
은 실제 input DOM node를 가진다. 그러나, useImperativeHandle
은 리액트가 부모 컴포넌트에게 ref 값으로 고유한 특수 객체를 공급하도록 지시한다. 그래서 Form
컴포넌트 내에 inputRef.cureent
는 오직 focus
메서드만 가질 수 있다. 이 경우에, ref "handle"은 DOM node가 아니라 useImperativeHandle
호출 내에서 생성한 사용자 지정 객체이다.
리액트가 ref 설정하는 시점 : 커밋 중 DOM 업데이트가 되었을 때
리액트에서 모든 업데이트는 두 단계로 나뉜다.
- 렌더링 하는 동안, 리액트는 컴포넌트를 호출하여 화면에 무엇이 표시되어야 하는지 파악한다.
- 커밋하는 동안, 리액트는 DOM에 변경사항을 적용한다.
보통 렌더링동안 ref에 접근하는 것을 원하진 않는다. 그것은 DOM node를 가진 ref에 대해서도 마찬가지이다.
- 첫 번째 렌더링 동안, DOM node는 아직 생성되지 않았고, 그래서 ref.cureent는 null이 될 것이다.
- 그리고 변경사항이 렌더링 될 동안, DOM node는 아직 변경사항이 적용되지 않았다. 따라서 ref를 읽기에는 너무 이르다.
리액트는 커밋하는 동안 ref.current
를 설정한다.
- DOM이 업데이트되기 전, 리액트는 영향을 받는
ref.current
값을 null
로 설정한다.
- DOM이 업데이트된 후, 리액트는 즉시 연관된 DOM node를 ref에 설정한다.
보통 이벤트 핸들러를 통해 ref에 접근한다. 만약 ref로 무언가를 하고 싶지만 이를 수행할 특정 이벤트가 없는 경우, Effect가 필요할 수 있다. Effect에 대해서는 다음 페이지에서 설명하겠다.
DEEP DIVE : flushing state는 flushSync와 동기적으로 업데이트된다.
새 할 일을 추가하고, 목록의 마지막 자식까지 화면을 아래로 스크롤하는 코드를 고려해 보자. 항상 마지막으로 추가한 항목 바로 앞에 있던 할 일로 스크롤되는 문제를 확인할 수 있다.
이 문제는 아래 두 줄과 연관된다.
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();
리액트에서 state 업데이트는 큐로 수행한다. 일반적으로 이는 사용자가 원하는 것이다. 그러나, 여기에서는 setTodos
가 DOM에 즉시 업데이트가 되지 않기 때문에 발생한 문제이다. 그래서 마지막 요소로 목록을 스크롤할 때는 아직 할 일이 추가되지 않은 상태이다. 이것이 스크롤이 항상 한 항목씩 "뒤처지는" 이유이다.
이 문제를 해결하기 위해, 리액트에게 DOM을 동기적으로 업데이트("flush")하게 강제할 수 있다. 이를 수행하기 위해, react-dom
을 통해 flushSync
를 임포트 하고 state 업데이트를 flushSync
호출로 감싼다.
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
이는 flushSync
로 감싸진 코드가 실행된 직후 리액트가 DOM을 동기적으로 업데이트하도록 지시한다. 그 결과, 스크롤을 시도할 때 마지막 할 일은 이미 DOM에 존재한다.
ref를 이용한 DOM 조작 모범 사례
ref는 탈출구이다. "리액트 밖으로 나가야 할 때"에만 이를 사용해야 한다.
일반적인 예로는 focus, scroll 위치를 관리하거나 리액트가 노출하지 않는 브라우저 API를 호출하는 것이 있다. focus, scroll과 같은 비파괴적인 동작을 붙이는 경우에는 문제가 발생하지 않는다.
하지만 DOM을 수동적으로 수정하려고 하면, React가 수행하는 변경 사항과 충돌할 위험이 있다.
이 문제를 설명하기 위해, 이 예시에는 환영 메시지와 두 개의 버튼이 있다. 첫 번째 버튼은 조건부 렌더링과 state를 사용해 그 존재여부를 전환한다. 두 번째 버튼은 remove()
DOM API를 사용해 리액트가 제어할 수 없는 DOM에서 강제로 제거한다.
"Toggle with setState"을 여러 번 누르면 메시지는 사라지고 나타나기를 반복할 것이다. 그리고 "Remove from the DOM"을 누르면 강제로 메시지가 지워진다. 마지막으로 "Toggle with setState" 눌러보자
DOM 요소를 수동으로 지운 후, 다시 보여주기 위해 setState
를 사용하면 충돌이 난다. 이는 사용자가 DOM을 변경했고, 리액트가 이를 계속 올바르게 관리하는 방법을 모르기 때문이다.
리액트가 관리하는 DOM node를 변경하는 것을 피해라. 리액트가 관리하는 요소를 수정하거나 자식을 추가/삭제하는 것은 일관성 없는 시각적 결과 또는 위와 같은 충돌이 발생할 수 있다.
그러나, 이를 전혀 할 수 없다는 이야기가 아니라 주의가 필요하다는 이야기이다. 리액트가 업데이트할 이유가 없는 DOM의 일부를 안전하게 수정할 수 있다. 예를 들어, JSX에 일부 <div>
가 항상 비어있다면 React는 그 자식들을 건드릴 이유가 없다. 따라서 수동적으로 요소를 추가/제거하는 것이 안전하다.