4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TS×React useImperativeHandleのススメ ~ファイル選択用form部品を添えて~

4
Last updated at Posted at 2025-12-15

はじめに

Reactでは様々なhooksが用意されています。
その中でも、状態変数を管理するuseState、グローバルな状態(context)を管理するuseContextなどはよく用いられると思います。

しかし、useImperativeHandleを活用するシーンはそんなに多くないと思います。
今回は、ファイルのアップロード用のform部品である<input type="file" />のcomponentでうまくuseImperativeHandleを使えたので、その紹介です。

useImperativeHandleとは

公式
useImperativeHandle は、ref として公開されるハンドルをカスタマイズするための React フックです。

公式では「親コンポーネントにカスタムrefハンドルを公開する」において、ボタンを押下するとテキストボックスにフォーカスが当たる実装例を提供しています。

しかし「フォーカスを充てるためのボタンを実装する」というような場面はほとんどありません。
私自身、あまり実装に向いている場面が思い浮かばず全く用いてきませんでした。

<input type="file" />とは

MDN Web Docs
<input>要素のtype="file"型は、ユーザーが一つまたは複数のファイルを端末のストレージから選択することができるようにします。
選択されると、ファイルはフォーム投稿を使用してサーバーにアップロードしたり、JavaScript コードとファイル API を使用して操作したりすることができます。

<label for="avatar">
  Choose a profile picture:
</label>
<input type="file" id="avatar" name="avatar" accept="image/png, image/jpeg" />

image.png

ファイルをアップロードするためのform部品です。
現場業務で「画像や書類をアップロードする画面」を実装する中で、実装の必要が出てきました。

注意点

<input />自身にファイルの情報を保持しない

<input type="file" />は、ファイルがアップロードされていたとしても、それをvalue属性やfile属性 (こんな属性はありませんが) 等に保持しません。
一応、(document.getElementById("") as HTMLInputElement).filesのように、外部から現在設定されているファイルの状態を参照する術はありますが、React的な文法での参照方法(=タグの属性と状態変数を同期させる方法)はありません。

また、Reactでよく見るonChangeを監視してvalue属性にstateを渡す、という実装は必要ありません。
関連して、defaultValue属性も、「デフォルトのファイルを設定しておく」という意味では効果がないですね。

よくある例(テキストボックス)
Textbox.tsx
import { useState, type ChangeEvent, type FC } from "react";

export const Textbox: FC = () => {
  const [value, setValue] = useState<string>("");

  const handleChangeTextbox = (value: string) => setValue(value);

  return (
    <input
      type='text'
      value={value}
      onChange={(e: ChangeEvent<HTMLInputElement>) => handleChangeTextbox(e.currentTarget.value)}
    />
  );
};

そのため「アップロードされたファイルの情報をReactの世界で参照したい」という場合には、onChangeイベントを監視し、e.currentTarget.filesをstateに保持する必要があります。
その際、保持しているstate<input type="file" />に渡す必要はありません。

見た目のカスタマイズがしにくい

以下のように実装してみます。

components/FileInput.tsx
import { type FC } from "react";

export const FileInput: FC = () => {
  return (
    <input
      type='file'
      style={{ border: "1px solid black" }}
    />
  );
};
  • ファイルがアップロードされていない状態
    image.png

  • ファイルがアップロードされている状態
    image.png

黒い枠線で囲まれている部分が<input type="file" />のようです。
「ファイルを選択」ボタンと、ファイルの選択状態を表示するラベルがデフォルトで表示されるようです。
もっといい感じにUIを構築したいですよね。

実装してみる

アップロードされたファイルの保持

先述したように、現在アップロードされているファイルをReact的に参照する術がない以上、アップロードされたファイルをstateで保持する必要があります。
以下のように実装してみます。

components/FileInput.tsx
import { useState, type ChangeEvent, type FC } from "react";

export const FileInput: FC = () => {
  const [files, setFiles] = useState<File[]>([]);

  const handleChangeFile = (files: FileList | null) => {
    if (!files) {
      setFiles([]);
      return;
    }

    setFiles(Array.from(files));
  };

  // 仮でコンソールに出力
  console.log(files);

  return (
    <>
      <input
        type='file'
        style={{ border: "1px solid black" }}
        onChange={(e: ChangeEvent<HTMLInputElement>) => handleChangeFile(e.currentTarget.files)}
      />
    </>
  );
};

e.currentTarget.filesは、ファイルがアップロードされた場合FileList型で渡ってきます。
しかし、それだと扱いづらいためFile型の配列を状態変数filesに保持します。

問題点

上記のコードだと、<FileInput />を用いている親コンポーネントからfilesを参照する術がありません。
結局、親からは(document.getElementById("") as HTMLInputElement).filesを使わないとアクセスできない、という状態です。

