🤸‍♀️

ReactのRefとRefForwardingを一気に学び直した

2022/08/28に公開

動機

業務でstyled-componentsを触る機会がありました。
styled-componentsは自分の周りではあまり良い噂を聞いていなかったため、少し調べたところこんな記事がHitしました。
https://zenn.dev/yhase_rqp/articles/db63567117c110

記事の内容としては

  • 様々な基礎概念を隠蔽しすぎている
  • 使うことのメリットデメリットや、隠蔽されている部分の理解をしたチームでないと使わないほうが良い

という内容です。

なので、しっかりと順を追って復習しました。

環境

viteで作成したReact18のTypeScriptプロジェクトで実施。

Reactの利用は関数型コンポーネントを前提としています。

Refを軽くおさらい

公式Docを見ていただくのが一番正確です。

公式では

一般的な React のデータフローでは、props が、親コンポーネントがその子要素とやりとりする唯一の方法です。子要素を変更するには、新しい props でそれを再レンダーします。ただし、この一般的なデータフロー以外で、子要素を命令型のコードを使って変更する必要がある場合もあります。変更したい子要素が React コンポーネントのインスタンスのことも、DOM 要素のこともあるでしょう。どちらの場合でも、React は避難ハッチを提供します。

小難しいのですが、宣言的であるReactで手続き的にDOMを操作する際にしばしば利用されます。(厳密にはDOM操作だけのためにあるのものではないのですが割愛します。※インスタンス変数のようなものはありますか?

実際の利用イメージとしては、jQueryの手続き的にDOMを操作していく方法がとても近いです。

Refはいつ使うのか

公式が提示しているユースケースとしてもDOMへの直接的な操作を挙げています。

  • フォーカス、テキストの選択およびメディアの再生の管理
  • アニメーションの発火
  • サードパーティの DOM ライブラリとの統合

私が身近であると感じるのは以下の2点です。

  • サードパーティの DOM ライブラリとの統合
  • atomsのような非制御コンポーネントにとても近いコンポーネントの作成

atomsの非制御/制御についてはこちらの記事が大変分かりやすいです。

ハンズオン

Refの挙動のチェックから、RefForwardingにまで一気に触れていきたいと思います。

作ったコンポーネントにRefを渡してみる

こんなatomsのコンポーネントを用意してみました。
ポイントはこの時点では非制御コンポーネントであるbuttonにrefを渡していない部分です。

import React, { ComponentProps } from "react";

type Props = ComponentProps<"button">;

export const Button: React.FC<Props> = ({ children }) => {
  return <button>{children}</button>;
};

作成したButtonコンポーネントへRefを渡してDOMにアクセスしてみます。

import { useEffect, useRef } from "react";
import "./App.css";
import { Button } from "./components/core/Button";

function App() {
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (buttonRef.current) { // ①
      console.log(buttonRef.current);
      buttonRef.current.disabled = true;
    } else {
      console.log("ref is null"); // ②
    }
  },[]);

  return (
    <div className="App">
      <div>
        <h1>Hello</h1>
        <Button ref={buttonRef}>Click Me</Button> 
      </div>
    </div>
  );
}

export default App;
  1. DOMへアクセスできたら、buttonの状態をdisabledへ手続き的に変更する
  2. アクセスできない場合、consoleに ref is null と表示する。

結果はRefへのアクセスに失敗し、分岐はnullになります。

理由としてはRefを受け取っても、実際に返す非制御コンポーネントに渡されていないのが問題です。

import React, { ComponentProps } from "react";

type Props = ComponentProps<"button">;

export const Button: React.FC<Props> = ({ children }) => {
  return <button>{children}</button>; //ここにrefが渡されていない!
};

またエラーの内容にReact.forwardRef()がありますが、あとで触れますので一旦飛ばします。

PropsでRefを取り出して渡してみる

import React, { ComponentProps } from "react";

type Props = ComponentProps<"button">;

export const Button: React.FC<Props> = ({ children, ref }) => {
  return <button ref={ref}>{children}</button>;
};

エラーが変わって

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はpropではないという重要事実が発覚します。
もしpropで渡すのなら、別の(別変数に入れた)propとして渡してくださいとのこと。

独自のProps定義をしてRefを渡してみる

指示通り、myRefを定義して渡してみます。

import React, { ComponentProps } from "react";

type Props = ComponentProps<"button"> & {
  myRef?: React.RefObject<HTMLButtonElement>;
};

export const Button: React.FC<Props> = ({ children, myRef }) => {
  return <button ref={myRef}>{children}</button>;
};

意図したとおりにconsoleにdomが表示され、ボタンもdisabledになりました。

myRef ではなくもっと普通に非制御コンポーネントのようにrefを渡せないのか?

ここでRefの転送(Forwarding Ref という考え方、方法)が出てきます。

forwardRef関数 (引数にFCを受け取る高階関数)を使って、制御コンポーネントが渡されたrefオブジェクトを受け取る(転送)することができる

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

type Props = ComponentProps<"button">;

export const Button = forwardRef<HTMLButtonElement, Props>(
  ({ children }, ref) => {
    return <button ref={ref}>{children}</button>;
  }
);

もちろん利用側も非制御コンポーネントを扱うかのようにRefを渡せる

import { useEffect, useRef } from "react";
import "./App.css";
import { Button } from "./components/core/Button";

function App() {
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (buttonRef.current) {
      console.log(buttonRef.current);
      buttonRef.current.disabled = true;
    } else {
      console.log("ref is null");
    }
  },[]);

  return (
    <div className="App">
      <div>
        <h1>Hello</h1>
        <Button ref={buttonRef}>Click Me</Button> //違和感ないね!
      </div>
    </div>
  );
}

export default App;

一応こういった議論もあるようで、ComponentPropsWithRefなどを利用し厳密に型を定義したほうが良さそうです。

import React, { forwardRef } from "react";

type Props = React.ComponentPropsWithRef<"button">;

export const Button: React.FC<Props> = forwardRef<HTMLButtonElement, Props>(
  ({ children }, ref) => {
    return <button ref={ref}>{children}</button>;
  }
);

最後に

styled-componentsの利用から、隠蔽されているref、refの転送の理解などをする動機が得られたことを、冒頭の記事のおかげでできたため、作者であるhs様には感謝しています。

Discussion