useRef

GI JUNG·2023년 12월 5일
0

react

목록 보기
2/8
post-thumbnail

🍀 useRef

useRef는 컴포넌트 life-cycle동안 re-rendering을 유발하지 않으며 값을 참조할 때 사용하는 hook이다. 특히 DOM 조작에 자주 사용된다. useRef도 훅이므로 hook을 사용할 때의 규칙을 준수해야 하며, local에 값이 저장된다.

react docs에서는 useRef를 사용함으로써 아래와 같은 3가지를 보장할 수 있다고 한다.

  1. You can store information between re-renders (unlike regular variables, which reset on every render).
    렌더링 될 때 마다 바뀌는 값(state)이 아닌 rendering이 되어도 값을 일관되게 저장할 수 있다.

  2. Changing it does not trigger a re-render (unlike state variables, which trigger a re-render)
    re-rendering을 유발시키는 state와 달리 re-rendering을 유발시키지 않는다.

  3. The information is local to each copy of your component (unlike the variables outside, which are shared).
    외부 컴포넌트와 공유하는 값과 달리 컴포넌트 자체에 존재하는 로컬 값이다.

❗️ ref를 변경하는 것은 re-rendering을 유발시키지 않으므로 바뀌는 값에 따라 screen에 표현하고자 하는 상황에는 적합하지 않다.

useRef는 current라는 property를 가지며 current를 key로 하여 값과 mapping된다.

const ref = useRef({name: 'jjung'})

console.log(ref) 

// 👇 ref는 current에 값을 저장한다.
ref = {
  current: {
    name: 'jjung'
  }
}

🔎 3가지 type(usage)

useRef는 3가지의 type을 overloading하고 있다.

1. function useRef<T>(initialValue: T): MutableRefObject<T>;
2. function useRef<T>(initialValue: T | null): RefObject<T>;
3. function useRef<T = undefined>(): MutableRefObject<T | undefined>;

useRef는 크게 mutable(변경가능)과 readOnly(변경불가) RefObject 타입을 가지는 2가지 type을 반환한다. 이 때 readOnly라고 해도 deep이 아닌 shallow의 특징을 띄기 때문에 내부 property는 변경가능하다.

< MutableRefObject >

interface MutableRefObject<T> {
  current: T;
}

< ReadOnly RefObject >

interface RefObject<T> {
  readonly current: T | null;
}

1️⃣ 초기값이 주어진 MutableRefObject

	/**
     * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
     * (`initialValue`). The returned object will persist for the full lifetime of the component.
     *
     * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
     * value around similar to how you’d use instance fields in classes.
     *
     */
    function useRef<T>(initialValue: T): MutableRefObject<T>;

위의 타입은 초기값을 주어진 경우 그 초기값의 타입이 제네릭 T가 된다. 그리고 mutable ref object를 반환하므로 re-rendering을 유발하지 않으면서 local 값을 사용하고 싶을 때 사용한다.

적절한 예시로는 시간이 얼마나 지났나에 대한 게임??? 스탑워치가 있을 것 같다.

function App() {
  const [time, setTime] = useState(0);
  const intervalId = useRef(0); // 👉🏻 re-rendering이 필요하지 않음
  const reduceTime = useRef(0); // 👉🏻 re-rendering이 필요하지 않음

  const startStopWatch = () => {
    intervalId.current = setInterval(() => {
      reduceTime.current += 10;
      console.log("reduceTime", reduceTime.current);
    }, 10);
  };

  const stopStopWatch = () => {
    clearInterval(intervalId.current);
  };

  const onStart = () => {
    startStopWatch();
    setTime(0);
  };

  const onStop = () => {
    stopStopWatch();
    setTime(parseFloat((reduceTime.current / 1000).toFixed(2)));
    reduceTime.current = 0;
  };

  return (
    <>
      <h1>{`${time}초. 맞추면 천재🥲`}</h1>
      <button onClick={onStart}>start</button>
      <button onClick={onStop}>stop</button>
    </>
  );
}