useImperativeHandleを通じて、親にfilesを公開する

<FileInput />を以下のようにリファクタリングします。

types/components/FileInputRef.d.ts
export interface FileInputRef {
  /**
   * 現在のファイルを取得する
   * @returns {File[]}
   */
  getFiles: () => File[];

  /**
   * 現在のファイルの名称・MIMEを取得する
   * @returns {{fileName: string; type: string }[]}
   */
  getFileInfo: () => { fileName: string; type: string }[];
}
components/FileInput.tsx
import { useImperativeHandle, useState, type ChangeEvent, type FC, type RefObject } from "react";
import type { FileInputRef } from "../types/components/FileInputRef";

interface FileInputProps {
  /**
   * ref
   * @type {RefObject<FileInputRef | null>}
   */
  ref: RefObject<FileInputRef | null>;
}

export const FileInput: FC<FileInputProps> = ({ ref }) => {
  const [files, setFiles] = useState<File[]>([]);

  const handleChangeFile = (files: FileList | null) => {
    if (!files) {
      setFiles([]);
      return;
    }

    setFiles(Array.from(files));
  };

  useImperativeHandle(
    ref,
    () => ({
      getFiles: () => files,
      getFileInfo: () => files.map(({ name, type }) => ({ fileName: name, type })),
    }),
    [files]
  );

  return (
    <>
      <input
        id='a'
        type='file'
        style={{ border: "1px solid black" }}
        onChange={(e: ChangeEvent<HTMLInputElement>) => handleChangeFile(e.currentTarget.files)}
      />
    </>
  );
};

そして、親でrefを定義して<FileInput />に渡します。

親の実装
components/Parent.tsx
import { useRef, useState, type FC } from "react";
import type { FileInputRef } from "../types/components/FileInputRef";
import { FileInput } from "./FileInput";

export const Parent: FC = () => {
  const [result, setResult] = useState<string>("");

  const fileInputRef = useRef<FileInputRef>(null);

  const handleClickButton = () => setResult(JSON.stringify(fileInputRef.current?.getFileInfo()));

  return (
    <>
      <FileInput ref={fileInputRef} />
      <div>
        <button onClick={handleClickButton}>getFileInfoを実行</button>
        <p>{result}</p>
      </div>
    </>
  );
};

image.png

Parent.tsxにおいて、ref.current?.getFileInfo()を通じてファイルの情報にアクセスできています。

ポイント

refの型を定義

types/components/FileInputRef.d.tsにおいて、refの型を定義しています。
今回の実装の主眼は「親にfilesを公開する」という点だったため、filesをそのまま返却するgetFilesfilesの中のよく使う情報(ファイル名・MIME)の配列を返却するgetFileInfoを盛り込みました。

useImperativeHandleの第三引数の依存配列にfilesを渡す

useEffectフックの第二引数等でもおなじみですが、useImperativeHandleも第三引数に依存配列を受け取り、リアクティブな変数の変更をrefの内容に反映します。
もし空配列を渡すと、ファイルをアップロードしてもgetFilesgetFileInfoどちらも動作しないのでご注意を!
省略すると、毎回のレンダリング時にrefの内容を再生成するみたいですね~

もう少し実装してみる

types/components/FileInputRef.d.ts
export interface FileInputRef {
  ...
  /**
   * `accept`属性に含まれるファイルのみがアップロードされているかを検証する
   * @returns {boolean}
   */
  isValidFiles: () => boolean;
}
components/FileInput.tsx
import { memo, useImperativeHandle, useState, type ChangeEvent, type FC, type RefObject } from "react";
import type { FileInputRef } from "../types/components/FileInputRef";

interface FileInputProps {
  /**
   * ref
   * @type {RefObject<FileInputRef | null>}
   */
  ref: RefObject<FileInputRef | null>;

  /**
   * multiple属性
   * @type {boolean}
   */
  multiple?: boolean;

  /**
   * accept属性に設定したい内容の配列 \
   * `Array.join(",")`され設定される
   * @type {string[]}
   */
  accept?: string[];
}

export const FileInput: FC<FileInputProps> = memo(({ ref, multiple, accept }) => {
  const [files, setFiles] = useState<File[]>([]);

  const handleChangeFile = (files: FileList | null) => {
    if (!files) {
      setFiles([]);
      return;
    }

    setFiles(Array.from(files));
  };

  useImperativeHandle(
    ref,
    () => ({
      getFiles: () => files,
      getFileInfo: () => files.map(({ name, type }) => ({ fileName: name, type })),
      isValidFiles: () =>
        accept?.length ? files.map(({ type }) => type).every((type) => accept.includes(type)) : true,
    }),
    [accept, files]
  );

  return (
    <>
      <input
        type='file'
        multiple={multiple}
        accept={accept && accept.join(",")}
        style={{ border: "1px solid black" }}
        onChange={(e: ChangeEvent<HTMLInputElement>) => handleChangeFile(e.currentTarget.files)}
      />
    </>
  );
});

