포스트

[React] Ref 정리

[React] Ref 정리

React에서 ref는 렌더링 결과물 중 특정 대상을 “직접 참조”하기 위한 수단이다.
일반적으로 React는 상태(state)와 props로 UI를 선언적으로 구성한다. 그러나 때로는 DOM에 직접 접근해야 하거나, 값의 변경이 렌더링을 유발하지 않기를 원하는 상황이 존재한다. 이때 ref가 사용된다.



Ref의 대표적인 용도

ref는 크게 다음 목적에서 쓰인다.

  1. DOM 요소에 직접 접근하기 위한 용도
    예) 특정 input에 포커스 주기, 스크롤 위치 제어, 요소 크기 측정 등

  2. 렌더링과 무관한 값 저장을 위한 용도
    예) 타이머 ID, 이전 값(previous value), 외부 라이브러리 인스턴스 등
    이 값이 바뀌어도 컴포넌트는 다시 렌더링되지 않는다.



useRef의 동작 방식

useRef(initialValue){ current: initialValue } 형태의 객체를 반환한다.
이 객체는 컴포넌트가 다시 렌더링되더라도 동일한 참조를 유지한다.

  • ref.current를 변경해도 렌더링이 다시 발생하지 않는다.
  • 렌더링을 발생시키는 값은 state이며, ref는 그와 목적이 다르다.


예시 1: DOM 접근 (Input 포커스)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useRef } from "react";

export default function FocusExample() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focusInput = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={focusInput}>Focus</button>
    </div>
  );
}

위 예시에서 ref는 DOM 요소를 가리킨다.
이러한 작업은 상태로 해결하기 어렵거나 불필요하게 복잡해지기 쉬우며, ref가 적합한 대표 사례이다.



예시 2: 렌더링과 무관한 값 저장 (타이머 ID)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { useEffect, useRef, useState } from "react";

export default function TimerExample() {
  const [count, setCount] = useState(0);
  const timerRef = useRef<number | null>(null);

  const start = () => {
    if (timerRef.current !== null) return;
    timerRef.current = window.setInterval(() => setCount((c) => c + 1), 1000);
  };

  const stop = () => {
    if (timerRef.current === null) return;
    window.clearInterval(timerRef.current);
    timerRef.current = null;
  };

  useEffect(() => stop, []);

  return (
    <div>
      <p>{count}</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

타이머 ID는 화면에 보여줄 값이 아니며, 변경 시 렌더링이 필요하지 않다.
따라서 상태 대신 ref에 저장하는 편이 합리적이다.



State와 Ref의 차이

  • state는 변경 시 렌더링을 유발하며, UI를 갱신하기 위한 값이다.
  • ref는 변경해도 렌더링을 유발하지 않으며, “기억해야 하는 값”을 유지하기 위한 통로이다.

따라서 화면에 반영되어야 하는 값은 state, 그렇지 않은 값은 ref가 적합한 경우가 많다.



ForwardRef: 자식의 DOM을 부모가 참조하기

컴포넌트를 하나 더 감싸면 DOM에 직접 ref를 꽂기 어려워지는 경우가 있다.
이때 forwardRef를 사용하면 부모가 전달한 ref를 자식의 DOM에 연결할 수 있다.

forwardRef의 기본 형태는 다음과 같다.

forwardRef<T, P>(render)

  • T: 부모가 ref.current로 받게 될 값의 타입
  • P: 컴포넌트의 props 타입
1
2
3
4
5
6
7
8
9
10
11
12
import { forwardRef } from "react";

type Props = React.ComponentProps<"input">;

const Input = forwardRef<HTMLInputElement, Props>(

  function Input(props, ref) {
    return <input ref={ref} {...props} />;
  }
);

export default Input;
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useRef } from "react";
import Input from "./Input";

export default function Parent() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  return (
    <div>
      <Input ref={inputRef} placeholder="Email" />
      <button onClick={() => inputRef.current?.focus()}>Focus</button>
    </div>
  );
}


useImperativeHandle: DOM에 직접 접근 대신 허용된 기능만 노출

forwardRef만 사용하면 부모가 자식 내부의 DOM을 곧바로 참조하게 된다.
경우에 따라 부모에게 DOM 전체를 주기보다, 필요한 동작만 “명령형 API”로 노출하는 편이 더 안전하다. 이때 useImperativeHandle을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { forwardRef, useImperativeHandle, useRef } from "react";

export type FancyInputHandle = {
  focus: () => void;
  clear: () => void;
};

type Props = React.ComponentProps<"input">;

const FancyInput = forwardRef<FancyInputHandle, Props>(

  function FancyInput(props, ref) {
    const innerRef = useRef<HTMLInputElement | null>(null);

    // 이로써 부모는 focus(), clear()만 호출가능
    useImperativeHandle(ref, () => ({
      focus: () => innerRef.current?.focus(),
      clear: () => {
        if (innerRef.current) innerRef.current.value = "";
      },
    }));

    return <input ref={innerRef} {...props} />;
  }
);

export default FancyInput;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useRef } from "react";
import FancyInput, { type FancyInputHandle } from "./FancyInput";

export default function Parent() {
  const inputRef = useRef<FancyInputHandle | null>(null);

  return (
    <div>
      <FancyInput ref={inputRef} placeholder="Type here..." />

      <button onClick={() => inputRef.current?.focus()}>Focus</button>
      <button onClick={() => inputRef.current?.clear()}>Clear</button>
    </div>
  );
}

이 방식은 부모가 자식의 내부 구현에 과하게 의존하는 것을 줄이고, 필요한 기능만 노출할 수 있다는 장점이 있다.

useImperativeHandle을 사용하면 직접 지정한 프로퍼티/메소드 외 다른 프로퍼티/메소드들은 존재하지 않는 것으로 간주한다.



Ref 사용 시 주의점

  • ref로 대부분의 문제를 해결하려 하면 React의 선언적 흐름이 흐려질 수 있다.
  • DOM 조작은 최후의 수단에 가깝다. 가능하다면 상태/props로 해결하는 편이 유지보수에 유리하다.
  • 그럼에도 포커스, 스크롤, 측정, 외부 라이브러리 연동처럼 “명령형 제어”가 필요한 영역에서는 ref가 필수적이다.


마무리

ref는 React에서 선언적 UI 모델을 보완하는 도구이다.
UI에 직접 반영되지 않는 값을 보존하거나, 특정 DOM을 제어해야 하는 순간에 ref를 사용하면 구조를 단순하게 유지할 수 있다.
다만 과도한 DOM 접근은 복잡도를 높일 수 있으므로, 목적이 분명할 때 제한적으로 사용하는 태도가 중요하다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.