게임하는 사용자는 시작버튼을 누르고 멈춤을 눌렀을 때 시간을 예측하는 예시로 intervalIdreduceTime은 변경될 때마다 re-rendering이 필요하지 않다. 이를 state로 작성한다면 0.01초마다 re-rendering되어 performance측면에서 좋지 않을 것이다. 따라서, intervalIdreduceTime컴포넌트 life cycle 동안 re-rendering을 유발하지 않으면서 값은 바뀌어야 한다. 초기값은 0으로 type -> number이다. 따라서, intervalIdreduceTimeMutableRefObject<number> type을 가지게 된다.

useRef<number>(initialValue: number): MutableRefObject<number>

typescript가 정확히 추론을 하는지 마우스를 올려보면 잘 추론함을 확인할 수 있다.

그리고 useRef<number>(0)와 같이 쓸 수 있는데 어차피 typescript가 초기값을 기반으로 type T를 추론하기 때문에 동일한 코드이다.

추가적으로 정말 ref값은 re-rendering을 유발하지 않는지 확인해보자. 이는 chrome에서 지원하는 react dev tool확장자를 통해서 Highlight updates when components render를 체크해주면 확인해 볼 수 있다.

re-rendering이 되는 component에 대해서는 박스가 쳐지는데 state가 바뀔 때만 박스가 쳐지는 것을 확인할 수 있다.

그리고 당연한 얘기지만 number type인 intervalId를 다른 type으로 재할당을 시도하면 type error가 발생한다.

2️⃣ 초기값 null을 허용하는 ReadOnly RefObject

위의 1️⃣ MutableObject에서는 초기값 null을 허용하지 않는다. 하지만, Readonly RefObject는 초기값으로 null을 허용하며 제네릭으로 지정해준 type으로 고정된다. 이번 예시에서는 DOM을 조작하는데 많이 쓰인다. 즉, DOM의 참조를 유지할 때 사용한다고 생각하면 편할 듯 한다.

근데 여기서 궁금했던 것이 있었는데, 아래와 같은 궁금증이 들었다.

🤔💡 예를 들어 vanilla javascript로 document.getElementById('something')으로 DOM에 접근하려고 할 때 없는 element라면 undefined를 return한다. 따라서 초기값으로 undefined를 허용하는 refobject여야 하지 않나 생각이 들었다. 이는 react의 작동과 관련이 있음을 깨달았는데 밑에 life-cycle과 ref의 관계에 대해서 설명한다.

	// convenience overload for refs given as a ref prop as they typically start with a null value
    /**
     * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
     * (`initialValue`). The returned object will persist for the full lifetime of the component.
     *
     * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
     * value around similar to how you’d use instance fields in classes.
     *
     * Usage note: if you need the result of useRef to be directly mutable, include `| null` in the type
     * of the generic argument.
     *
     */
    function useRef<T>(initialValue: T | null): RefObject<T>;

2번째 오버로딩 된 타입을 보면 1️⃣과 다른 점을 발견할 수 있다.

다른 점 1️⃣ <--> 2️⃣

  • convenience overload for refs given as a ref prop as they typically start with a null value
    전형적으로 null이란 값으로 시작함으로 편의상 제공된다.

  • Usage note: if you need the result of useRef to be directly mutable, include | null in the type of the generic argument.
    MutableRefObject를 반환받고 싶으면 제네릭 타입에 null을 union으로 지정해라

여기서 제네릭에 null을 명시해주면 MutableRefObject를 반환한다는 것에 멘붕이 왔다. 그러면 결국 3가지 타입 모두다 MutableRefObject를 반환하는 것이 아닌가??라는 생각이 왔는데, 변경가능함 & 변경가능하지 않음에 초점을 맞춰서 이러저러한 테스트를 해보니 감이 잡혔다.(사실상 재할당)

먼저 중요 포인트를 짚고 넘어가보자.

  • null 명시 X: 초기값 null만 허용하며 이후에 null값을 허용하지 않는다.
  • null 명시: 이후에 임의로 값을 null로 재할당할 수 있다.

🧱 null 제네릭에 명시하지 않을 때   👉🏻  ReadOnly RefObject

