useImperativeHandle 떠먹여드립니다.

dante Yoon·2022년 7월 31일
35

react

목록 보기
7/19

안녕하세요, 단테입니다.

오늘은 비교적 생소한 훅일 수 있는 useImperativeHandle에 대해 알아보겠습니다.

useImperative 영상 강의

이 훅은 child component의 상태 변경을 parent component에서 하거나, child component의 핸들러를 parent component에서 호출해야 하는 경우 사용하는 훅입니다.

React.forwardRef

당연하게도! 이 훅은 함수형 컴포넌트에서만 선언해서 사용할 수가 있는데요, 이 훅을 사용하기 위해서는 child component에서는 ref를 props로 전달받아야 합니다.

그런데, 단테의 useRef 떠먹여드립니다. 포스팅동영상을 보면, 함수형 컴포넌트에서는 ref를 props로 전달받지 못한다고 하는데 어떻게 전달받을 수 있을까요?

ref를 전달받는 child component에서 React.forwardRef를 사용하면 받을 수 있는데요, forwardRef는 상위 컴포넌트에서 전달받은 ref를 하위 컴포넌트로 전달하는 역할을 맡습니다. 이 함수는 React.ReactNode 타입을 반환하기 때문에 일반 함수형 컴포넌트와 동일하게 JSX 문법을 써서 렌더링 할 수 있습니다.

const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="FancyButton">
    {props.children}
  </button>
));

// You can now get a ref directly to the DOM button:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

useImperativeHandle을 사용해보자

child component의 상태 값을 parent component에서 바꿔보는 예제를 살펴볼게요. 동일한 컴포넌트를 두 페이지에서 각각 렌더링할 건데요, 이 때 테이블의 상태는 각 페이지에서 변경해보려고 합니다. 이상적인 시나리오는 아니지만 useImperativeHandle을 실습해보는 데는 부족함이 없을 것입니다.

전체 코드는 아래와 같습니다.

const IndexPage = () => {
  const tableRef = React.useRef({
    name: undefined,
    setName: () => {}
  });

  return (
    <div>
      <DataTable rows={initialRows} ref={tableRef} />
    </div>
  );
};

const SecondPage = () => {
  const tableRef = React.useRef({
    name: undefined,
    setName: () => {}
  });
  console.log("SecondPage: ", tableRef.current);

  return (
    <div>
      <DataTable rows={initialRows} ref={tableRef} />
    </div>
  );
};

const Layout = () => {
  const navigate = useNavigate();
  return (
    <>
      <div onClick={() => navigate("/")}>go '/'</div>
      <div onClick={() => navigate("/2")}>go '/2'</div>
      <Outlet />
    </>
  );
};

export default function App() {
  return (
    <div className="App">
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Layout />}>
            <Route path="/" element={<IndexPage />} />
            <Route path="/2" element={<SecondPage />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </div>
  );
}

그리고 테이블 컴포넌트입니다.

import React, { forwardRef, useImperativeHandle } from "react";

export const initialRows = [
  {
    name: "Charles",
    age: 20
  },
  {
    name: "Dante",
    age: 21
  },
  {
    name: "Peter",
    age: 22
  }
];

export const DataTable = forwardRef(({ rows }, ref) => {
  const [count, setCounts] = React.useState(0);
  const [name, setName] = React.useState();
  console.log("name: ", name);

  useImperativeHandle(ref, () => ({
    count,
    setCounts,
    name,
    setName
  }));

  const onClick = (name) => {
    setName(name);
  };

  return (
    <div>
      {rows.map(({ name, age }) => {
        return (
          <div key={name} onClick={() => onClick(name)}>
            <span>name: {name}</span>
            <span>age: {age}</span>{" "}
          </div>
        );
      })}
    </div>
  );
});

이 데이터 테이블 컴포넌트는 row props를 받아 아래와 같이 name, age를 렌더링 합니다.

그리고 페이지를 변경할 수 있도록 앱의 최상단에서 react-router-dom을 통해서 페이지를 네비게이션 합니다.

useImperativeHandle이 쓰인 부분을 보면, forwardRef의 인자 함수의 두번째 인자인 ref를 가져와 useImperativeHandle에서 인자를 사용하고 있습니다.

useImperativeHandle(ref, () => ({
  count,
  setCounts,
  name,
  setName
}));

