forwardRef는 왜 쓸까?

GI JUNG·2023년 11월 30일
1

react

목록 보기
1/8
post-thumbnail

🍀 forwardRef는 왜 쓸까?

react-query로 무한스크롤을 구현하던 도중에 useRef에 대한 기초가 생각보다 부족하다고 생각해서 useRef에 대해서 정리하다가 forwardRef를 알게되었다. 여기저기 블로그도 찾아보고 docs도 봤지만 함수형 컴포넌트는 인스턴스를 생성하지 않기 때문에 부모가 자식에게 ref참조를 전달할 때는 forwardRef를 통해 전달해야된다. 함수형 컴포넌트가 인스턴스를 생성하지 않는 것은 클래스형 컴포넌트와 달리 this라는 keyword가 존재하지 않아서다라는 글 밖에 찾아보지 못 했다. react 공식 문서가 그렇게 하라니까 그렇게 하는 것은 좋은데 나에게는 아래의 궁금증을 유발하면서 더 명확한 이유가 필요했다.

함수형 컴포넌트도 함수이므로 부모 컴포넌트에서 자식 컴포넌트로 ref를 전달하면 되는데 굳이 왜 forwardRef를 써야 될까? 🤔🤔🤔

이에 대한 고민, 리서치 및 테스트 등 약 5일간에 느낀 것을 글로 기록해보려고 한다.

input의 focus 예제를 통해서 부모의 ref참조를 자식에게 전달해보자

❌ 부모 ref 전달(ref 속성 이용)

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

  useEffect(() => {
    console.log("ref in Parent", ref);  // 1️⃣
  }, []);

  const onFocus = () => {
    ref.current?.focus();
  };

  return (
    <div>
      <Input ref={ref} />
      <button onClick={onFocus}>click to focus input</button>
    </div>
  );
}

function Input({ ref }: { ref: RefObject<HTMLInputElement> }) {
  useEffect(() => {
    console.log("ref in Child", ref); // 2️⃣
  }, []);

  return <input ref={ref} type="text" />;
}

테스트를 해보면 warning과 같이 출력결과를 볼 수 있다.

  • 1️⃣ 부모에서 ref: { current: null }
  • 2️⃣ 자식에서 ref: undefined

    ❗️❗️❗️warning
    Warning: Input: ref is not a prop. Trying to access it will result in undefined being returned. If you need to access the same value within the child component, you should pass it as a different prop

    이는 ref는 props로서 사용할 수 없다.라고 한다. react에서는 ref & key 속성을 예약어로서 지정하여 이 식별자로 다른 컴포넌트에게 전달할 수 없다.
    👉🏻 참고: special props

✅ 부모 ref 전달(inputRef 이용)

자식 컴포넌트에게 ref를 전달하기 위해 inputRef속성을 통해 props를 내려주자.

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

  useEffect(() => {
    console.log("inputRef in Parent", inputRef); // 1️⃣ 
  }, []);

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

  return (
    <div>
      <Input inputRef={inputRef} />
      <button onClick={onFocus}>click to focus input</button>
    </div>
  );
}

function Input({ inputRef }: { inputRef: RefObject<HTMLInputElement> }) {
  useEffect(() => {
    console.log("inputRef in Child", inputRef); // 2️⃣ 
  }, []);

  return <input ref={inputRef} type="text" />;
}

inputRef를 통해 부모의 ref 참조를 내려주면 warning이 발생하지 않고 아래의 컴포넌트 마운트 과정을 통해서 ref가 input element를 잘 참조함을 볼 수 있다.

  • 1️⃣ 부모에서 inputRef: { current: input }
  • 2️⃣ 자식에서 inputRef: { current: input }

💡 ref는 컴포넌트 렌더링 과정 중 commit 단계에서 요소와 연결됨으로 컴포넌트 mount가 끝난 시점인 useEffect에서 console을 찍어야 한다.

🔎 forwardRef

위에서 ref의 참조를 자식에게 내려주면 되는데 굳이 왜 forwardRef를 사용해야 되지? 라고 의문점을 가졌었다. 이에 대해 내가 느낀 것을 차근히 풀어보려 한다.(본인 주관입니다 ㅜㅜ)

1️⃣ 부모에서 자식에게 ref참조를 전달 명시

첫 번째 이유는 부모가 자식에게 ref 참조를 내려주고 있어~라는 코드의 목적이 확실히 보여서이다. 이는 다른 개발자 간에 협업을 유리하게 만들며 코드의 가독성을 높여준다고 생각한다.

input에 대한 ref는 inputRef, button에 대한 ref는 buttonRef와 같이 간단한 것은 typescript의 언어 서비스 도움으로 쉽게 해결할 수 있다 쳐도 HOC 컴포넌트에 ref를 전달하거나 독자적인 식별자 naming(inputRef가 아닌 ipRef)으로 props를 넘긴다면 코드의 가독성이 떨어질 것이다.