먼저 제네릭에 null을 명시하지 않을 때를 살펴보자. ref가 input element를 참조하게 하고 이후에 값을 바꾸어 보자

function App() {
  const inputRef = useRef<HTMLInputElement>(null);
  const inputRef1 = useRef<HTMLInputElement>(null);
  
  inputRef.current = null; // 👉 ❌
  inputRef.current = inputRef1.current; // 👉 ❌
  
  return <input ref={inputRef} />;
}

초기값을 null로 할당하고 제네릭에 null을 명시하지 않은 경우는 재할당이 불가능하다.

read-only propertynull을 할당할 수 없으며, 이 경우 ReadOnly RefObject type을 갖는다.

다시 정리하면 아래와 같다.

💡 초기값을 null로 할당 & 제네릭에 null을 명시하지 않은 경우

  • 초기값 null만을 허용한다.
  • DOM을 참조한 이후에는 값을 아예 바꿀 수 없다.(read-only)

이에 대해서 많이 쓰이는 예제는 focus 예제로 들 수 있을 것 같다.

function App() {
  const inputRef = useRef<HTMLInputElement>(null);

  const onClick = () => inputRef.current?.focus();

  return (
    <>
      <input ref={inputRef} />
      <button onClick={onClick}>click to focus!!!</button>
    </>
  );
}

💧 null 제네릭에 명시할 때   👉🏻  MutableRefObject

useRef에 대한 타입 정의 설명을 다시 한 번 봐보자.

Usage note: if you need the result of useRef to be directly mutable, include | null in the type of the generic argument.

제네릭에 null을 선언하는 경우는 mutable한 useRef 결과를 반환를 얻을 수 있다고 한다. 이는 MutableRefObject를 return한다는 의미다.

위의 focus예제에서 제네릭에 null을 명시해보자.

  const inputRef = useRef<HTMLInputElement | null>(null);

제네릭에 null을 명시했더니 Mutable RefObject로 추론함을 볼 수 있다. 이 의미는 ref가 input element를 참조해도 이후에 참조를 제거할 수 있다는 의미가 된다.

아래 button을 클릭하면 input element를 가리키는 참조를 없애 더 이상 focus를 하지 못하는 예제를 만들었다.

function App() {
  const isClicked = useRef(false);
  const inputRef = useRef<HTMLInputElement | null>(null);

  const onClick = () => {
    console.log("before", inputRef.current);
    inputRef.current?.focus();
    !isClicked.current &&
      setTimeout(() => alert("더 이상 focus를 할 수 없습니다!!!"), 1000);
    isClicked.current = true;
    inputRef.current = null;
    console.log("after", inputRef.current);
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={onClick}>click to focus!!!</button>
    </>
  );
}

처음 컴포넌트가 mount된 후에는 ref가 input element를 참조하지만 ref는 null로 재할당이 가능하기 때문에 버튼을 클릭한 이후에는 focus가 되지 않음을 확인할 수 있다.

  • 제네릭으로 null을 명시할 시 DOM 참조 이후 null로 재할당할 수 있다.
  • 개인적인 생각이지만 특별한 케이스가 아니면 잘 사용하지 않을 것 같다.

3️⃣ 초기값을 지정하지 않는 경우

/**
  // convenience overload for potentially undefined initialValue / call with 0 arguments
  // has a default to stop it from defaulting to {} instead
*/
function useRef<T = undefined>(): MutableRefObject<T | undefined>;

docs의 설명을 보면 3️⃣의 경우는 초기값이 undefined일 때 편의상 제공하는 타입이며, 0개의 인자를 전달할 때 {}로 정의되는 것을 막기 위함이다라고 되어있다. 여기서 0개의 인자를 전달할 때 {}로 정의되는 것을 막기 위함이 이해가 가지 않았는데, 아래와 같은 느낌으로 추론했다.

useRef<T = {}>()

이렇게 되면 T의 값은 빈 객체일 수도 있지만 undefined 또는 null 타입일 수도 있으므로 null타입에 대해서 오버로딩한 1️⃣, 2️⃣의 경우와 겹치지 않기 위해서 한 것 같다...?


