React 19で、refのコールバックに後片付けの関数を返す方法

​​React​ref,TypeScript,useCallback,useImperativeHandle

React 19 では、ref に渡すコールバックから関数を返せるようになりました。返した関数は、要素が画面から外れるときに React が呼びます。そのため、ref で始めた処理の後片付けを、始める処理のすぐ下に書けます。

筆者は検証環境で、同じ TypeScript のコンポーネントを React 19 と React 18 で動かし、ref のコールバックと後片付けの関数がいつ呼ばれるかを記録しました。その結果と、公式ドキュメントに書かれている決まりをまとめます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。React 19 側は react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、比べる React 18 側は react / react-dom 18.3.1 です。どちらも esbuild 0.28.2 で変換し、jsdom 29.1.1 の上で描画しました。GIF は Playwright 1.56.1 で Chromium 141 を操作して撮りました。

後片付けの関数を返す書き方

React 19 の発表記事によると、ref のコールバックから返した関数は、コンポーネントが外れるときに呼ばれます。検証では、次の WithCleanup を用意しました。log は、呼ばれた順番を記録するための検証用の関数です。

tsx
import { log, name } from './log';

// 後片付けの関数を返す ref コールバック(毎回の描画で新しい関数になる)
export function WithCleanup({ label }: { label: string }) {
  return (
    <div
      ref={(node) => {
        log(`ref ${name(node)} (${label})`);
        return () => {
          log(`cleanup (${label})`);
        };
      }}
    >
      {label}
    </div>
  );
}

このコンポーネントを描画し、props を変えて再描画し、最後に削除しました。React 19 での記録は次のとおりです。

text
react 19.3.1

[A. 後片付けの関数を返す(インラインの関数)]
  1. 描画
    ref <div> (one)
  2. props を変えて再描画
    cleanup (one)
    ref <div> (two)
  3. 削除
    cleanup (two)

描画するとコールバックが <div> を受け取り、削除すると返した関数が呼ばれました。コールバックが null で呼ばれることは、一度もありませんでした。

関数を返さなければ、これまでどおり null で呼ばれる

関数を返さない、React 18 までの書き方も試しました。WithoutCleanup は、受け取った値を記録するだけで何も返しません。

text
[B. 何も返さない(インラインの関数)]
  1. 描画
    ref <div> (one)
  2. props を変えて再描画
    ref null (one)
    ref <div> (two)
  3. 削除
    ref null (two)

React 19 でも、要素が外れるときにコールバックが null で呼ばれました。ref コールバックのリファレンスには、これは以前の書き方との互換のための動きで、将来のバージョンでなくなると書かれています。後片付けが必要な処理は、関数を返す書き方に移しておくと安心です。

React 18 では返した関数が使われない

同じ WithCleanup を React 18 で動かすと、結果が変わりました。

text
react 18.3.1

[A. 後片付けの関数を返す(インラインの関数)]
  1. 描画
    ref <div> (one)
    console.error: Warning: Unexpected return value from a callback ref in div. A callback ref should not return a function.
  2. props を変えて再描画
    ref null (one)
    ref <div> (two)
    console.error: Warning: Unexpected return value from a callback ref in div. A callback ref should not return a function. (2回)
  3. 削除
    ref null (two)
    console.error: Warning: Unexpected return value from a callback ref in div. A callback ref should not return a function.

React 18 では、返した関数は一度も呼ばれず、要素が外れるときはコールバックが null で呼ばれました。関数を返すたびに「コールバックの ref は関数を返すべきではない」という警告も出ています。(2回)は、同じ警告が続けて2回出たことを、検証用のスクリプトで1行にまとめた印です。

注意

React 18 でも動かす必要があるコードで後片付けの関数だけに頼ると、後片付けが一度も行われません。React 18 では、コールバックが null を受け取ったときに片付ける書き方が必要です。

再描画のたびに呼ばれるのは、関数が毎回新しいから