이제 <DataTable/>를 선언하는 쪽을 볼까요?

const IndexPage = () => {
  const tableRef = React.useRef({
    name: undefined,
    setName: () => {}
  });

  return (
    <div>
      <DataTable rows={initialRows} ref={tableRef} />
    </div>
  );
};

DataTable ref props에 tableRef를 대입하고 있습니다.

전체 앱에서는 go "/"를 눌러 '/'로 이동, go "/2"를 눌러 '/2' 페이지로 이동하는데요,
데이터 테이블의 각 row를 누르면 이름이 콘솔에 기록되는 것을 알 수 있습니다.

아래 codesandbox를 이용해 콘솔을 찍어보세요.

언제 사용하면 좋을까요?

여기서부터는 제 개인적인 의견이 많이 들어가있습니다.

상위 컴포넌트에서는 여러 개의 하위 컴포넌트들을 포함할 수 있는데요,
이벤트 핸들러나 주요 비즈니스 로직을 상위 컴포넌트에서 작성해 하위 컴포넌트로 전달하는 경우,
상위 컴포넌트에서 많은 로직을 작성해야 하고 비대해지는 것을 감안해야 합니다.
요구 사항이 변경될 때마다 상태를 추가하거나 컴포넌트를 빼거나 더할 때 로직의 많은 부분도 변경되어야 하는데요,

A {
  aState1, aState2, aState3
  bState1, bState2, bState3
  cState1, cState2, cState3
  
  aHandler1, aHandler2, aHandler3
  bHandler1, bHandler2, bHandler3
  
  cHandler1, cHandler2, cHandler3
  
  return (
    <a
     aHandler1
     aHandler2
     aHandler3
    /> 
    <b
     bHandler1
     bHandler2
     bHandler3
    />
    <c
     cHandler1
     cHandler2
     cHandler3
    /> 
  );
}

수도 코드니까 문법은 감안해서 봐주세요!

너무너무 복잡한 경우 useImperativeHandle을 이용해 관심사의 분리와 함께 코드를 간결하게할 수 있지 않나 생각을 해봅니다.

aRef는 a 컴포넌트 내부에서 forwardRef를 선언하며 만든 인터페이스만 준수하면 될 것 같은데요.

A {
  aRef
  ...
  
  useEffect(() => {
    doSomething
  },[aRef.current])
  return (
    <a ref={aRef}/> 
    <b
     bHandler1
     bHandler2
     bHandler3
    />
    <c
     cHandler1
     cHandler2
     cHandler3
    /> 
  );
}

근데 이건 전혀 좋은 코드가 아닙니다.

그럼 useImperativeHandle 자주 활용하면 되겠네요!

아닙니다. 자주 활용하면 안됩니다.
어쩔 수 없는 경우에만 사용하라고 도큐먼트에서 말하고 있습니다.

항상 그렇듯이, 대부분의 경우 ref를 사용한 명령형 코드는 피해야 합니다.

따라서 상위 컴포넌트에서 하위 컴포넌트의 상태 값을 변경하거나, 특정 함수를 참조해야 하는 엣지케이스에서만 사용하는 것이 올바른 이용법이라고 생각합니다.

관련해서 왜 ref를 왜 자주 사용하면 안되는지에 대한 블로그 글이 있어 소개드립니다.(영문입니다),

간단히 눈에 들어온 것을 이야기하자면 리엑트는 state 기반의 ui 변경이 일어나는 data driven 프레임워크인데 ref를 사용한 명령형 프로그래밍이 리엑트의 선언형 프로그래밍 방식과 맞지 않고,
ref 값과 state를 따로 들고 가는 것에서 데이터 정합성이 틀려지는 경우가 생기는 경우가 있을 수 있다는 단점을 말합니다.

맞는 이야기입니다.

근데 너무 교과서적인 내용이지 않나 싶습니다. (개인적인 생각입니다.)
(필요한 장점은 가져갈 수 있지 않나...)

useImperative / ref 사용을 왜 최대한 피해야 하는지에 대해 리엑트 개발자들과 여러 개발 커뮤니티를 통해 이야기를 나눠 봤습니다.

reddit

