리액트는 자동적으로 DOM을 렌더링 결과에 맞게 업데이트 하기 때문에 컴포넌트는 보통 DOM을 조작할 일이 없어요. 그러나 때때로 리액트가 관리하는 DOM 엘리먼트에 접근할 필요가 생겨요. 노드를 포커싱한다거나, 노드로 스크롤을 이동시켜야하거나, 크기와 위치를 측정해야할 때가 그 예에요. 리액트에는 이러한 것들을 해주는 내장 기능이 없기 때문에 ref로 DOM 노드를 지정해야해요.
이 페이지에서는
- 리액트가 관리하는 DOM 노드에ref
속성으로 어떻게 접근해야하는지
-ref
JSX 속성이useRef
훅과 어떻게 연관이 있는지
- 다른 컴포넌트의 DOM 노드에 어떻게 접근하는지
- 어떤 경우가 리액트로 관리되는 DOM 노드를 수정해도 안전한 경우인지
를 알아볼 거예요.
Getting a ref to the node | 노드에 ref 추가하기
리액트로 관리되는 DOM 노드에 접근하려면 useRef
훅을 불러와야해요.
import { useRef } from 'react';
그리고나서 컴포넌트 안에서 ref를 선언하세요.
const myRef = useRef(null);
마지막으로 조작하고 싶은 DOM 노드의 JSX 태그에 ref
속성으로 ref를 전달하세요.
<div ref={myRef}>
useRef
훅은 current
라는 속성만을 가진 객체를 반환해요. 처음에 myRef.current
는 null
이에요. 리액트가 이 <div>
로 DOM 노드를 생성할 때, 리액트는 myRef.current
안에 이 노드의 참조값을 넣어요. 그리고나면 이벤트 핸들러에서 이 DOM 노드에 접근할 수 있고, 해당 노드에 정의된 브라우저의 내장 API를 사용할 수도 있어요.
// 어떤 브라우저 API도 다 사용할 수 있어요.
myRef.current.scrollIntoView();
Example: Focusing a text input | 예시: 텍스트 입력창 포커싱하기
이 예시에서 버튼을누르면 입력창이 포커싱 돼요.
이를 구현하려면,
useRef
훅을 사용하여inputRef
를 선언하세요.<input ref={inputRef}>
로 ref를 전달하세요. 이 과정은 리액트에게 이<input>
의 DOM 노드를inputRef.current
에 넣으라고 말하는 과정이에요.handleClick
함수에서inputRef.current
를 통해 입력 DOM 노드를 읽고inputRef.current.focus()
를 사용해서focus()
를 호출하세요.- 이벤트 핸들러
handleCliuck
을onClick
으로<button>
에 전달하세요.
ref를 사용하는 가장 흔한 예시가 바로 DOM 조작이지만 useRef
훅은 timer ID와 같은 리액트 외부값을 저장하는데 사용할 수 있어요. 상태과 비슷하게 ref는 렌더링 동안에도 유지돼요. ref는 값이 설정되어도 리렌더링이 발생하지 않는 상태 변수에요. ref로 값 참조하기 문서에서 더 자세히 알아보세요.
Example: Scrolling to an element | 예시: 특정 요소로 스크롤하기
컴포넌트에서 한 개 이상의 ref를 사용할수도 있어요. 이 예시는 3장의 이미지를 가지는 캐러셀이에요. 각 버튼은 해당하는 DOM 노드에서 scrollIntoView()
메소드를 호출하여 이미지를 가운데로 오게해요.
ref 콜백을 사용하여 ref 목록을 조작하는 방법
더보기위의 예시에서 미리 정의되어있던 ref가 있었어요. 그러나 때때로 리스트의 각 항목마다 ref를 정의해야할 수도 있고 몇개의 항목을 가질지도 모르는 상황이 생길 거예요. 아래와 같이 작성하면 작동하지 않아요.<ul> {items.map((item) => { // 작동하지 않아요! const ref = useRef(null); return <li ref={ref} />; })} </ul>
왜냐하면 훅은 컴포넌트의 최상위에서만 호출할 수 있기 때문이에요.
useRef
를 반복문, 조건문 혹은map()
호출 안에서 사용할 수 없어요.
한 가지 방법은 부모 엘리먼트에 ref를 추가하고 이 엘리먼트에서 자식 노드들을 "찾기" 위하여querySelectorAll
과 같은 DOM 조작 메서드를 사용하는 거예요. 하지만 이는 다루기 힘들고 DOM 구조가 변하면 오류가 발생할 수 있어요.
다른 방법은ref
속성에 함수를 전달하는 것이에요. 이를ref
콜백이라고 불러요. 리액트는 ref를 설정해야할 때는 DOM 노드로, ref를 해제해야할 떄는null
로 ref 콜백을 호출해요. 이렇게 하면 배열이나 맵을 유지하면서 인덱스나 ID로 ref에 접근할수 있도록 해줘요.
이러한 방법을 긴 리스트에서 임의의 노드로 스크롤하는 방법에 어떻게 사용하는지 보여줄게요.
이 예시에서itemsRef
는 하나의 DOM 노드를 갖고 있지 않아요. 대신 각 항목의 ID에서 DOM 노드로의 맵을 갖고 있어요. (ref는 어떤 값이든 갖고 있을 수 있어요!) 모든 항목의ref
콜백은 맵을 업데이트 하는데 사용돼요.<li key={cat.id} ref={node => { const map = getMap(); if (node) { // 맵에 추가하기 map.set(cat.id, node); } else { // 맵에서 제거하기 map.delete(cat.id); } }} >
이렇게 하면 나중에 맵에서 DOM 노드를 개별적으로 읽어올 수 있어요.
Accessing another component’s DOM nodes | 다른 컴포넌트의 DOM 노드에 접근하기
<input />
과 같은 브라우저 엘리먼트를 결과물로 보여주는 내장 컴포넌트에 ref를 추가하면, 리액트는 ref의 current
속성을 (브라우저의 실제 <Input />
과 같이) 해당하는 DOM 노드로 설정해요.
그러나 만약 <MyInput />
과 같이 직접 만든 컴포넌트에 ref를 추가한다면 기본값은 null
이에요. 아래는 이를 증명하는 예시에요. 버튼을 클릭해도 입력창에 포커싱되지는 않아요.
이슈를 파악할 수 있도록 리액트는 콘솔창에 에러메시지를 보여줘요.
콘솔
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
경고: 함수 컴포넌트는 ref를 받을 수 없어요. 이 ref에 접근하려는 시도는 실패했어요. React.forwardRef()를 사용하려고 했나요?
기본적으로 리액트는 다른 컴포넌트의 DOM 노드에 접근하지 못하게 하기 때문에 이런 문제가 발생해요. 설령 자식 컴포넌트더라도요! 이는 의도적이에요. ref는 아껴서 사용해야하는 해치 탈출구에요. 직접 다른 컴포넌트의 DOM 노드를 조작하는 것은 코드를 위험하게 만들어요.
대신, DOM 노드를 노출하고 싶은 컴포넌트는 해당 행동을 선택해야해요. 컴포넌트는 자식 컴포넌트 중 하나에 ref를 "전송하도록" 지정할 수 있어요. MyInput
이 forwardRef
API를 사용하는 방법이에요.
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
위의 코드는 이렇게 작동해요.
<MyInput ref={inputRef} />
는 리액트가inputRef.current
에 해당하는 DOM 노드를 넣도록 만들어줘요. 그러나 이 행동을 수행할지는MyInput
컴포넌트의 선택에 달려있고, 기본적으로는 하지 않아요.MyInput
컴포넌트는forwardRef
를 사용하여 선언되었어요. 이는 props 뒤에 선언된 두 번째ref
인자로 상위에서inputRef
를 받도록 선택해요.MyInput
스스로<input>
에 전달받은ref
를 다시 전달해요.
이제 버튼을 누르면 입력창이 포커싱돼요.
디자인 시스템에서 DOM 노드에 ref를 전달하는 방식은 버튼이나 입력창과 같은 낮은 레벨의 컴포넌트에서 흔히 사용되는 패턴이에요. 반면, 폼이나 리스트 혹은 페이지 섹션과 같은 고레벨의 컴포넌트는 DOM 구조에서 의도되지 않은 의존성을 피하기 위해 보통 그들의 DOM 노드를 노출하지 않아요.
명령형 핸들을 사용하여 API의 일부 노출하기
더보기위의 예시에서MyInput
은 기본적인 DOM의 인풋 엘리먼트를 보여줘요. 이는 부모 컴포넌트가 이 노드에focus()
를 호출할 수 있도록 만들어줘요. 그러나 이는 부모 컴포넌트가 CSS 스타일을 변경하는 것과 같은 다른 행동들을 하도록 허용하는 거예요. 드물게, 노출된 기능을 제한하고 싶을 수 있어요.useImperativeHandle
을 사용하면 제한할 수 있어요.
여기서,MyInput
안에 있는realInputRef
는 실제 인풋 돔 노드를 갖고있어요. 그러나useImperativeHandle
은 리액트가 ref의 값으로 특별한 객체를 부모 컴포넌트에 제공할 것을 지시해요. 그래서Form
컴포넌트의inputRef.current
는focus
메서드만을 갖고 있어요. 이런 경우에는 ref "핸들"이 DOM 노드가 아니라useImperativeHandle
호출에서 만들어진 커스텀 객체가 돼요.
When React attaches the refs | 리액트가 레프를 붙일 때
리액트에서 모든 업데이트는 두 단계로 분리할 수 있어요.
- 렌더링하는 동안 리액트는 화면에 보여줄 것을 찾아내기 위해 컴포넌트를 호출해요.
- 커밋하는 동안 리액트는 DOM 노드의 변경 사항을 적용해요.
일반적으로는 렌더링 동안 ref에 접근하고 싶지 않을 거예요. 이는 ref가 DOM 노드를 갖고 있는 것에도 동일하게 적용돼요. 첫 렌더링 동안 DOM 노드는 아직 생성되지 않았기 때문에 ref.current
는 null
이에요. 그리고 업데이트의 렌더링 동안 DOM 노드는 아직 업데이트 되지 않았어요. 이들을 읽기에는 너무 일러요.
리액트는 ref.current
를 커밋하는 동안 설정해요. DOM을 업데이트하기 전, 리액트는 영향을 받는 ref.current
값을 null
로 설정해요. DOM을 업데이트 한 후에는 리액트가 즉시 이들을 해당하는 노드로 설정해요.
보통은 이벤트 핸들러에서 ref에 접근할 거예요. 만약 ref로 다른 무언가를 하고 싶지만 그 작업이 특정 이벤트가 아니라면 Effect를 사용하세요. 이후 페이지들에서 이펙트에 대해 이야기 할 거예요.
flushSync로 상태 업데이트를 동기적으로 플러시하기더보기새로운 할 일을 추가하고 목록의 마지막 자식으로 화면을 스크롤하는 코드를 살펴볼게요. 여러 이유로 마지막에 추가된 항목의 바로 직전 할일로 스크롤되는 것을 확인할 수 있어요.
이슈는 아래 두 줄에서 발생해요.
setTodos([ ...todos, newTodo]); listRef.current.lastChild.scrollIntoView();
리액트에서 상태 업데이트는 대기열로 들어가요. 일반적으로는 여러분들이 하고 싶은 일은 바로 이거예요. 그러나
setTodos
가 즉시 DOM을 업데이트하지 않기 때문에 문제가 발생해요. 따라서 마지막 엘리먼트로 목록을 스크롤하는 때에 todo는 아직 추가되지 않았어요. 스크롤링이 언제나 한 항목 뒤쳐진 이유에요.
이를 고치려면 React가 DOM을 동기적으로 업데이트(플러시)하도록 만들어야해요. 이를 위해서는react-dom
에서flushSync
를 불러오고flushSync
호출에 상태 업데이트를 감싸야해요.flushSync(() => { setTodos([ ...todos, newTodo]); }); listRef.current.lastChild.scrollIntoView();
이렇게하면 리액트는flushSync
에 감싸진 코드가 실행된 직후에 DOM을 동기적으로 업데이트해요. 결론적으로 마지막 할 일은 스크롤을 하는 순간에 DOM에 이미 들어가 있어요.
Best practices for DOM manipulation with refs | ref로 DOM을 조작하는 최적의 사용방법
ref는 해치 탈출구에요. "리액트 밖의 과정"이 필요할 때만 사용해야해요. 이의 일반적인 예시는 포커싱이나 위치 스크롤링을 조작하기 또는 리액트에서는 사용할 수 없는 브라우저 API 호출하기 등이 있어요.
포커싱이나 스크롤링과 같이 비파괴적인 행동을 고수하면 어떤 문제와도 마주하지 않을거예요. 하지만 만약 DOM을 직접 수정하려고 한다면 리액트가 만든 변경사항들과 충돌할 위험이 있어요.
이 문제를 설명하기 위해 아래의 예시는 환영 메시지와 두 개의 버튼을 포함하고 있어요. 첫 번째 버튼은 보통 리액트에서 하는 것처럼 조건부 렌더링과 상태를 사용하여 버튼의 유무를 변경할 수 있어요. 두 번째 버튼은 리액트의 조작 범위 외부에 있는 DOM에서 강제로 버튼을 제거하기 위해 remove()
DOM API를 사용하고 있어요.
"setState로 토글하기"를 몇 번 눌러보세요. 메시지는 사라지고 나타나기를 반복해요. 그리고 나서 "DOM에서 제거하기"를 누르세요. 강제로 버튼을 제거할 거예요. 마지막으로 "setState로 토글하기"를 누르세요.
직접 DOM 엘리먼트를 제거한 후 setState
를 사용하여 다시 보이도록 하려면 충돌이 발생해요. 왜냐하면 DOM 노드를 이미 변경했고 리액트는 이를 알맞게 조작하는 방법을 모르기 때문이에요
.
리액트로 관리되는 DOM 노드를 바꾸는 것을 피하세요. 리액트가 관리하는 엘리먼트 수정하기, 엘리먼트에 자식 추가하기, 엘리먼트에서 자식 제거하기 등은 일관적이지 않은 시각적인 결과는 가져오거나 위와 같은 충돌을 유발해요.
그러나 이 말이 절대 이 방법을 사용하지 말라는 말은 아니에요. 주의가 필요할 뿐이에요. 리액트가 업데이트할 이유가 없는 DOM의 일부는 안전하게 수정할 수 있어요. 예를 들어 만약 어떤 <div>
가 항상 JSX에서 비어있다면 리액트는 그 자식 리스트를 건들 필요가 없어요. 따라서 여기에 수동으로 엘리먼트를 추가하거나 제거하는 것을 안전해요.
Recap | 요약
- ref는 일반적인 콘셉트이지만 대부분은 DOM 엘리먼트를 갖고 있는데 사용할 거예요.
<div ref={myRef}>
를 전달하여myRef.current
에 DOM 노드를 추가하도록 리액트에 지시해요.- 보통은 DOM 노드를 포커싱하고, 스크롤하고, 측정하는 비파괴적인 작업에 ref를 사용해요.
- 컴포넌트는 기본적으로 DOM 노드를 노출하지 않아요.
forwardRef
를 사용하여 특정 노드에 두 번째 `ref 인자를 전달하여 DOM 노드를 노출할지를 선택할 수 있어요. - 리액트가 관리하는 DOM 노드를 변경하는 것을 삼가세요.
- 만약 리액트가 관리하는 DOM 노드를 수정한다면 리액트가 업데이트하지 않을 부분을 수정하세요.
Challenges | 도전 과제
1. 영상을 재생하고 일시정지하기
이 예시에서 버튼은 상태 변수를 재생이나 일시정지 상태에서 바꾸도록 토글해요. 그러나 실제로 영샹을 재생하거나 일시정지하기 위해서 상태를 토글하는 것만으로는 충분하지 않아요. play()
와 pause()
를 <video>
DOM 노드에서 호출해야해요. 이를 ref에 추가하고 버튼이 작동하도록 만드세요.
추가적인 과제로 사용자가 비디오에서 오른쪽 클릭을 하여 내장 브라우저 미디어 컨트롤을 사용하여 영상을 재생해도 "재생" 버튼이 비디오가 재생 중인지와 동기화되도록 유지하세요. 이를 수행하기 위해 비디오에서 onPlay
와 onPause
를 감시하도록 할 수 있어요.
Solution
ref를 선언하고 <video>
엘리먼트에 ref를 넣으세요. 그리노가서 ref.current.play()
와 ref.current.pause()
를 다음 상태에 맞춰서 이벤트 핸들러 안에서 호출하세요.
내장된 브라우저 컨트롤을 다루려면 <video>
엘리먼트에 onPlay
와 onPause
핸들러를 추가하고 여기에서 setIsPlaying
을 호출하세요. 이 방법으로 만약 사용자가 브라우저 컨트롤을 사용하여 영상을 재생하면 상태가 이에 맞춰 적용될 거예요.
2. 검색창 포커싱 하기
"Search" 버튼을 누르면 필드에 포커싱이 되도록 만들어보세요.
Solution
입력창에 ref를 추가하고 DOM 노드에서 focus()
를 호출하세요.
3. 이미지 캐러셀 스크롤하기
이 이미지 캐러셀은 활성화된 이미지를 변경하는 "Next" 버튼을 갖고 있어요. 클릭하면 갤러리가 활성화된 이미지로 스크롤하도록 만드세요. 활성화된 이미지의 DOM 노드에서 scrollIntoView()
를 호출하고 싶을 거예요.
node.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
Hint
이 과제에서 모든 이미지에 ref를 추가할 필요는 없어요. 현재 활성화된 이미지나 목록 자체만 ref를 갖고 있어도 충분해요. flushSync
를 사용하여 스크롤 하기 전에 DOM 노드가 업데이트 되도록 만드세요.
Solution
selectedRef
를 선언하고 현재 이미지에만 이것을 조건부로 전달하세요.
<li ref={index === i ? selectedRef : null}>
이미지가 선택되었다는 의미로 index === i
가 true라면 <li
>는 selectedRef
를 받아요. 리액트는 selectedRef.current
가 항상 현재 DOM 노드를 가리킨다고 생각해요.
flushSync
호출은 리액트가 DOM 노드를 스크롤 하기 전에 업데이트 하도록 만들기 위해 필수적이라는 사실을 기억하세요. 이를 사용하지 않으면 selectedRef.current
는 이전에 선택된 항목을 가리킬 거예요.
4. 다른 컴포넌트로 입력창 포커싱 하기
"Search" 버튼을 누르면 필드에 포커싱이 추가되도록 만드세요. 각 컴포넌트는 다른 파일에 정의되어 있고 밖으로 나갈 수 없음에 유의하세요. 이들을 어떻게 연결할까요?
Hint
SearchInput
과 같이 직접 만든 컴포넌트의 DOM 노드를 노출하려면 forwardRef
가 필요해요.
Solution
onClick
prop을 SearchButton
에 추가하고 SearchButton
는 브라우저 <button>
에 이를 전달해야해요. 또한 ref를 <SearchInput>
에 전달하여 실제 <input>
에 전달하고 채워야해요. 마지막으로 클릭 핸들러에서 ref 안에 저장된 DOM 노드로 focus
를 호출하세요.