まず、<FileInput /><input type="file" />multiple属性accept属性用のpropsを追加しました。
そして、FileInputRef型に「accept属性に含まれるファイルのみがアップロードされているかを検証する」ためのメソッドであるisValidFilesを追加しています。

isValidFiles: () => accept?.length ? files.map(({ type }) => type).every((type) => accept.includes(type)) : true

accept属性の配列に1つ以上のMIMEが設定されている場合、ファイルのMIMEを検証します。
accept属性が空配列・undefinedの場合はtrueを返却します。
これで、formのsubmit時のバリデーションも楽に実施できます。

pngのみ許容
components/Parent.tsx
import { useRef, useState, type FC } from "react";
import type { FileInputRef } from "../types/components/FileInputRef";
import { FileInput } from "./FileInput";

export const Parent: FC = () => {
  const [getFileInfoResult, setGetFileInfoResult] = useState<string>("");
  const [isValidFiles, setIsValidFiles] = useState<boolean>(true);

  const fileInputRef = useRef<FileInputRef>(null);

  const handleClickGetFileInfoButton = () => setGetFileInfoResult(JSON.stringify(fileInputRef.current?.getFileInfo()));
  const handleClickIsValidFilesButton = () => setIsValidFiles(fileInputRef.current?.isValidFiles() ?? false);

  return (
    <>
      <FileInput ref={fileInputRef} accept={["image/png"]} />
      <div>
        <button onClick={handleClickGetFileInfoButton}>getFileInfoを実行</button>
        <p>{getFileInfoResult}</p>
      </div>
      <div>
        <button onClick={handleClickIsValidFilesButton}>isValidFilesを実行</button>
        <p>{String(isValidFiles)}</p>
      </div>
    </>
  );
};
  • pngファイルがアップロードされた場合
    image.png

  • pngファイル以外がアップロードされた場合
    image.png

UIをかっこよくする

image.png

components/FileInput.tsx
import { memo, useImperativeHandle, useRef, useState, type ChangeEvent, type FC, type RefObject } from "react";
import type { FileInputRef } from "../types/components/FileInputRef";
import fileInputImage from "../assets/file-input.jpg";

interface FileInputProps {
  /**
   * ref
   * @type {RefObject<FileInputRef | null>}
   */
  ref: RefObject<FileInputRef | null>;

  /**
   * multiple属性
   * @type {boolean}
   */
  multiple?: boolean;

  /**
   * accept属性に設定したい内容の配列 \
   * `Array.join(",")`され設定される
   * @type {string[]}
   */
  accept?: string[];
}

export const FileInput: FC<FileInputProps> = memo(({ ref, multiple, accept }) => {
  const [files, setFiles] = useState<File[]>([]);

  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleClickImage = () => fileInputRef.current?.click();

  const handleChangeFile = (files: FileList | null) => {
    if (!files) {
      setFiles([]);
      return;
    }

    setFiles(Array.from(files));
  };

  useImperativeHandle(
    ref,
    () => ({
      getFiles: () => files,
      getFileInfo: () => files.map(({ name, type }) => ({ fileName: name, type })),
      isValidFiles: () =>
        accept?.length ? files.map(({ type }) => type).every((type) => accept.includes(type)) : true,
    }),
    [accept, files]
  );

  return (
    <>
      <img src={fileInputImage} width={160} height={120} style={{ cursor: "pointer" }} onClick={handleClickImage} />
      <input
        ref={fileInputRef}
        type='file'
        multiple={multiple}
        accept={accept && accept.join(",")}
        style={{ display: "none" }}
        onChange={(e: ChangeEvent<HTMLInputElement>) => handleChangeFile(e.currentTarget.files)}
      />
    </>
  );
});

image.png

<input type="file" />自身にはdisplay: none;を適用して非表示にし、HTMLInputElement型のRefObjectを渡します。
画像のonClickイベントにてref.currentclickメソッドを実行することで、<input type="file" />を表示せずとも機能を使うことができます。

画像に限らずとも、「<input type="file" />refを設定する」「任意の要素 (画像・案内文・シートなど) のonClickイベントで<input type="file" />ref.current.clickを実行する」という手順で実装できます。
任意の要素には、好きなスタイルを設定することができます。

おわりに

いかがだったでしょうか。
<input type="file" />はほとんど触ったことがなく実装も手詰まり感があったのですが、ふと以前先輩からご教示いただいたuseImperativeHandleの記憶が降りてきました。

React v19からはrefpropsとして扱えるようになったため、useImperativeHandleももっと脚光を浴びるかもですね~

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?