Why imperative code using refs should be avoided?

  • ref를 사용하지 않아도 해결하려고 하는 문제들을 충분히 해결할 수 있기 때문이다.
  • react는 선언형 프로그래밍이다. ref를 사용한 명령형 프로그래밍 방식은 best practice가 아니다.

github

이 댓글에서도 또한..

리엑트는 선언형 프로그래밍 방식을 차용한다.
명령형 프로그래밍 방식에서는 이상적이지 않다.

불필요하게 복잡해진다.

일화

저는 react-drag-drop-files 라이브러리의 컨트리뷰터입니다. 이 라이브러리는 파일 업로드와 같이 브라우저에 드래그 이벤트를 통한 파일 이동을 간편하게 이용할 수 있게 도와주는데요,

제가 파일 업로드 기능을 제공하는 웹앱을 작성할 때 drag state 변경에 따른 UI 변경을 해야 하는 상황이 있었습니다.

이 때 라이브러리 내부 깊숙한 곳에 드래그 이벤트 여부를 판단하는 컴포넌트가 있어 이 라이브러리를 사용하는 측에서는 컴포넌트가 드래그 상황을 판단하기 어려웠는데요,

이 때 저는 useImperativeHandle을 이용한 방법을 제시하며 PR을 올렸습니다.

라이브러리 메인테이너가 제가 제안한 방법 보다는 외부에서 핸들러를 props로 전달받아 드래그 상태 여부를 파악하는 것을 제안해 수정하긴 했지만,

당시 이 훅을 알고 있었기에 이런 방법론을 생각하고 제안할 수 있었습니다.

글을 마무리하며

오늘은 useImperativeHandle에 대해 알아보았습니다.
이 훅이 생소한 이유는 리엑트 공식문서에서 사용을 지양하라고 안내하고 있기도하며, 대부분의 경우 좀 더 나은 컴포넌트 설계로 필요한 이슈들을 해결할 수 있기 때문입니다.

그래도 해당 훅이 필요한 엣지케이스를 아는 것은 중요합니다.

꼭 공식 문서를 살펴보시고, codesandbox에 있는 코드를 살펴보시고 실습해보시면 이해를 좀 더 쉽게 할 수 있을 것입니다.

읽어주셔서 감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

3개의 댓글

comment-user-thumbnail
2022년 8월 8일

저도 ref를 사용했을 때 오히려 코드가 이전보다 더 깔끔해진 적이 있었던 것 같아요. 필요한 경우에는 유용하게 쓸 수 있는 기능인 것 같습니다. 유익한 글 감사합니다!

답글 달기
comment-user-thumbnail
2023년 4월 12일

저도 해외 개발자 의견에 공감하는게, 프로젝트 규모가 작으면 모르겠는데 커질수록 ref등 조금씩 예외를 둬버리면 더 골치아파지는, 특히 다수의 개발자가 그 형식을 익혀야 하는 비용이 생기는것 같아 좀 규모가 있는 프로젝트는 ref보다 통상적인 방식이 더 효율적이 좋을것 같습니다. 잘보고 갑니다.

답글 달기
comment-user-thumbnail
2024년 1월 16일

공식문서와 해외포럼에서 괜히 이야기하는게 아닐거라서...
공통 컴퍼넌트에서 ref로 메서드를 과도하게 제공해주는 플젝을 경험해봤는데 1. 리액트가 명령형 프로그래밍이 아님. 2. 1번의 이유로 데이터 정합성이 어그러짐. 이 두가지 문제들로 인해 해당 컴퍼넌트를 사용하는 팀원들이 굉장히 고생했었습니다.

해당 스펙으로 무언가를 제어하려 할 때 setState로 상태를 변경함에서 시작하는게 아닌 사용자가 자꾸만 함수로 trigger를 해야만 하는 바닐라식 개발 패턴을 유도했고, 외부에서 함수를 trigger 할 수 있다는 이야긴 해당 컴퍼넌트 내부의 상태와 하등 관계없이 무언가를 제어한다는 이야기가 되기도 합니다.
따라서 필요한 데이터를 반환해주는 메서드를 제공해도, 그 메서드를 외부에서는 정확한 타이밍에 사용할 수 없다는게 해당 컴퍼넌트의 문제점이었습니다.

useImperativeHandle는 정말로 꼭 필요한 극소수의 상황에서만 사용하는게 맞을 것 같습니다.

답글 달기