useImperativeHandleで子コンポーネントにカスタムAPIを定義する

スポンサーリンク
// 子コンポーネントが公開するAPIの型
export type InputFieldHandle = {
  focus: () => void
  clear: () => void
  getValue: () => string
}

// useImperativeHandle で外から呼べるメソッドを定義する
const InputField = forwardRef<InputFieldHandle, Props>((props, ref) => {
  const [value, setValue] = useState('')
  const inputRef = useRef<HTMLInputElement>(null)

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    clear: () => { setValue('') },
    getValue: () => value,
  }))

  return <input ref={inputRef} value={value} onChange={(e) => setValue(e.target.value)} />
})

前の記事では forwardRef を使って親から子の DOM 要素に直接アクセスしました。今回はその発展として useImperativeHandle を紹介します。

forwardRef だけでは子の raw な DOM が露出しますが、useImperativeHandle を使うと子コンポーネントが「外から呼べるメソッド」を自分で定義できます。DOMの代わりに focus() / clear() / getValue() といったカスタム API を公開する形です。


構成

3つのコンポーネントで構成します。

  • InputField.tsxuseImperativeHandle でカスタムAPIを公開する子コンポーネント
  • FocusPanel.tsx:親から受け取ったrefを使って InputField を操作する子コンポーネント
  • App.tsx:ref を持ち、両コンポーネントをレンダリングする親コンポーネント

InputField.tsx:useImperativeHandle でAPIを定義する

まず公開するAPIの型を定義します。

export type InputFieldHandle = {
  focus: () => void
  clear: () => void
  getValue: () => string
}

次に forwardRefuseImperativeHandle を組み合わせます。

import { forwardRef, useImperativeHandle, useRef, useState } from 'react'

const InputField = forwardRef<InputFieldHandle, Props>(
  ({ label, placeholder, onFocus, onKeyDown }, ref) => {
    const [value, setValue] = useState('')
    const inputRef = useRef<HTMLInputElement>(null)

    useImperativeHandle(ref, () => ({
      focus: () => inputRef.current?.focus(),
      clear: () => { setValue('') },
      getValue: () => value,
    }))

    return (
      <div className="field">
        <label>{label}</label>
        <input
          ref={inputRef}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          onFocus={onFocus}
          onKeyDown={onKeyDown}
        />
      </div>
    )
  }
)

ポイントは2つです。

  • value の状態は InputField 自身が useState で管理します。親は state を持ちません
  • useImperativeHandle の第2引数に公開するメソッドを返す関数を渡します。外からは ref.current.focus() のように呼べます

App.tsx:ref の型が InputFieldHandle になる

親は useRef<InputFieldHandle>(null) で ref を作ります。HTMLInputElement ではなく InputFieldHandle 型になる点が forwardRef だけのときとの違いです。

const nameRef = useRef<InputFieldHandle>(null)
const emailRef = useRef<InputFieldHandle>(null)

送信時は getValue() で値を取得し、clear() でリセットします。

const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault()
  const name = nameRef.current?.getValue()
  const email = emailRef.current?.getValue()
  console.log({ name, email })
  emailRef.current?.clear()
  nameRef.current?.clear()
  nameRef.current?.focus()
}

FocusPanel.tsx:子コンポーネントから InputField を操作する

FocusPanelApp から nameRef / emailRef を props で受け取り、ボタン操作で InputField のメソッドを呼びます。

type Props = {
  nameRef: RefObject<InputFieldHandle | null>
  emailRef: RefObject<InputFieldHandle | null>
  lastFocusedRef: RefObject<InputFieldHandle | null>
}

function FocusPanel({ nameRef, emailRef, lastFocusedRef }: Props) {
  return (
    <div className="panel">
      {/* クリア後に対象フィールドへ移動 */}
      <button onClick={() => { nameRef.current?.clear(); nameRef.current?.focus() }}>
        名前をクリア
      </button>

      {/* クリア後に元のフィールドへ戻る */}
      <button onClick={() => { nameRef.current?.clear(); lastFocusedRef.current?.focus() }}>
        名前をクリア(元に戻る)
      </button>
    </div>
  )
}

フォーカス制御の2パターン

クリア後のフォーカス挙動は用途によって使い分けられます。

パターン1:クリア後に対象フィールドへ移動

clear() でリセットした直後に同じフィールドの focus() を呼ぶだけです。「クリアしたらそのフィールドを入力し直す」という流れに向いています。

// FocusPanel.tsx:クリア後に対象フィールドへ移動する
nameRef.current?.clear()
nameRef.current?.focus()

クリア後に対象フィールドへ移動

パターン2:クリア後に元のフィールドへ戻る

直前にフォーカスしていたフィールドを lastFocusedRef で追跡しておき、クリア後に戻します。

// App.tsx:onFocus でフォーカス位置を記録する
const lastFocusedRef = useRef<InputFieldHandle | null>(null)

<InputField
  ref={nameRef}
  onFocus={() => { lastFocusedRef.current = nameRef.current }}
  ...
/>
<InputField
  ref={emailRef}
  onFocus={() => { lastFocusedRef.current = emailRef.current }}
  ...
/>
// FocusPanel.tsx:クリア後に元の位置へ戻す
nameRef.current?.clear()
lastFocusedRef.current?.focus()

クリア後に元のフィールドへ戻る


forwardRef との違い

forwardRef のみ useImperativeHandle
親が取得するもの DOM 要素(HTMLInputElement カスタムAPI(InputFieldHandle
子の state 親が管理 子が自分で管理
公開する操作 DOM の全メソッド 定義したメソッドのみ
カプセル化 弱い 強い

useImperativeHandle は「子の内部実装を隠しながら、必要な操作だけを外に公開したい」場合に適しています。


まとめ

やりたいこと 使うもの ポイント
子にカスタムAPIを定義する useImperativeHandle 第2引数にメソッドを返す関数を渡す
子が自分で state を管理する useState を子の中に置く 親は state を持たない
直前のフォーカス位置を記録する useRef + onFocus クリア後の復元に使う
子から公開するAPIの型を定義する export type XxxHandle 親の useRef<XxxHandle> と合わせる