最初の記録では、props を変えて再描画しただけで cleanup (one) と ref <div> (two) が続けて出ていました。リファレンスによると、React は前と違う関数が ref に渡されると、前の関数の後片付けを呼んでから、新しい関数を DOM ノードで呼びます。ref={(node) => { ... }} のように書くと描画のたびに新しい関数が作られるので、再描画のたびにこの入れ替えが起きます。

再描画のたびに片付けと準備をやり直したくないときは、useCallback で同じ関数を渡し続けます。

tsx
import { useCallback } from 'react';
import { log, name } from './log';

// useCallback で同じ関数を渡し続ける ref コールバック
export function StableCallback({ label }: { label: string }) {
  const ref = useCallback((node: HTMLDivElement | null) => {
    log(`ref ${name(node)}`);
    return () => {
      log('cleanup');
    };
  }, []);
  return <div ref={ref}>{label}</div>;
}
text
[C. useCallback で同じ関数を渡す]
  1. 描画
    ref <div>
  2. props を変えて再描画
  3. 削除
    cleanup

React 19 では、再描画のときに何も呼ばれず、描画と削除のときにだけ呼ばれました。

Strict Mode では、最初に準備と片付けが1回ずつ増える

リファレンスには、Strict Mode では開発中だけ、最初の準備の前に準備と片付けを1回ずつ余分に行うと書かれています。後片付けが、準備でしたことをきちんと元に戻しているかを確かめるための動きです。WithCleanup を <StrictMode> の中で描画したところ、そのとおりになりました。

text
[D. StrictMode の中で A を描画]
  1. 描画
    ref <div> (one)
    cleanup (one)
    ref <div> (one)
  2. 削除
    cleanup (one)

同じことを React 18 で試すと、余分な1回はありませんでした(警告の行は省いています)。

text
[D. StrictMode の中で A を描画]
  1. 描画
    ref <div> (one)
  2. 削除
    ref null (one)

一覧の項目ごとの DOM ノードを Map で持つ

後片付けの関数は、数が決まっていない要素の ref をまとめて持つときにも使えます。公式の学習ページにある、ref コールバックで一覧の ref を管理する例にならい、項目ごとの <li> を Map に入れ、外れるときに消す CatList を書きました。probe は、検証用に Map を外から見るための入れ物です。

tsx
import { useRef } from 'react';
import { probe } from './log';

// 公式ドキュメントの例と同じく、項目ごとの DOM ノードを Map で持つ
export function CatList({ cats }: { cats: string[] }) {
  const itemsRef = useRef<Map<string, HTMLLIElement | null>>(new Map());
  const map = itemsRef.current;
  probe.map = map; // 検証用
  return (
    <ul>
      {cats.map((cat) => (
        <li
          key={cat}
          ref={(node) => {
            map.set(cat, node);
            return () => {
              map.delete(cat);
            };
          }}
        >
          {cat}
        </li>
      ))}
    </ul>
  );
}

項目を消したり足したりしながら、Map の中身を記録しました。React 19 の結果です。

text
[F. 項目ごとの DOM ノードを Map で持つ]
  1. Neo, Millie, Bella を表示
    map size=3 { Neo=<li>, Millie=<li>, Bella=<li> }
  2. Millie を消す
    map size=2 { Neo=<li>, Bella=<li> }
  3. Bella を消す
    map size=1 { Neo=<li> }
  4. Luna を足す
    map size=2 { Neo=<li>, Luna=<li> }

React 18 では、消した項目が null のまま Map に残りました。「1.」の段は React 19 と同じだったため省き、警告の行も省いています。

text
  2. Millie を消す
    map size=3 { Neo=<li>, Millie=null, Bella=<li> }
  3. Bella を消す
    map size=3 { Neo=<li>, Millie=null, Bella=null }
  4. Luna を足す
    map size=4 { Neo=<li>, Millie=null, Bella=null, Luna=<li> }

React 18 は返した関数を呼ばず、代わりにコールバックを null で呼ぶためです。map.set(cat, node) に null が渡り、項目が消えずに残りました。

同じ CatList を Chromium で動かした様子がこちらです。Neo、Millie、Bella、Luna の4匹から始めて、先頭の猫を1匹ずつ3回消しました。上が React 18、下が React 19 です。