따라서, forwardRef를 이용한다는 것은 ref를 사용한다고 명시하면 부모가 자식에게 ref를 전달한다는 의미를 forwardRef를 아는 개발자라면 이를 직관적으로 알아볼 수 있을 것이다.

2️⃣ useImperativeHandle을 이용한 low-level 컴포넌트 구현 가능

두 번째 이유는 low-level component구현이 가능하다는 것이다. react에서 컴포넌트는 자체적인 상태를 갖기 때문에 독립적인 컴포넌트를 구성하여 재사용성이 용이하게 만드는 컴포넌트 기반 구조 패러다임을 지향하는 것이 좋다.

하지만, input을 focus하는 예제에서는 부모 컴포넌트에서 자식 컴포넌트 내에 있는 DOM과 직접 참조 관계를 형성한다.
따라서, 자식 컴포넌트인 Input 컴포넌트 내에서 focus를 할 수 있는 자체적인 inputRef와 돔의 참조관계를 유지하고 부모 컴포넌트에게는 focus를 트리거 할 수 있는 함수를 전달하면 Input 컴포넌트를 low-level 컴포넌트로 구현할 수 있다. 이는 다른 컴포넌트에서도 Input 컴포넌트를 재사용을 용이하게 만든다.

그리고 커스텀한 기능(컨트롤?)을 자식 컴포넌트 내에서만 제어할 수 있어 제어권이 부모가 아닌 자식에게 있다.

어떤 예제가 적합할까 생각하다가 개발자 아래와 같은 상황이 좋겠다 생각했다.

  • 개발자 A의도: focus 기능 추가하고 싶음
  • 개발자 B의도: blur 기능 추가하고 싶음

without useImperativeHandle

const App = () => {
  const [inputValue, setInputValue] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);

  const onClick = () => {
    if (inputRef.current) {
      inputRef.current?.focus();
      inputRef.current?.blur(); // 👉🏻 ❗️ 개발자 B의 실수
    }
  };

  return (
    <div>
      <input
        ref={inputRef}
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        type="text"
      />
      <button onClick={onClick}>focus input</button>
    </div>
  );
};

inputRef -> inputElement

개발자 B는 typescript가 blur라는 속성을 제공하는 것을 보고 그냥 blur기능을 추가하고 blur 동작에 문제가 없는 것을 확인하고 만족해 한다.하지만, 개발자 A가 다시 확인해보는데 focus가 되지 않는 현상을 발견한다.

극단적 예시지만...??? 위와 같은 상황에서는 개발자 B가 blur를 호출했을 때 에러를 띄워주거나 typescript compile time에 타입 에러를 발생시키면 이와 같은 일이 없을 것이다.
이를 useImperativeHandle을 이용하여 부모에게 사용할 수 있는 기능을 명시함으로써 문제를 해결해 보자

with useImperativeHandle

Input을 따로 빼서 low-level로 만들어보자.

// Input Component(자식)
import {
  DetailedHTMLProps,
  InputHTMLAttributes,
  forwardRef,
  useImperativeHandle,
  useRef,
  useState,
} from "react";

type InputHandler = { focus: () => void };

const Input = forwardRef<
  InputHandler,
  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
>((props, ref) => {
  const [inputValue, setInputValue] = useState("");
  const inputRef = useRef<HTMLInputElement>(null); // ⭐️ 자체적인 ref생성

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(), // 🚗 부모에게 노출할 기능
  }));

  return (
    <input
      {...props}
      ref={inputRef}
      value={inputValue}
      onChange={(e) => setInputValue(e.target.value)}
    />
  );
});

위와 같이 Input component를 따로 작성함으로써 다음과 같은 이점이 생긴다.

  1. 참조관계 제거: ⭐️를 보면 inputRef는 부모가 아닌 자식인 Input 컴포넌트에서 생성함으로써 부모가 직접 DOM에 접근하는 직접적인 참조관계를 제거할 수 있다.
  2. 제어권 확보: 🚗를 보면 부모에게 노출할 기능을 작성하여 부모가 아닌 Input에서 제어권을 갖는다.
  3. 자체적 상태 관리: input element에 대한 값에 대한 갱신을 Input 컴포넌트 내에서 관장한다.

without useImperativeHandle에서의 코드보다 Input 컴포넌트가 low-level component에 가까워졌다. input과 관련된 모든 로직을 Input컴포넌트가 관리하며 다른 컴포넌트에서는 그저 가져다 쓰기만 하면 된다.

이제, App에서의 코드를 보자.

// App Component(자식)
type InputHandler = { focus: () => void };

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

  const onClick = () => {
    if (inputRef.current) {
      inputRef.current.focus();
      inputRef.current.blur(); // 개발자 B가 시도 👉🏻 ❌
    }
  };

  return (
    <div>
      <Input type="text" ref={inputRef} />
      <button onClick={onClick}>focus input</button>
    </div>
  );
};

