2023. 12. 12. 20:56ㆍReact
리렌더링을 일으키지 않고 정보를 "기억하는" 방법을 알아보자.
컴포넌트가 특정 정보를 기억하도록 하고 싶지만
그 정보가 새로운 렌더링을 촉발하지 않게 하려면 ref를 사용하면 된다!
📄 Docs
0. useRef
useRef(initialValue)
컴포넌트의 최상위 레벨에서 useRef를 호출해서 ref를 선언한다.
import { useRef } from 'react';
const ref = useRef(0);
useRef가 반환하는 객체
{
current: 0 // The value you passed to useRef
}
ref.current
속성을 통해 ref의 현재 값에 접근할 수 있다.- 이것은 React가 추적하지 않는 컴포넌트의 비밀 주머니 ㅋㅋㅋ 와 같다..
- ref는 React의 단방향 데이터 흐름에서 escape hatch가 된다!
1. Parameters
initialValue
- ref 객체의 current 프로퍼티에 들어갈 초기 설정 값이다.
- 어떤 유형의 값이든 지정할 수 있다.
- 초기 렌더링 이후부터는 무시된다.
Returns
- 단일 프로퍼티인 current를 가진 객체를 반환한다.
- current의 값은 처음에는 전달한 initialValue가 되고, 나중에 다른 값으로 바꿀 수 있다.
- 반환 받은 이 객체 ref를 JSX 노드의 ref 속성으로 전달하면 JSX 노드가 current에 담긴다.
- 다음 렌더링에서도 useRef는 동일한 객체를 반환한다.
2. 주의사항
- 초기화를 제외하고는 렌더링 중에 ref.current를 읽거나 쓰면 안된다. (컴포넌트의 동작을 예측할 수 없게 되기 때문)
- Stric Mode에서는 컴포넌트 함수가 두 번씩 호출되어 의도하지 않은 동작을 찾는 데 도움을 준다.
ref 객체도 두 번 생성되고 그 중 하나는 버려진다. 컴포넌트 함수는 순수해야 하므로 이것이 로직에 영향을 미치지는 않는다.
2-1. state와의 차이
- ref의 current 속성은 state와 달리 읽고 수정할 수 있는 일반 JS 객체이다.
단, 렌더링에 사용되는 객체(ex. state의 일부)를 포함하는 경우에는 변이해서는 안된다. - ref.current가 변경되어도 리렌더가 일어나지 않는다.
- 리렌더링되어도 값이 유지되는 것은 state와 동일하다.
refs | state | |
---|---|---|
사용법 | useRef() 는 { current: initialValue } 을 반환 |
useState() 는 state 변수의 현재값과 state 설정자함수([value, setValue])를 반환 |
리렌더링 | 변경 시 리렌더링을 촉발하지 않음 | 변경 시 리렌더링을 촉발함 |
변경 가능성 | Mutable — 렌더링 프로세스 외부에서 current 값을 수정하고 업데이트할 수 있음 | Immutable — setState를 사용해서 state 변수를 수정해 리렌더링을 대기열에 추가해야함 |
값 접근 | 렌더링 중에는 current 값을 읽거나 쓰지 않아야 함 | 언제든지 state를 읽을 수 있음. 각 렌더링에는 변경되지 않는 자체 state snapshot이 있음 |
👉 렌더링에 보여줘야 하는 값은 state로,
렌더링에 사용되지 않는 값은(값이 바뀌어도 리렌더링될 필요 없는 경우) ref로 보관하는 것이 더 효율적일 수 있다.
3. 사용법
3-1. ref를 사용하면 다른점
state VS ref
- ref는 변경되어도 리렌더를 촉발하지 않는다. 따라서 ref는 컴포넌트의 UI에 영향을 미치지 않는 정보를 저장하는 데 적합하다.
일반 변수 VS ref
- 렌더링할 때마다 재설정되는 일반 변수와 달리 정보가 유지된다.
외부 변수 VS ref
- 정보가 공유되는 외부 변수와 달리 각 컴포넌트에 로컬로 저장된다.
3-2. ref에 올바르게 접근하기
컴포넌트는 순수 함수처럼 동작해야 하므로, 렌더링 중에 ref를 읽거나 쓰면 안된다!
컴포넌트 최상위 레벨에서 ref에 접근하는 코드가 있으면 안 된다는 뜻
(렌더링 중에 읽거나 써야만 한다면, ref 대신 state를 쓰자!)
👇 이렇게 하면 안됨
function MyComponent() {
// ...
// 🚩 Don't write a ref during rendering
myRef.current = 123;
// ...
// 🚩 Don't read a ref during rendering
return <h1>{myOtherRef.current}</h1>;
}
대신 이벤트 핸들러나 useEffect 안에서 읽거나 쓰자
function MyComponent() {
// ...
useEffect(() => {
// ✅ You can read or write refs in effects
myRef.current = 123;
});
// ...
function handleClick() {
// ✅ You can read or write refs in event handlers
doSomething(myOtherRef.current);
}
// ...
}
3-3. DOM 조작
React는 DOM을 자동으로 업데이트하므로 컴포넌트가 DOM을 자주 조작할 필요는 없지만,
때로 노드에 포커싱하거나 스크롤, 크기나 위치 측정을 위해 DOM 요소에 접근해야 할 수도 있다.
React에는 이러한 작업을 수행할 수 있는 내장 기능이 없으므로 DOM 노드에 대한 ref가 필요하다.
ref로 DOM을 조작하는 것은 일반적이다. React에는 이를 위한 빌트인 기능도 있다.
1) 초기값이 null인 ref 객체를 생성한다.
import { useRef } from 'react';
function MyComponent() {
const inputRef = useRef(null);
// ...
2) 조작하려는 DOM 노드의 JSX에 ref를 전달한다.
DOM 노드를 가져올 JSX 태그에 ref
속성으로 참조를 전달한다.
return <input ref={inputRef} />;
3) 끝!
쓰면 됨
처음에는 inputRef.current는 null이 된다.
React가 DOM 노드를 생성하고 화면에 배치한 후, ref 객체의 current 속성이 이 DOM 노드로 설정된다.
function handleClick() {
inputRef.current.focus();
}
노드가 화면에서 제거되면 ref의 current는 다시 null이 된다.
ref 콜백
ref가 몇 개 필요한지 모를 때는 사용법
아래 예시는 목록의 각 항목에 ref를 전달하려 하고 있다.
<ul>
{items.map((item) => {
// Doesn't work!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
But, 훅은 컴포넌트의 최상위 레벨에서만 호출해야 하기 때문에 useRef를 반복문 안에서 호출할 수는 없다.
이를 해결하기 위해 ref 속성에 함수를 전달하는 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
});
}
3-4. Ref의 내용 재생성 피하기
- React는 초기에 ref 값을 한 번 저장하고 다음 렌더링부터는 이를 무시한다.
function Video() {
const playerRef = useRef(new VideoPlayer());
// ...
이 코드에서 new VideoPlayer()
의 결과는 초기 렌더링에만 사용되지만,
호출 자체는 이후의 모든 렌더링에서도 여전히 계속 이뤄진다.
useRef가 초기값을 첫 마운트 때만 사용한다는 것은, 이 부분(new VideoPlayer()
)이 호출되지 않는다는 뜻이 아니다!
렌더링마다 실행은 매번 되어 인스턴스를 매번 생성한다.
만들어놓고 쓰지는 않음..ㅋㅋ
생성하는 데 비용이 발생하므로 좋지 않다.
이 문제를 해결하려면 아래와 같이 초기화하면 된다.
function Video() {
const playerRef = useRef(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
// ...
원래 이렇게 렌더링 중에 ref.current를 쓰거나 읽으면 안되지만,
이 경우에는 결과가 항상 동일하고 초기화 중에만 조건이 실행되어 충분히 예측이 가능하므로 괜찮다.
3-5. ref의 작동 방식
useRef는 useState로 구현할 수 있다.
// Inside of React
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
useRef는 항상 동일한 객체를 반환하기 때문에 state 설정자는 사용되지 않는다.
즉, ref는 setter가 없는 일반 state 변수로 생각할 수 있다.
하지만 useRef를 기본적으로 제공해주므로, 이딴식으로 쓰지 말고 useRef를 쓰도록 ^ㅁ^
3-6. 언제 쓰나요
일반적으로 React의 범위를 벗어나서 외부 API와 통신할 때 사용한다.
주로 컴포넌트의 모양에 영향을 주지 않는 브라우저 API 등과 통신할 때 사용한다.
- 타임아웃 ID 저장
- DOM 요소 저장 or 조작
- JSX 계산에 필요하지 않은 다른 객체들 저장할 때 쓴다.
컴포넌트에 일부 값을 저장해야 하지만 렌더링 로직에는 영향을 미치지 않는 경우 ref를 쓰자.
이러한 상황에서 React의 일반적인 데이터 흐름 내에서 정보를 추적하기보다는
ref를 사용해 정보를 직접적으로 관리하는 것이 더 효율적일 수 있다.
3-7. ref 모범사례
아래의 원칙을 따라 컴포넌트의 예측 가능성을 높일 수 있다.
1) ref를 escape hatch로 취급하자!
앱의 주요 로직과 데이터 흐름이 ref에 크게 의존한다면, 접근 방식을 재고해볼 필요가 있다.
2) 렌더링 중에는 읽거나 쓰지 말자
React는 ref.current가 언제 변경되는지 알 수 없기 때문에, 렌더링 중에 ref.current를 읽는 것은 컴포넌트의 동작을 예측하기 어렵게 만든다.
예외) 아래처럼 첫 렌더링 중에만 ref를 설정하는 것은 ㄱㅊ
if (!ref.current) ref.current = new Thing()
state는 동기적으로 업데이트되지 않지만, ref는 값을 변이하면 즉시 변경된다.
ref.current = 5;
console.log(ref.current); // 5
3) ref를 이용한 DOM 조작 모범 사례
포커싱이나 스크롤처럼 비파괴적인 동작에는 문제가 없다.
그러나 DOM을 수동으로 수정하려 하면 React가 수행하는 변경 사항과 충돌할 위험이 있다..!!
예를 들어, DOM 엘리먼트를 수동으로 제거(remove()
)하고 setState를 통해 다시 표시하려고 하는 경우 충돌이 발생한다.
따라서 React가 관리하는 DOM 노드를 변경할 때는 주의가 필요하다.
위 상황과 달리 React가 업데이트할 이유가 없는 DOM 요소는 안전하게 수정할 수 있다.
3-8. ref는 언제 첨부되나요?
React에서 모든 업데이트는 두 단계로 나뉜다.
- 렌더링: 렌더링하는 동안 컴포넌트를 호출하여 화면에 무엇이 표시되어야 하는지 파악한다.
- 커밋: 커밋하는 동안 DOM에 변경 사항을 적용한다.
첫 번째 렌더링을 할 때는 DOM 노드들이 아직 생성되지 않았기 때문에 ref.current는 null이 된다.
그리고 업데이트의 렌더링 중에는, DOM 노드들이 아직 업데이트되지 않았기 때문에 아직 읽기에는 너무 이르다.
따라서 React는 commit 중에 ref.current를 설정한다!
DOM을 업데이트한 후, 즉시 ref.current 값을 해당 DOM 노드로 설정한다.
보통 이벤트 핸들러에서 ref에 접근하는데, 마땅한 특정 이벤트가 없다면 Effect가 필요할 수 있다.
flushSync
flushSync는 React의 DOM 렌더러 내에서 제공하는 API로, 동기적으로 상태 업데이트를 강제로 수행할 때 사용된다.
일반적인 React의 상태 업데이트는 비동기적으로 동작하는데, 상태 변경을 즉시 반영하고 싶을 때 flushSync를 사용한다.
React에서는 state 업데이트가 큐에 등록되므로 DOM을 즉시 업데이트하지 않아 종종 문제가 발생할 수 있다.
목록에 새 항목을 추가하고 마지막 항목으로 스크롤하려면 아래 예시처럼 flushSync를 사용해서 동기적으로 업데이트를 해줘야 원하는 동작이 가능하다.
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
Troubleshooting
다른 컴포넌트의 DOM 노드에 접근하기
<input/>
같은 브라우저 요소를 출력하는 빌트인 컴포넌트에 ref를 넣으면 잘 작동하지만,
컴포넌트에 ref를 넣으려고 하면 null이 반환되고 원하는 동작을 하지 않는다.
콘솔에 이런 오류도 뜸 👇
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
Check the render method of `MyForm`.
at MyInput
at MyForm (https://1e4ad8f7.sandpack-bundler-4bw.pages.dev/App.js:24:38)
이유는
기본적으로 컴포넌트가 다른 컴포넌트의 DOM 노드에 접근하는 것을 허용하지 않기 때문이다.
대신, 자신의 DOM 노드를 공개하고 싶은 컴포넌트는 자신의 자식 중 하나에게 ref를 전달한다고 forwardRef
로 명시할 수 있다.
컴포넌트를 forwardRef를 사용해서 선언하면, props 다음의 두 번째 ref 인자로 전달 받은 ref를 받도록 설정된다.
import { forwardRef, useRef } from 'react';
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
내부 DOM 노드에 대한 접근 제한하기
부모로부터 받은 ref에 대한 사용자 정의 인터페이스 제공하기
위 예시에서, 부모 컴포넌트가 MyInput에 focus()를 호출할 수 있게 되었다.
그런데 이렇게 하면 부모 컴포넌트가 MyInput에 대해 다른 작업도 할 수 있게 된다.
그렇지 못하게 제한하고 싶다면 useImperativeHandle
을 사용하면 된다.
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} />;
});
이렇게 하면 부모 컴포넌트에 대한 ref 값으로 특별한 객체를 제공하도록 React에 지시해서
focus 메서드만 가지게 만든다.
이때 ref 핸들은 DOM 노드가 아닌, useImperativeHandle
내부에서 생성한 사용자 정의 객체다.
이를 통해 명시적인 메서드나 속성만을 제공할 수 있다.
useImperativeHandle에 대해 자세히 알아보자 ㅎㅎ
useImperativeHandle
ref로 노출되는 핸들을 직접 정의할 수 있게 해주는 훅이다.
Parameters
ref
- forwardRef 함수에서 두 번째 인자로 받은 ref
createHandle
- 노출하려는 ref 핸들을 반환하는 함수
- 인자는 없다.
- 어떤 방식으로 작성하든 상관 없지만, 일반적으로 노출하려는 메서드가 있는 객체를 반환한다.
(optional) dependencies
- createHandle 코드 내에서 참조하는 모든 반응형 값을 나열한 목록
- 이 반응형 값은 props, state 및 컴포넌트 내에서 직접 선언한 모든 변수와 함수를 포함한다.
- 이것도 역시나 린터가 올바르게 의존성을 지정했는지 확인해줌 👍
- 이 의존성이 변경되거나 인수를 생략하면 createHandle 함수가 다시 실행되고 새로 생성된 핸들이 ref에 할당된다.
Returns
- undefined를 반환한다.
Usage
부모 컴포넌트에 커스텀 ref 핸들 노출
- 기본적으로 컴포넌트는 자식 컴포넌트의 DOM 노드를 부모 컴포넌트에 노출하지 않는다.
- 따라서 부모 컴포넌트가 자식의 DOM 노드에 접근하려면 forwardRef를 사용해서 선택적으로 참조에 포함시켜줘야 한다.
import { forwardRef } from 'react';
const MyInput = forwardRef(function MyInput(props, ref) {
return <input {...props} ref={ref} />;
});
- 위 코드에서는 ref로
<input>
의 DOM 노드를 받게 되는데, 커스텀한 값을 노출할 수 있다. - 이것을 커스텀하려면 useImperativeHandle을 사용하면 되는 것!
import { forwardRef, useImperativeHandle } from 'react';
const MyInput = forwardRef(function MyInput(props, ref) {
useImperativeHandle(ref, () => {
return {
// ... your methods ...
};
}, []);
return <input {...props} />;
});
- 이렇게 작성하면 이제 ref에
<input>
이 전달되지 않게 된다.
focus와 scrollIntoView만 노출시켜보자
그러기 위해서는 실제 DOM을 별도의 ref에 유지해야 한다.
import { forwardRef, useRef, useImperativeHandle } from 'react';
const MyInput = forwardRef(function MyInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => {
return {
focus() {
inputRef.current.focus();
},
scrollIntoView() {
inputRef.current.scrollIntoView();
},
};
}, []);
return <input {...props} ref={inputRef} />;
});
사용자 정의 명령 노출
- imperative handle을 통해 노출하는 메서드가 DOM 메서드일 필요는 없다.
아래의 Post 컴포넌트는 imperative handle을 통해 ScrollAndFocusAddComment
메서드를 표시한다.
const Post = forwardRef((props, ref) => {
const commentsRef = useRef(null);
const addCommentRef = useRef(null);
useImperativeHandle(ref, () => {
return {
scrollAndFocusAddComment() {
commentsRef.current.scrollToBottom();
addCommentRef.current.focus();
}
};
}, []);
return (
<>
<article>
<p>Welcome to my blog!</p>
</article>
<CommentList ref={commentsRef} />
<AddComment ref={addCommentRef} />
</>
);
});
Pitfall
ref를 과도하게 사용하지 말 것!
- ref는 props로 표현할 수 없는 필수적인 행동에만 써야 한다.
ex) 특정 노드로 스크롤하기, 초점 맞추기, 애니메이션 촉발하기, 텍스트 선택하기 등 - props으로 표현할 수 있는 것은 ref를 사용하지 말아야 한다.
ex) Modal 컴포넌트에서 {open, close}와 같은 imperative handle을 노출하는 대신 isOpen 등의 prop을 사용하고, Effect를 통해 명령형 동작을 노출할 수 있다.
'React' 카테고리의 다른 글
왜 Hooks는 컴포넌트의 최상위 레벨에서 호출해야 하나요? (1) | 2023.12.29 |
---|---|
[Redux] Redux, React Redux, Redux DevTools (1) | 2023.10.03 |
[React] Router (version 6) (0) | 2023.10.02 |