React는 렌더링 출력과 일치하도록 DOM을 자동으로 업데이트하므로 컴포넌트가 자주 조작할 필요가 없습니다. 하지만 때로는 노드에 초점을 맞추거나 스크롤하거나 크기와 위치를 측정하기 위해 React가 관리하는 DOM 요소에 접근해야 할 수도 있습니다. React에는 이러한 작업을 수행할 수 있는 빌트인된 방법이 없으므로 DOM 노드에 대한 ref가 필요합니다.
React가 관리하는 DOM 노드에 접근하려면, 먼저 useRef 훅을 불러오세요:
import { useRef } from 'react';
그런 다음 컴포넌트 내부에서 ref를 선언하세요:
const myRef = useRef(null);
마지막으로, DOM 노드를 가져올 JSX 태그에 ref 속성으로 참조를 전달하세요:
<div ref={myRef}>
이 useRef 혹은 current 라고 하는 프로퍼티가 포함된 객체를 반환합니다. 처음에는 myRef.current 는 null 이 될 것입니다. React가 이 <div> 에 대한 DOM 노드를 생성하면, React는 이 노드에 대한 참조를 myRef.current 에 넣습니다. 그런 다음 이벤트 핸들러에서 이 DOM 노드에 액세스하고 여기에 정의된 빌트인 브라우저 API를 사용할 수 있습니다.
myRef.current.scroolIntoView();
이 예제에서는 버튼을 클릭하면 input에 초점이 맞춰집니다:
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
이를 구현하려면:
useRef훅으로inputRef를 선언합니다.- 이를
<input ref={inputRef}>에 전달합니다. 이렇게 하면 React가 이<input>의 DOM 노드를inputRef.current에 넣을 것입니다.handleClick함수에서inputRef.current로부터 input DOM 노드를 읽어와서inputRef.current.focus()로focus()를 호출합니다.<button>의onClick에handleClick이벤트 핸들러를 전달합니다.
DOM 조작이 ref의 가장 일반적인 사용 사례이지만, useRef 혹은 timer ID와 같은 다른 것들은 React 외부에 저장하는 데 사용될 수 있습니다. 상태와 유사하게 ref는 렌더링 사이에 유지됩니다. ref는 상태 변수와 비슷하지만 설정할 때 리렌더링을 촉발하지 않습니다. ref에 대한 자세한 내용은 Refs로 값 참조하기에서 읽어보세요.
컴포넌트느 ref를 하나 이상 포함할 수도 있습니다. 이 예시에서는 세 개의 이밎로 구성된 캐러셀이 있습니다. 각 버튼은 DOM 노드에서 브라우저 scrollIntoView() 메서드를 호출하여 해당 이미지를 중앙에 배치합니다:
import { useRef } from 'react';
export default function CatFriends() {
const firstCatRef = useRef(null);
const secondCatRef = useRef(null);
const thirdCatRef = useRef(null);
function handleScrollToFirstCat() {
firstCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
function handleScrollToSecondCat() {
secondCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
function handleScrollToThirdCat() {
thirdCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
return (
<>
<nav>
<button onClick={handleScrollToFirstCat}>
Tom
</button>
<button onClick={handleScrollToSecondCat}>
Maru
</button>
<button onClick={handleScrollToThirdCat}>
Jellylorum
</button>
</nav>
<div>
<ul>
<li>
<img
src="https://placekitten.com/g/200/200"
alt="Tom"
ref={firstCatRef}
/>
</li>
<li>
<img
src="https://placekitten.com/g/300/200"
alt="Maru"
ref={secondCatRef}
/>
</li>
<li>
<img
src="https://placekitten.com/g/250/200"
alt="Jellylorum"
ref={thirdCatRef}
/>
</li>
</ul>
</div>
</>
);
}
위의 예에서는 ref를 항목 수만큼 미리 정의해 두었습니다. 그러나 목록의 각 항목에 대해 얼마나 많은 ref가 필요한지 알 수 없는 경우도 있습니다. 이런 경우에는 제대로 작동하지 않을 것입니다:
<ul>
{items.map((item) => {
// Doesn't work! 작동하지 않습니다!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
이는 훅은 컴포넌트의 최상위 레벨에서만 호출해야 하기 때문입니다. 반복문 또는 map() 내부에서는 useRef 를 호출할 수 없습니다.
이 문제를 해결할 수 있는 한 가지 방법은 부모 엘리먼트에 대한 단일 ref를 가져온 다음 querySelectorAll 과 같은 DOM 조작 메서드를 사용하여 개별 하위 노드를 “찾는” 것입니다. 하지만 이 방법은 DOM 구조가 변경되면 깨질 수 있습니다.
또 다른 해결책은 ref 속성에 함수를 전달하는 것입니다. 이를 ref 콜백이라고 합니다. React는 ref를 설정할 때가 되면 DOM 노드로, 지울 때가 되면 null 로 ref 콜백을 호출할 것입니다. 이를 통해 자신만의 배열이나 Map을 유지 관리하고, 인덱스나 일종의 ID로 모든 ref에 접근할 수 있습니다.
다음 예제는 이러한 접근으로 긴 목록에서 임의 노드로 스크롤하는 방법을 보여 줍니다.
import { useRef } from 'react';
export default function CatFriends() {
const itemsRef = useRef(null);
function scrollToId(itemId) {
const map = getMap();
const node = map.get(itemId);
node.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
function getMap() {
if (!itemsRef.current) {
// Initialize the Map on first usage.
itemsRef.current = new Map();
}
return itemsRef.current;
}
return (
<>
<nav>
<button onClick={() => scrollToId(0)}>
Tom
</button>
<button onClick={() => scrollToId(5)}>
Maru
</button>
<button onClick={() => scrollToId(9)}>
Jellylorum
</button>
</nav>
<div>
<ul>
{catList.map(cat => (
<li
key={cat.id}
ref={(node) => {
const map = getMap();
if (node) {
map.set(cat.id, node);
} else {
map.delete(cat.id);
}
}}
>
<img
src={cat.imageUrl}
alt={'Cat #' + cat.id}
/>
</li>
))}
</ul>
</div>
</>
);
}
const catList = [];
for (let i = 0; i < 10; i++) {
catList.push({
id: i,
imageUrl: 'https://placekitten.com/250/200?image=' + i
});
}
이 예제에서 itemsRef 는 단일 DOM 노드를 보유하지 않습니다. 대신 항목 ID에서 DOM 노드로의 Map을 보유합니다. (Ref는 모든 값을 보유할 수 있습니다) 모든 목록 항목의 ref 콜백은 맵을 업데이트합니다.
<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 노드를 읽을 수 있습니다.
<input /> 과 같은 브라우저 엘리먼트를 출력하는 빌트인 컴포넌트에 ref를 넣으면, React는 해당 ref의 current 프로퍼티를 해당 DOM 노드로 설정합니다.
그러나 <MyInput /> 과 같은 여러분이 만든 컴포넌트에 ref를 넣으려고 하면 기본적으로 null 이 반환됩니다. 다음은 이를 보여주는 예시입니다. 버튼을 클릭해도 input에 초점이 맞춰지지 않는 것을 확인할 수 있습니다.
import { useRef } from 'react';
function MyInput(props) {
return <input {...props} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
문제를 알아차리는 데 도움이 되도록 React는 콘솔에 오류를 출력하기도 합니다.
이는 기본적으로 React가 컴포넌트가 다른 컴포넌트의 DOM 노드에 접근하는 것을 허용하지 않기 때문입니다. 심지어 자신의 자식에게도 허용하지 않으며 이는 의도적인 것입니다. ref는 탈출구이기 때문에 아껴서 사용해야 합니다. 다른 컴포넌트의 DOM 노드를 수동으로 조작하면 코드가 훨씬 더 취약해집니다.
대신, DOM 노드를 노출하길 원하는 컴포넌트에 해당 동작을 설정해야 합니다. 컴포넌트는 자신의 ref를 자식 중 하나에 “전달”하도록 지정할 수 있습니다. MyInput 이 forwardRef API를 사용하는 방법은 다음과 같습니다.
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
작동 방식은 다음과 같습니다.
<MyInput ref={inputRef} />는 React에게 해당 DOM 노드를inputRef.current에 넣으라고 지시합니다. 그러나 이를 선택할지는MyInput컴포넌트에 달려 있으며, 기본적으로 그렇지 않습니다.MyInput컴포넌트를forwardRef를 사용하여 선언하면,props다음의 두 번째ref인수에 위의inputRef를 받도록 설정됩니다.MyInput은 수신한ref를 내부의<input>으로 전달합니다.
이제 버튼을 클릭하면 input에 초점이 맞춰집니다.
디자인 시스템에서 버튼, 입력 등과 같은 저수준 컴포넌트는 해당 ref를 DOM 노드로 전달하는 것이 일반적인 패턴입니다. 반면 양식, 목록 또는 페이지 섹션과 같은 상위 수준 컴포넌트는 일반적으로 DOM 구조에 대한 우발적 의존성을 피하기 위해 해당 DOM 노드를 노출하지 않습니다.
위의 예시에서 MyInput 은 원본 DOM input 엘리먼트를 노출합니다. 이를 통해 부모 컴포넌트가 이 요소에 focus() 를 호출할 수 있습니다. 그런데 이렇게 하면 부모 컴포넌트가 다른 작업을 할 수도 있습니다. 드문 경우지만 노출되는 기능을 제한하고 싶을 수도 있습니다. 그럴 땐 useImperativeHandle 을 사용하면 됩니다:
import {
forwardRef,
useRef,
useImperativeHandle
} from 'react';
const MyInput = forwardRef((props, ref) => {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
// Only expose focus and nothing else
focus() {
realInputRef.current.focus();
},
}));
return <input {...props} ref={realInputRef} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
여기서 MyInput 내부의 realInputRef 는 실제 input DOM 노드를 보유합니다. 하지만 useImperativeHandle 은 부모 컴포넌트에 대한 ref 값으로 고유한 특수 객체를 제공하도록 React에 지시합니다. 따라서 Form 컴포넌트 내부의 inputRef.current 에는 focus 메서드만 있게 됩니다. 이 경우 ref ”핸들”은 DOM 노드가 아니라 useImperativeHandle() 내부에서 생성한 사용자 정의 객체입니다.
React에서 모든 업데이트는 두 단계로 나뉩니다:
- 렌더링 하는 동안 React는 컴포넌트를 호출하여 화면에 무엇이 표시되어야 하는지 파악합니다.
- 커밋하는 동안 react는 DOM에 변경 사항을 적용합니다
일반적으로 렌더링 중에는 ref에 엑세스하는 것을 원하지 않을 것입니다. DOM 노드를 보유하는 ref도 마찬가지입니다. 첫 번째 렌더링 중에는 DOM 노드가 아직 생성되지 않았으므로 ref.current 는 null 이 됩니다. 그리고 업데이트를 렌더링하는 동안에는 DOM 노드가 아직 업데이트되지 않았습니다. 따라서 이를 읽기에는 너무 이르죠.
React는 커밋하는 동안에 ref.current 를 설정합니다. React는 DOM이 업데이트 되기 전에는 ref.current 의 값을 null 로 설정하였다가, DOM이 업데이트된 직후 해당 DOM 노드로 다시 설정합니다.
일반적으로 이벤트 핸들러에서 ref에 접근합니다. ref로 무언가를 하고 싶지만 그 작업을 수행할 특정 이벤트가 없다면, Effect가 필요할 수 있습니다. Effect에 대해서는 다음 페이지에서 설명하겠습니다.
다음과 같이 새 할일을 추가하고 목록의 마지막 하위 항목까지 화면을 아래로 스크롤하는 코드를 고려해 보세요. 어떤 이유에서인지 항상 마지막으로 추가한 할 일의 바로 앞에 있던 할 일로 스크롤되는 것을 볼 수 있습니다.
import { useState, useRef } from 'react';
export default function TodoList() {
const listRef = useRef(null);
const [text, setText] = useState('');
const [todos, setTodos] = useState(
initialTodos
);
function handleAdd() {
const newTodo = { id: nextId++, text: text };
setText('');
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
return (
<>
<button onClick={handleAdd}>
Add
</button>
<input
value={text}
onChange={e => setText(e.target.value)}
/>
<ul ref={listRef}>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</>
);
}
let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
initialTodos.push({
id: nextId++,
text: 'Todo #' + (i + 1)
});
}
문제는 바로 다음 두 줄입니다:
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();
React에서는 상태 업데이트가 큐에 등록됩니다. 일반적으로 이것은 사용자가 원하는 것입니다. 그러나 여기서는 setTodos 가 DOM을 즉시 업데이트하지 않기 때문에 문제가 발생합니다. 따라서 목록을 마지막 요소로 스크롤할 때 할 일이 아직 추가되지 않은 상태입니다. 이것이 스크롤이 항상 한 항목씩 “뒤처지는” 이유입니다.
이 문제를 해결하기 위해 React가 DOM을 동기적으로 업데이트하도록 강제할 수 있습니다. 이렇게 하려면 react-dom 에서 flushSync 를 import하고 상태 업데이트를 flushSync 호출로 감싸면 됩니다:
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
이렇게 하면 flushSync 로 감싼 코드가 실행된 직후 React가 DOM을 동기적으로 업데이트하도록 지시합니다. 그 결과 스크롤을 시도할 때 DOM에 이미 마지막 할 일이 있을 것입니다.
Ref는 탈출구입니다. “React 외부로 나가야” 할 때만 사용해야 합니다. 일반적인 예로는 초점을 맞추거나, 스크롤 위치를 관리하거나 React가 노출하지 않는 브라우저 API를 호출하는 것이 있습니다.
포커스나 스크롤같은 비파괴적 동작을 고수한다면 문제가 발생하지 않을 것입니다. 그러나 DOM을 수동으로 수정하려고 하면 React가 수행하는 변경 사항과 충돌할 위험이 있습니다.
다음 예시는 이 문제를 설명하기 위해 환영 메시지와 두 개의 버튼이 포함되어 있습니다. 첫 번째 버튼은 React에서 일반적으로 사용하는 것처럼 조건부 렌더링과 상태를 사용하여 그 존재여부를 전환합니다. 두 번째 버튼은 remove() DOM API를 사용하여 React가 제어할 수 없는 DOM에서 강제로 제거합니다.
“Toggle with setState”를 몇 번 눌러보세요. 메시지가 사라졌다가 다시 나타날 것입니다. 그런 다음 “Remove from the DOM”을 누르세요. 그러면 강제로 제거될 것입니다. 마지막으로 “Toggle with setState”를 눌러보세요:
import { useState, useRef } from 'react';
export default function Counter() {
const [show, setShow] = useState(true);
const ref = useRef(null);
return (
<div>
<button
onClick={() => {
setShow(!show);
}}>
Toggle with setState
</button>
<button
onClick={() => {
ref.current.remove();
}}>
Remove from the DOM
</button>
{show && <p ref={ref}>Hello world</p>}
</div>
);
}
DOM 엘리먼트를 수동으로 제거한 후 setState 를 사용하여 다시 표시하려고 하면 충돌이 발생합니다. 이는 사용자가 DOM을 변경했고 React가 이를 계속 올바르게 관리하는 방법을 모르기 때문입니다.
React가 관리하는 DOM 노드를 변경하지 마세요. React가 관리하는 요소를 수정하거나, 자식을 추가하거나 제거하면, 위와 같이 일관성 없는 시각적 결과나 충돌이 발생할 수 있습니다.
그렇다고 전혀 할 수 없다는 것은 아니고, 주의가 필요하다는 의미입니다. React가 업데이트할 이유가 없는 DOM의 일부는 안전하게 수정할 수 있습니다. 예를 들어, JSX에서 일부 <div> 가 항상 비어 있는 경우, React는 그 자식 목록을 건드릴 이유가 없습니다. 따라서 수동으로 요소를 추가하거나 제거하더라도 안전합니다.