1️⃣, 2️⃣와 다르게 이번에는 초기값을 지정하지 않는 경우이다. 초기값을 지정하지 않는 경우는 javascript 동작과 관련이 있으며, 객체에 없는 property의 접근은 undefined을 반환하는 동작과 같다.

초기값을 지정하지 않는 경우반드시 제네릭에 어떤 type의 값을 가지는지 명시해 주어야 한다. 이는 이후에 어떤 값이 들어올지 모르므로 typescript는 불평을 토로하기 때문이다.

이 예제는 1️⃣번의 예제를 대체할 수 있으며 아래와 같이 사용하면 된다.

function App() {
  const [time, setTime] = useState(0);
  // 👉🏻 1️⃣번의 코드 대체 가능 ✅
  const intervalId = useRef<number>(); 
  // 👉🏻 1️⃣번의 코드 대체 가능하긴 함. ⚠️
  const reduceTime = useRef<number>(); 

  const startStopWatch = () => {
    intervalId.current = setInterval(() => {
      reduceTime.current += 10;
      console.log("reduceTime", reduceTime.current);
    }, 10);
  };

  const stopStopWatch = () => {
    clearInterval(intervalId.current);
  };

  const onStart = () => {
    startStopWatch();
    setTime(0);
  };

  const onStop = () => {
    stopStopWatch();
    setTime(parseFloat((reduceTime.current / 1000).toFixed(2)));
    reduceTime.current = 0;
  };

  return (
    <>
      <h1>{`${time}초. 맞추면 천재🥲`}</h1>
      <button onClick={onStart}>start</button>
      <button onClick={onStop}>stop</button>
    </>
  );
}

intervalId의 return 값은 0이 아닌 number type의 값을 return하며 이후 산술연산 없이 그저 반환하기만 하므로 3️⃣ 케이스로 1️⃣을 대체할 수 있다. 하지만, 2️⃣는 추천하지 않는데 누적 시간은 산술연산을 필요하며 undefined와 연산은 NAN을 초래할 수 있기 때문이다. 따라서, NAN이 나오지 않게 추가적인 검사 코드가 필요하다.

function App() {
  ...//
  const intervalId = useRef<number>(); // 👉🏻 1️⃣번의 코드 대체 가능 ✅
  // 👉🏻 1️⃣번의 코드 대체 가능하긴 함. ⚠️
  const reduceTime = useRef<number>(); 

  const startStopWatch = () => {
    intervalId.current = setInterval(() => {
      reduceTime.current = (reduceTime.current ?? 0) + 10; // 🛠️ 수정
    }, 10);
  };
  ...//

  return (.../);
}

🛠️ useRef 구현해보기

state와 같이 re-rendering을 유발하지 않으면서, local에 값을 유지하면서 값을 계속 유지시킬 수 있어야 한다.

처음에는 모듈 상단에 값을 보존할까 했는데 useRef는 함수이기 때문에 closure의 개념을 이용하지 않았나 생각해서 closure를 이용하여 구현했다. 테스트 한 것은 아래 내용과 괕다.

ref 값 증가 button: re-rendering을 유발하지 않고 값만 업데이트 하는지 확인하는 버튼
rendering 유발 button: rendering을 유발시키고 나서도 ref의 참조값이 보존되는지 확인하는 버튼

// useMyRef.tsx

interface RefType<T> {
  current: T;
}

const useMyRef = (() => {
  const result: RefType<unknown> = { current: null };

  return <T,>(initialValue: T): RefType<T> => {
    if (!result.current) {
      result.current = initialValue;
    }

    return result as RefType<T>;
  };
})();

export default useMyRef;
// App.tsx
import { useState } from "react";
import useMyRef from "./useMyRef";

function App() {
  const [triggerRender, setTriggerRender] = useState(true);
  const testValue = useMyRef<number>(0);

  const onTriggerRendering = () => setTriggerRender(!triggerRender);

  const onClick = () => {
    testValue.current += 1;
    console.log(testValue.current);
  };

  return (
    <>
      <button onClick={onTriggerRendering}>렌더링 유발</button>
      <button onClick={onClick}>ref 값 1씩 증가</button>
    </>
  );
}