同じCatListをReact 18と19で縦に並べ、先頭の猫を消すボタンを3回押したときのMapの件数と中身を示す画面
先頭の猫を消すボタンを3回押したときの動き。React 18 は Map の件数が4のままで、消した猫が null で残る。React 19 は件数が3、2、1と減る

useImperativeHandle の ref にも使える

発表記事によると、後片付けの関数は DOM の ref だけでなく、クラスコンポーネントの ref や useImperativeHandle でも使えます。useImperativeHandle で { reset } を渡す子に、親から後片付けの関数を返すコールバックを渡して試しました。

tsx
import { useImperativeHandle, type Ref } from 'react';
import { log } from './log';

type CounterHandle = { reset: () => void };

// useImperativeHandle で、DOM ではなく独自のオブジェクトを ref に渡す子
function Counter({ ref }: { ref?: Ref<CounterHandle> }) {
  useImperativeHandle(ref, () => ({ reset: () => {} }), []);
  return <p>counter</p>;
}

export function HandleParent() {
  return (
    <Counter
      ref={(handle) => {
        log(`ref ${handle ? `{ ${Object.keys(handle).join(', ')} }` : 'null'}`);
        return () => {
          log('cleanup');
        };
      }}
    />
  );
}
text
[E. useImperativeHandle の ref に後片付けの関数を返す]
  1. 描画
    ref { reset }
  2. 削除
    cleanup

コールバックは useImperativeHandle で作ったオブジェクトを受け取り、削除のときに返した関数が呼ばれました。この子は ref を props で受け取る React 19 の書き方なので、React 18 では ref が届かず、どちらも呼ばれませんでした。クラスコンポーネントの ref は、今回は試していません。

TypeScript では、値を返すコールバックがエラーになる

後片付けの関数を返せるようになったため、React 19 の型では、ref のコールバックから関数以外を返すとエラーになります。発表記事では、アロー関数の式の形をやめるのがふつうの直し方だと説明されています。次のファイルで確かめました。

tsx
let instance: HTMLDivElement | null = null;

// 1つ目:アロー関数の式で代入して、その値を返してしまう書き方
export const A = () => <div ref={(current) => (instance = current)} />;
// 2つ目:ブロックで書けば何も返さない
export const B = () => <div ref={(current) => { instance = current; }} />;
text
src/TypeError.tsx(4,29): error TS2322: Type '(current: HTMLDivElement | null) => HTMLDivElement | null' is not assignable to type 'Ref<HTMLDivElement> | undefined'.
  Type '(current: HTMLDivElement | null) => HTMLDivElement | null' is not assignable to type '(instance: HTMLDivElement | null) => void | (() => VoidOrUndefinedOnly)'.
    Type 'HTMLDivElement | null' is not assignable to type 'void | (() => VoidOrUndefinedOnly)'.
      Type 'null' is not assignable to type 'void | (() => VoidOrUndefinedOnly)'.
tsc exit=1

エラーは4行目だけで、ブロックで書いた6行目は通りました。式の形の (instance = current) は代入した値を返すので、型の上では「後片付けの関数のつもりなのか」が区別できないためです。

同じ直し方をまとめて行う codemod もあります。発表記事で紹介されている types-react-codemod の no-implicit-ref-callback-return を、このファイルに実行しました。

text
$ npx types-react-codemod no-implicit-ref-callback-return target
text
4c4,6
< export const A = () => <div ref={(current) => (instance = current)} />;
---
> export const A = () => <div ref={current => {
>   (instance = current);
> }} />;

4行目がブロックの形に書き換わり、書き換えたあとのファイルは型チェックを通りました(tsc exit=0)。6行目は変わっていません。

ポイント

React 19 では、ref のコールバックから返した関数が、要素が外れるときに呼ばれます。関数を返すと null での呼び出しはなくなり、関数を返さなければこれまでどおり null で呼ばれます。再描画のたびに呼ばれるのを避けたいときは、useCallback で同じ関数を渡します。

​Posted by 86