여기서 헷갈릴 수 있는데 App에서의 inputRef는 DOM이 아닌 focus라는 함수를 속성으로 가지는 객체다.

이제 개발자 B가 blur에 대한 함수를 작성한다고 하면 아래와 같이 typescript가 type 에러를 보여주며, 이를 무시하고 애플리케이션을 실행했다고 해도 오류를 나타낼 것이다.

<typescript type error⚠️ >

<blur -> undefined -> can't be invoked!!⚠️ >

3️⃣ 캡슐화 & 제어권 확보

Input에 값에 기본적인 유효성 검사는 은닉하면서 추가적인 유효성 검사를 진행하기 위해서는 어떨까?

예를 들어 undefined가 아니면서 빈 문자열이 아닌 검사는 기본적으로 검사해야한다. 기본적인 검사는 Input 컴포넌트 내에서처리하고 값에 대한 추가적인 검사는 콜백으로 넘겨서 처리할 수 있다.

먼저 기본적인 검사부터 보자

// Input.tsx
const Input = forwardRef<
  InputHandler,
  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
>((props, ref) => {
  const [inputValue, setInputValue] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    validateCheck: () => {
      // ✅ 기본적인 검사 진행
      const defaultCheck = inputValue !== undefined && inputValue.length >= 1; 

      return defaultCheck;
    },
  }));

  return (
    <input
      {...props}
      ref={inputRef}
      value={inputValue}
      onChange={(e) => setInputValue(e.target.value)}
    />
  );
});

위와 같이 Input 컴포넌트내에서 기본적으로 진행함으로써 내부로직을 감추고 Input을 가져다 쓰는 어떤 컴포넌트에서도 빈 문자열 처리에 대한 코드를 따로 작성하지 않아도 된다.

이제 추가적인 검사를 하고 싶은 것을 살펴보자

type InputHandler = {
  focus: () => void;
  // 👇 추가적으로 진행할 검사로직을 콜백으로 받아옴을 선언
  validateCheck: (cb?: (v: string) => boolean) => boolean;
};

const Input = forwardRef<InputHandler, .../ >((props, ref) => {
  ...//

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    validateCheck: (cb) => {
      const defaultCheck = inputValue !== undefined && inputValue.length >= 1;

      if (cb && typeof cb === "function") {
        // ✅ 기본 검사와 추가 검사를 진행
        return defaultCheck && cb(inputValue);
      }

      return defaultCheck;
    },
  }));
  
  ...//
});

이제 콜백을 통해서 추가적인 검사가 가능하게 되었다. 특수문자를 제외한 추가적인 검사를 진행하고 싶다면 부모인 App에서 콜백 내에서 검사 코드를 작성해주면된다.

type InputHandler = {
  focus: () => void;
  validateCheck: (cb?: (v: string) => boolean) => boolean;
};

// 특수문자 제외 
const checkNotSpecialChar = (s: string) => {
  const specialCharRegex = /[-’/`~!#*$@_%+=.,^&(){}[\]|;:”<>?\\]/g;

  return !specialCharRegex.test(s);
};

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

  const onClick = () => {
    if (inputRef.current) {
      // ✅기본 검사와 추가적인 검사 
      const isValidate = inputRef.current.validateCheck(checkNotSpecialChar);

      if (!isValidate) {
        inputRef.current.focus();
        alert("특수문자는 입력할 수 없습니다");
      }
    }
  };

  return (
    <div>
      <Input type="text" ref={inputRef} />
      <button onClick={onClick}>focus input</button>
    </div>
  );
};

위와 같은 구현을 통해 아래와 같은 이점을 갖는다.

  • 은닉화(캡슐화): 내부 동작은 외부에 노출시키지 않는다.
  • 제어권 확보: 콜백을 넘겨받아서 실행하는 주체가 자식이다.

< 결과 >

🔥 마치며

이 블로그는 forwardRef를 사용하는 방법보다는 혼자 공부하면서 느낀 것과 궁금증을 위주로 정리했다. 느낀 것이 틀린방향인지 맞는 방향인지는 모르지만, 시간이 더 흘러 실력이 는다면 뭐가 맞고 그른지 더 잘 판단할 수 있다. 그런데 좀 의아한 건 input state도 forwardRef 내부에 넣었는데 추천 검색어와 같은 경우는 onChange와 value를 내려줘야하는 상황이 오는데 뭐가 맞는지는 잘 모르겠다...흠...

📚 참고

special props
forwardRef docs
useImperativeHandle docs
manupulate DOM with ref docs

profile
step by step

1개의 댓글

comment-user-thumbnail
2024년 9월 21일

forwardRef를 이해하는데 많은 도움이 되었습니다

답글 달기