여기서 오류가 났었는데 화살표 함수에서 <T>() => {}와 같이 하면 jsx에서 <T>태그로 인식하기 때문에 ,(쉼표)를 이용하여 generic을 명시하여 오류를 해결할 수 있다.

<T, >() => {} // 👉🏻 쉼표를 넣어줌으로써 generic으로 인식함

이거 구현하는데 1시간이 걸렸다....🥲🥲🥲

♻️ life-cycle과 ref의 관계(useRef가 결정되는 시점)

ref가 결정되는 시점은 life-cycle의 rendering phase와 commit phase 중 commit phase에서 ref가 결정된다.

아래 그림 빨간 박스 참고

이는 초기 rendering중에 ref값을 확인할 수 없는 이유이다.(docs에서도 rendering중에 ref값 사용을 지양한다.)

function App () {
  const inputRef = useRef<HTMLInputElement>(null);
  
  console.log('inputRef ->', inputRef); // ❌
  
  return <input ref={inputRef} />
}

초기 rendering 중에 console을 찍어보면 null값을 확인할 수 있는데, 이는 위에서 설명했듯이 컴포넌트 코드가 실행되고나서 mount전의 시점에 결정되기 때문이다. 이는 컴포넌트 코드 실행 -> ref결정 -> mount 완료의 순으로 흘러가기 때문에 정확히 확인하고 싶다면 current가 있을 시 console을 출력하거나 마운트 이후 실행되는 hook인 useEffect를 활용하면 된다.

function App () {
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
	console.log('inputRef ->', inputRef); // ✅
  }, []);
  
  return <input ref={inputRef} />
}

그리고 에서 언급한 vanilla javascript에서 없는 DOM에 접근시 undefined를 return하는데 react에서는 null값을 사용하는 이유는 ref의 참조가 결정되고나서 컴포넌트가 unmount될 때 null을 할당하여 참조를 제거하는데 undefined 또는 null이 아닌 null값 하나로 일관된 값으로 관리하기 위해서인 것 같다.

📚 정리
👉🏻 ref는 컴포넌트가 mount되는 시점에 참조가 결정되며, unmount시 null값을 가진다.

🤔 useRef가 current 속성을 도입한 이유???

state와 같이 current property를 없애고 그냥 써도 됐는데 굳이 current를 통해서 접근하는 이유가 궁금해서 여기저기 뒤져보다가 여기서 미묘한...??? 이유를 찾아볼 수 있었다.

You can access the current value of that ref through the ref.current property. This value is intentionally mutable, meaning you can both read and write to it. It’s like a secret pocket of your component that React doesn’t track. (This is what makes it an “escape hatch” from React’s one-way data flow—more on that below!)

current속성을 만듬으로써 이 값은 state와 달리 re-rendering을 유발하지 않고 변경가능한 값을 저장할 수 있다는 것을 명시하고 싶어서이지 않을까??? 생각해본다.🤔🤔🤔

아마...??? state와 ref를 갱신하는 것도 다름으로 DX를 위해서 그런 건가?? 싶은 생각이다.

🔥 마치며

useRef를 사용하긴 하는데 정확히 모르고 사용하는 기분이 들어 찝찝해서 정리해봤다. 그리고 풀리지 않는 의문이 있는데 ref={undefined}를 넣는 것은 허용한다. 하지만, 아래 react type에서 가져온 것을 보면 ref는 const inputRef = useRef<HTMLInputElement>()를 이용해 참조하게 하는 것은 오류가 나는 것이 당연하다. 그냥 typescript를 이용하여 일관된 코드의 작성을 유도하는 것인가 생각이 든다.

interface ClassAttributes<T> extends Attributes {
  ref?: LegacyRef<T> | undefined;
}
type Ref<T> = RefCallback<T> | RefObject<T> | null;
type LegacyRef<T> = string | Ref<T>;

useRef와 관련된 forwardRef & useImperativeHandle도 정리했다.

📚 참고

state VS ref
3 types of useRef
useRef react docs

profile
step by step

0개의 댓글