컴포넌트가 어떤 정보를 "기억"하게 하고 싶지만, 그 정보가 새로운 렌더링을 트리거하지 않길 원한다면, ref를 사용할 수 있어요.
React에서 useRef Hook을 import해서 컴포넌트에 ref를 추가할 수 있어요:
import { useRef } from 'react';
컴포넌트 내부에서 useRef Hook을 호출하고, 참조하고 싶은 초기값을 유일한 인자로 전달하면 돼요. 예를 들어, 여기 값 0에 대한 ref가 있어요:
const ref = useRef(0);
useRef는 이런 객체를 반환해요:
{
current: 0 // useRef에 전달한 값
}
ref.current 프로퍼티를 통해 해당 ref의 현재 값에 접근할 수 있어요. 이 값은 의도적으로 변경 가능(mutable)해요, 즉 읽기와 쓰기가 모두 가능하다는 뜻이에요. React가 추적하지 않는 컴포넌트의 비밀 주머니 같은 거죠. (이게 바로 React의 단방향 데이터 흐름에서 벗어나는 "탈출구"가 되는 이유예요—아래에서 더 자세히 설명할게요!)
여기서 버튼을 클릭할 때마다 ref.current가 증가해요:
import { useRef } from 'react';
export default function Counter() {
let ref = useRef(0);
function handleClick() {
ref.current = ref.current + 1;
alert('You clicked ' + ref.current + ' times!');
}
return (
<button onClick={handleClick}>
Click me!
</button>
);
}
ref는 숫자를 가리키고 있지만, state처럼 어떤 것이든 가리킬 수 있어요: 문자열, 객체, 심지어 함수도요. state와 달리, ref는 읽고 수정할 수 있는 current 프로퍼티를 가진 평범한 JavaScript 객체예요.
컴포넌트가 매번 증가할 때마다 리렌더링되지 않는다는 점에 주목하세요. state처럼 ref도 React에 의해 리렌더링 사이에 유지돼요. 하지만 state를 설정하면 컴포넌트가 리렌더링되죠. ref를 변경하는 건 그렇지 않아요!
하나의 컴포넌트에서 ref와 state를 함께 사용할 수 있어요. 예를 들어, 사용자가 버튼을 눌러 시작하거나 멈출 수 있는 스톱워치를 만들어 볼게요. 사용자가 "Start"를 누른 이후 얼마나 시간이 지났는지 표시하려면, Start 버튼이 눌린 시점과 현재 시간을 추적해야 해요. 이 정보는 렌더링에 사용되므로 state에 보관할 거예요:
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
사용자가 "Start"를 누르면, setInterval을 사용해서 10밀리초마다 시간을 업데이트할 거예요:
import { useState } from 'react';
export default function Stopwatch() {
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
function handleStart() {
// Start counting.
setStartTime(Date.now());
setNow(Date.now());
setInterval(() => {
// Update the current time every 10ms.
setNow(Date.now());
}, 10);
}
let secondsPassed = 0;
if (startTime != null && now != null) {
secondsPassed = (now - startTime) / 1000;
}
return (
<>
<h1>Time passed: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>
Start
</button>
</>
);
}
"Stop" 버튼이 눌리면, now state 변수의 업데이트를 멈추기 위해 기존 interval을 취소해야 해요. clearInterval을 호출하면 되는데, 사용자가 Start를 눌렀을 때 setInterval 호출이 이전에 반환한 interval ID를 전달해야 해요. interval ID를 어딘가에 보관해야 하죠. interval ID는 렌더링에 사용되지 않으므로 ref에 보관할 수 있어요:
import { useState, useRef } from 'react';
export default function Stopwatch() {
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
const intervalRef = useRef(null);
function handleStart() {
setStartTime(Date.now());
setNow(Date.now());
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
}
function handleStop() {
clearInterval(intervalRef.current);
}
let secondsPassed = 0;
if (startTime != null && now != null) {
secondsPassed = (now - startTime) / 1000;
}
return (
<>
<h1>Time passed: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>
Start
</button>
<button onClick={handleStop}>
Stop
</button>
</>
);
}
어떤 정보가 렌더링에 사용된다면 state에 보관하세요. 어떤 정보가 이벤트 핸들러에서만 필요하고 변경해도 리렌더링이 필요 없다면, ref를 사용하는 게 더 효율적일 수 있어요.
아마 ref가 state보다 덜 "엄격"하게 느껴질 거예요—예를 들어 항상 state 설정 함수를 사용하지 않고도 직접 변경할 수 있으니까요. 하지만 대부분의 경우 state를 사용하고 싶을 거예요. ref는 자주 필요하지 않은 "탈출구"예요. state와 ref를 비교하면 이래요:
| refs | state |
|---|---|
useRef(initialValue)는 { current: initialValue }를 반환해요 | useState(initialValue)는 state 변수의 현재 값과 state 설정 함수를 반환해요 ( [value, setValue]) |
| 변경해도 리렌더링을 트리거하지 않아요. | 변경하면 리렌더링을 트리거해요. |
Mutable(변경 가능)—렌더링 프로세스 외부에서 current 값을 수정하고 업데이트할 수 있어요. | "Immutable(불변)"—리렌더링을 큐에 넣으려면 state 설정 함수를 사용해서 state 변수를 수정해야 해요. |
렌더링 중에 current 값을 읽거나 쓰면 안 돼요. | 언제든지 state를 읽을 수 있어요. 하지만 각 렌더링은 변경되지 않는 자체적인 state 스냅샷을 가져요. |
여기 state로 구현된 카운터 버튼이 있어요:
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<button onClick={handleClick}>
You clicked {count} times
</button>
);
}
count 값이 표시되기 때문에 state 값을 사용하는 게 맞아요. 카운터 값이 setCount()로 설정되면, React는 컴포넌트를 리렌더링하고 화면이 새 카운트를 반영하도록 업데이트해요.
만약 이걸 ref로 구현하려고 했다면, React는 컴포넌트를 절대 리렌더링하지 않을 거라서 카운트가 변하는 걸 볼 수 없을 거예요! 이 버튼을 클릭해도 텍스트가 업데이트되지 않는 걸 보세요:
import { useRef } from 'react';
export default function Counter() {
let countRef = useRef(0);
function handleClick() {
// This doesn't re-render the component!
countRef.current = countRef.current + 1;
}
return (
<button onClick={handleClick}>
You clicked {countRef.current} times
</button>
);
}
이래서 렌더링 중에 ref.current를 읽으면 신뢰할 수 없는 코드가 되는 거예요. 그게 필요하다면 대신 state를 사용하세요.
useState와 useRef 둘 다 React에서 제공하지만, 원칙적으로 useRef는 useState 위에 구현될 수 있어요. React 내부에서 useRef가 이렇게 구현되어 있다고 상상할 수 있어요:
// Inside of React
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
첫 번째 렌더링 동안 useRef는 { current: initialValue }를 반환해요. 이 객체는 React에 의해 저장되므로, 다음 렌더링에서도 같은 객체가 반환돼요. 이 예제에서 state 설정자가 사용되지 않는 것에 주목하세요. useRef는 항상 같은 객체를 반환해야 하기 때문에 불필요해요!
React는 실제로 충분히 일반적이기 때문에 내장된 useRef 버전을 제공해요. 하지만 설정자가 없는 일반적인 state 변수라고 생각할 수 있어요. 객체 지향 프로그래밍에 익숙하다면, ref가 인스턴스 필드를 떠올리게 할 수 있어요—하지만 this.something 대신 somethingRef.current라고 쓰는 거죠.
일반적으로, 컴포넌트가 React "외부로 나가서" 외부 API—종종 컴포넌트의 외관에 영향을 주지 않는 브라우저 API—와 통신해야 할 때 ref를 사용해요. 이런 드문 상황들이 있어요:
컴포넌트가 어떤 값을 저장해야 하지만 렌더링 로직에 영향을 주지 않는다면, ref를 선택하세요.
이 원칙들을 따르면 컴포넌트를 더 예측 가능하게 만들 수 있어요:
ref.current를 읽거나 쓰지 마세요. 렌더링 중에 어떤 정보가 필요하다면, 대신 state를 사용하세요. React는 ref.current가 언제 변경되는지 모르기 때문에, 렌더링 중에 읽기만 해도 컴포넌트의 동작을 예측하기 어렵게 만들어요. (유일한 예외는 첫 번째 렌더링 중에 ref를 한 번만 설정하는 if (!ref.current) ref.current = new Thing() 같은 코드예요.)React state의 제한 사항은 ref에 적용되지 않아요. 예를 들어, state는 모든 렌더링에 대한 스냅샷처럼 동작하고 동기적으로 업데이트되지 않아요. 하지만 ref의 현재 값을 변경하면 즉시 변경돼요:
ref.current = 5;
console.log(ref.current); // 5
이건 ref 자체가 일반적인 JavaScript 객체이기 때문에 그렇게 동작하는 거예요.
ref로 작업할 때 mutation을 피하는 것에 대해 걱정할 필요도 없어요. 변경하는 객체가 렌더링에 사용되지 않는 한, React는 ref나 그 내용으로 무엇을 하든 신경 쓰지 않아요.
ref를 어떤 값이든 가리키도록 할 수 있어요. 하지만 ref의 가장 일반적인 사용 사례는 DOM 요소에 접근하는 거예요. 예를 들어, 프로그래밍 방식으로 input에 포커스를 주고 싶을 때 유용해요. <div ref={myRef}>처럼 JSX의 ref 속성에 ref를 전달하면, React는 해당 DOM 요소를 myRef.current에 넣어요. 요소가 DOM에서 제거되면, React는 myRef.current를 null로 업데이트해요. 이에 대해 Ref로 DOM 조작하기에서 더 읽을 수 있어요.
current라는 단일 프로퍼티를 가진 평범한 JavaScript 객체예요.useRef Hook을 호출해서 React에게 ref를 요청할 수 있어요.current 값을 설정해도 리렌더링이 트리거되지 않아요.ref.current를 읽거나 쓰지 마세요. 컴포넌트를 예측하기 어렵게 만들어요.메시지를 입력하고 "Send"를 클릭해 보세요. "Sent!" 알림이 나타나기까지 3초 지연이 있다는 걸 알 수 있을 거예요. 이 지연 동안 "Undo" 버튼이 보여요. 클릭해 보세요. 이 "Undo" 버튼은 "Sent!" 메시지가 나타나는 걸 막아야 해요. handleSend 중에 저장된 timeout ID에 대해 clearTimeout을 호출해서 이렇게 해요. 하지만 "Undo"를 클릭한 후에도 "Sent!" 메시지가 여전히 나타나요. 왜 작동하지 않는지 찾아서 고쳐보세요.
let timeoutID 같은 일반 변수는 리렌더링 사이에 "살아남지" 못해요. 왜냐하면 모든 렌더링이 컴포넌트를 실행하고 (그리고 변수들을 초기화하기 때문이에요) 처음부터 시작하거든요. timeout ID를 다른 곳에 보관해야 할까요?
import { useState } from 'react';
export default function Chat() {
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);
let timeoutID = null;
function handleSend() {
setIsSending(true);
timeoutID = setTimeout(() => {
alert('Sent!');
setIsSending(false);
}, 3000);
}
function handleUndo() {
setIsSending(false);
clearTimeout(timeoutID);
}
return (
<>
<input
disabled={isSending}
value={text}
onChange={e => setText(e.target.value)}
/>
<button
disabled={isSending}
onClick={handleSend}>
{isSending ? 'Sending...' : 'Send'}
</button>
{isSending &&
<button onClick={handleUndo}>
Undo
</button>
}
</>
);
}
컴포넌트가 리렌더링될 때마다 (예: state를 설정할 때), 모든 로컬 변수가 처음부터 초기화돼요. 그래서 timeoutID 같은 로컬 변수에 timeout ID를 저장하고 나중에 다른 이벤트 핸들러가 그걸 "볼" 거라고 기대할 수 없는 거예요. 대신 React가 렌더링 사이에 보존하는 ref에 저장하세요.
import { useState, useRef } from 'react';
export default function Chat() {
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);
const timeoutRef = useRef(null);
function handleSend() {
setIsSending(true);
timeoutRef.current = setTimeout(() => {
alert('Sent!');
setIsSending(false);
}, 3000);
}
function handleUndo() {
setIsSending(false);
clearTimeout(timeoutRef.current);
}
return (
<>
<input
disabled={isSending}
value={text}
onChange={e => setText(e.target.value)}
/>
<button
disabled={isSending}
onClick={handleSend}>
{isSending ? 'Sending...' : 'Send'}
</button>
{isSending &&
<button onClick={handleUndo}>
Undo
</button>
}
</>
);
}
이 버튼은 "On"과 "Off"를 토글하도록 되어 있어요. 하지만 항상 "Off"만 보여줘요. 이 코드에 무슨 문제가 있는 걸까요? 고쳐보세요.
import { useRef } from 'react';
export default function Toggle() {
const isOnRef = useRef(false);
return (
<button onClick={() => {
isOnRef.current = !isOnRef.current;
}}>
{isOnRef.current ? 'On' : 'Off'}
</button>
);
}
이 예제에서 ref의 현재 값이 렌더링 출력을 계산하는 데 사용되고 있어요: {isOnRef.current ? 'On' : 'Off'}. 이건 이 정보가 ref에 있으면 안 되고, 대신 state에 넣어야 한다는 신호예요. 이걸 고치려면 ref를 제거하고 대신 state를 사용하세요:
import { useState } from 'react';
export default function Toggle() {
const [isOn, setIsOn] = useState(false);
return (
<button onClick={() => {
setIsOn(!isOn);
}}>
{isOn ? 'On' : 'Off'}
</button>
);
}
이 예제에서 모든 버튼 클릭 핸들러가 "디바운스"되어 있어요. 이게 무슨 뜻인지 보려면 버튼 중 하나를 눌러보세요. 메시지가 1초 후에 나타나는 걸 알 수 있을 거예요. 메시지를 기다리는 동안 버튼을 누르면 타이머가 리셋돼요. 그래서 같은 버튼을 빠르게 여러 번 계속 클릭하면, 클릭을 멈춘 후 1초가 지나야 메시지가 나타나요. 디바운싱은 사용자가 "어떤 일을 멈출" 때까지 어떤 동작을 지연시킬 수 있게 해줘요.
이 예제는 작동하지만 의도한 대로는 아니에요. 버튼들이 독립적이지 않아요. 문제를 보려면 버튼 하나를 클릭하고, 즉시 다른 버튼을 클릭해 보세요. 지연 후에 두 버튼의 메시지가 모두 보일 거라고 예상하겠죠. 하지만 마지막 버튼의 메시지만 나타나요. 첫 번째 버튼의 메시지는 사라져요.
왜 버튼들이 서로 간섭하는 걸까요? 문제를 찾아서 고쳐보세요.
마지막 timeout ID 변수가 모든 DebouncedButton 컴포넌트 사이에서 공유되고 있어요. 이래서 한 버튼을 클릭하면 다른 버튼의 timeout이 리셋되는 거예요. 각 버튼에 대해 별도의 timeout ID를 저장할 수 있을까요?
let timeoutID;
function DebouncedButton({ onClick, children }) {
return (
<button onClick={() => {
clearTimeout(timeoutID);
timeoutID = setTimeout(() => {
onClick();
}, 1000);
}}>
{children}
</button>
);
}
export default function Dashboard() {
return (
<>
<DebouncedButton
onClick={() => alert('Spaceship launched!')}
>
Launch the spaceship
</DebouncedButton>
<DebouncedButton
onClick={() => alert('Soup boiled!')}
>
Boil the soup
</DebouncedButton>
<DebouncedButton
onClick={() => alert('Lullaby sung!')}
>
Sing a lullaby
</DebouncedButton>
</>
)
}
button { display: block; margin: 10px; }
timeoutID 같은 변수는 모든 컴포넌트 사이에서 공유돼요. 이래서 두 번째 버튼을 클릭하면 첫 번째 버튼의 대기 중인 timeout이 리셋되는 거예요. 이걸 고치려면 timeout을 ref에 보관하면 돼요. 각 버튼이 자체 ref를 갖게 되므로 서로 충돌하지 않아요. 두 버튼을 빠르게 클릭하면 두 메시지가 모두 나타나는 걸 확인해 보세요.
import { useRef } from 'react';
function DebouncedButton({ onClick, children }) {
const timeoutRef = useRef(null);
return (
<button onClick={() => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onClick();
}, 1000);
}}>
{children}
</button>
);
}
export default function Dashboard() {
return (
<>
<DebouncedButton
onClick={() => alert('Spaceship launched!')}
>
Launch the spaceship
</DebouncedButton>
<DebouncedButton
onClick={() => alert('Soup boiled!')}
>
Boil the soup
</DebouncedButton>
<DebouncedButton
onClick={() => alert('Lullaby sung!')}
>
Sing a lullaby
</DebouncedButton>
</>
)
}
button { display: block; margin: 10px; }
이 예제에서 "Send"를 누르면 메시지가 표시되기 전에 약간의 지연이 있어요. "hello"를 입력하고 Send를 누른 다음, 빠르게 입력을 다시 수정해 보세요. 수정했는데도 알림에는 여전히 "hello"가 표시돼요 (버튼이 클릭된 그 시점의 state 값이었거든요).
보통 이 동작이 앱에서 원하는 거예요. 하지만 가끔 비동기 코드가 어떤 state의 최신 버전을 읽어야 하는 경우가 있을 수 있어요. 클릭 시점이 아닌 현재 입력 텍스트를 알림에 표시하게 할 방법을 생각해 볼 수 있을까요?
import { useState, useRef } from 'react';
export default function Chat() {
const [text, setText] = useState('');
function handleSend() {
setTimeout(() => {
alert('Sending: ' + text);
}, 3000);
}
return (
<>
<input
value={text}
onChange={e => setText(e.target.value)}
/>
<button
onClick={handleSend}>
Send
</button>
</>
);
}
State는 스냅샷처럼 동작하므로, timeout 같은 비동기 작업에서 최신 state를 읽을 수 없어요. 하지만 ref에 최신 입력 텍스트를 보관할 수 있어요. ref는 변경 가능하므로 언제든지 current 프로퍼티를 읽을 수 있어요. 현재 텍스트가 렌더링에도 사용되기 때문에, 이 예제에서는 state 변수 (렌더링용)와 ref (timeout에서 읽기용) 둘 다 필요해요. 현재 ref 값을 수동으로 업데이트해야 해요.
import { useState, useRef } from 'react';
export default function Chat() {
const [text, setText] = useState('');
const textRef = useRef(text);
function handleChange(e) {
setText(e.target.value);
textRef.current = e.target.value;
}
function handleSend() {
setTimeout(() => {
alert('Sending: ' + textRef.current);
}, 3000);
}
return (
<>
<input
value={text}
onChange={handleChange}
/>
<button
onClick={handleSend}>
Send
</button>
</>
);
}