10日で覚えるReactDay 7: RefsとPortals
books.chapter 710日で覚えるReact

Day 7: RefsとPortals

今日学ぶこと

  • useRefフックの基本
  • DOM要素への直接アクセス
  • Refと状態の違い
  • forwardRefによるRef転送
  • Portalsの活用方法

Refとは

Ref(リファレンス) は、レンダリングに影響しない値を保持するための仕組みです。主にDOM要素への直接アクセスに使用します。

flowchart TB
    subgraph RefUseCases["Refの主な用途"]
        A["DOM要素へのアクセス"]
        B["タイマーIDの保持"]
        C["前回の値の保持"]
        D["外部ライブラリとの連携"]
    end

    style A fill:#3b82f6,color:#fff
    style B fill:#8b5cf6,color:#fff
    style C fill:#22c55e,color:#fff
    style D fill:#f59e0b,color:#fff

Stateとの違い

特徴 State Ref
更新時に再レンダリング する しない
値の保持 レンダリング間で保持 レンダリング間で保持
主な用途 UIに表示するデータ レンダリング不要なデータ
アクセス方法 直接参照 .currentプロパティ

useRefの基本

useRefは、Refオブジェクトを作成するフックです。

基本構文

import { useRef } from 'react';

function Component() {
  const ref = useRef(initialValue);
  // ref.current でアクセス
}

シンプルな例

function Counter() {
  const countRef = useRef(0);

  function handleClick() {
    countRef.current += 1;
    console.log(`クリック回数: ${countRef.current}`);
    // 注意: UIは更新されない!
  }

  return <button onClick={handleClick}>クリック</button>;
}
TypeScript版
function Counter(): React.JSX.Element {
  const countRef = useRef<number>(0);

  function handleClick(): void {
    countRef.current += 1;
    console.log(`クリック回数: ${countRef.current}`);
    // 注意: UIは更新されない!
  }

  return <button onClick={handleClick}>クリック</button>;
}

DOM要素へのアクセス

Refの最も一般的な用途は、DOM要素への直接アクセスです。

フォーカスの制御

function TextInput() {
  const inputRef = useRef(null);

  function handleFocus() {
    inputRef.current.focus();
  }

  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={handleFocus}>フォーカス</button>
    </div>
  );
}
TypeScript版
function TextInput(): React.JSX.Element {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleFocus(): void {
    inputRef.current?.focus();
  }

  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={handleFocus}>フォーカス</button>
    </div>
  );
}

スクロールの制御

function ScrollableList() {
  const listRef = useRef(null);

  function scrollToTop() {
    listRef.current.scrollTo({
      top: 0,
      behavior: 'smooth'
    });
  }

  function scrollToBottom() {
    listRef.current.scrollTo({
      top: listRef.current.scrollHeight,
      behavior: 'smooth'
    });
  }

  return (
    <div>
      <button onClick={scrollToTop}>上へ</button>
      <button onClick={scrollToBottom}>下へ</button>
      <ul ref={listRef} style={{ height: '200px', overflow: 'auto' }}>
        {Array.from({ length: 50 }, (_, i) => (
          <li key={i}>アイテム {i + 1}</li>
        ))}
      </ul>
    </div>
  );
}
TypeScript版
function ScrollableList(): React.JSX.Element {
  const listRef = useRef<HTMLUListElement>(null);

  function scrollToTop(): void {
    listRef.current?.scrollTo({
      top: 0,
      behavior: 'smooth'
    });
  }

  function scrollToBottom(): void {
    listRef.current?.scrollTo({
      top: listRef.current.scrollHeight,
      behavior: 'smooth'
    });
  }

  return (
    <div>
      <button onClick={scrollToTop}>上へ</button>
      <button onClick={scrollToBottom}>下へ</button>
      <ul ref={listRef} style={{ height: '200px', overflow: 'auto' }}>
        {Array.from({ length: 50 }, (_, i) => (
          <li key={i}>アイテム {i + 1}</li>
        ))}
      </ul>
    </div>
  );
}

要素のサイズ取得

function MeasureBox() {
  const boxRef = useRef(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  useEffect(() => {
    if (boxRef.current) {
      const { width, height } = boxRef.current.getBoundingClientRect();
      setDimensions({ width, height });
    }
  }, []);

  return (
    <div>
      <div ref={boxRef} style={{ width: '200px', height: '100px', background: 'lightblue' }}>
        ボックス
      </div>
      <p>サイズ: {dimensions.width} x {dimensions.height}</p>
    </div>
  );
}
TypeScript版
interface Dimensions {
  width: number;
  height: number;
}

function MeasureBox(): React.JSX.Element {
  const boxRef = useRef<HTMLDivElement>(null);
  const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });

  useEffect(() => {
    if (boxRef.current) {
      const { width, height } = boxRef.current.getBoundingClientRect();
      setDimensions({ width, height });
    }
  }, []);

  return (
    <div>
      <div ref={boxRef} style={{ width: '200px', height: '100px', background: 'lightblue' }}>
        ボックス
      </div>
      <p>サイズ: {dimensions.width} x {dimensions.height}</p>
    </div>
  );
}

前回の値を保持

Refを使って、前回レンダリング時の値を保持できます。

function PreviousValue() {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef();

  useEffect(() => {
    prevCountRef.current = count;
  });

  const prevCount = prevCountRef.current;

  return (
    <div>
      <p>現在: {count}, 前回: {prevCount ?? 'なし'}</p>
      <button onClick={() => setCount(count + 1)}>増やす</button>
    </div>
  );
}
TypeScript版
function PreviousValue(): React.JSX.Element {
  const [count, setCount] = useState<number>(0);
  const prevCountRef = useRef<number | undefined>();

  useEffect(() => {
    prevCountRef.current = count;
  });

  const prevCount = prevCountRef.current;

  return (
    <div>
      <p>現在: {count}, 前回: {prevCount ?? 'なし'}</p>
      <button onClick={() => setCount(count + 1)}>増やす</button>
    </div>
  );
}

カスタムフックに抽出

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// 使用例
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>現在: {count}, 前回: {prevCount ?? 'なし'}</p>
      <button onClick={() => setCount(count + 1)}>増やす</button>
    </div>
  );
}
TypeScript版
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// 使用例
function Counter(): React.JSX.Element {
  const [count, setCount] = useState<number>(0);
  const prevCount = usePrevious<number>(count);

  return (
    <div>
      <p>現在: {count}, 前回: {prevCount ?? 'なし'}</p>
      <button onClick={() => setCount(count + 1)}>増やす</button>
    </div>
  );
}

タイマーの管理

タイマーIDをRefで保持することで、クリーンアップが確実に行えます。

function Stopwatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef(null);

  function start() {
    if (!isRunning) {
      setIsRunning(true);
      intervalRef.current = setInterval(() => {
        setTime(prev => prev + 10);
      }, 10);
    }
  }

  function stop() {
    if (isRunning) {
      clearInterval(intervalRef.current);
      setIsRunning(false);
    }
  }

  function reset() {
    clearInterval(intervalRef.current);
    setIsRunning(false);
    setTime(0);
  }

  // クリーンアップ
  useEffect(() => {
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  const formatTime = (ms) => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    const centiseconds = Math.floor((ms % 1000) / 10);
    return `${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
  };

  return (
    <div>
      <p style={{ fontSize: '2rem' }}>{formatTime(time)}</p>
      <button onClick={start} disabled={isRunning}>開始</button>
      <button onClick={stop} disabled={!isRunning}>停止</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}
TypeScript版
function Stopwatch(): React.JSX.Element {
  const [time, setTime] = useState<number>(0);
  const [isRunning, setIsRunning] = useState<boolean>(false);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  function start(): void {
    if (!isRunning) {
      setIsRunning(true);
      intervalRef.current = setInterval(() => {
        setTime(prev => prev + 10);
      }, 10);
    }
  }

  function stop(): void {
    if (isRunning) {
      clearInterval(intervalRef.current!);
      setIsRunning(false);
    }
  }

  function reset(): void {
    clearInterval(intervalRef.current!);
    setIsRunning(false);
    setTime(0);
  }

  // クリーンアップ
  useEffect(() => {
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  const formatTime = (ms: number): string => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    const centiseconds = Math.floor((ms % 1000) / 10);
    return `${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
  };

  return (
    <div>
      <p style={{ fontSize: '2rem' }}>{formatTime(time)}</p>
      <button onClick={start} disabled={isRunning}>開始</button>
      <button onClick={stop} disabled={!isRunning}>停止</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

forwardRef

子コンポーネントのDOM要素に親からアクセスするには、forwardRefを使用します。

flowchart LR
    subgraph ForwardRef["forwardRefの仕組み"]
        Parent["親コンポーネント<br/>ref={inputRef}"]
        Child["子コンポーネント<br/>forwardRef"]
        DOM["DOM要素<br/>input"]
    end

    Parent -->|"ref転送"| Child -->|"ref適用"| DOM

    style Parent fill:#3b82f6,color:#fff
    style Child fill:#8b5cf6,color:#fff
    style DOM fill:#22c55e,color:#fff

基本的な使い方

import { forwardRef, useRef } from 'react';

// 子コンポーネント: forwardRefでラップ
const TextInput = forwardRef(function TextInput(props, ref) {
  return (
    <input
      ref={ref}
      type="text"
      className="custom-input"
      {...props}
    />
  );
});

// 親コンポーネント
function Form() {
  const inputRef = useRef(null);

  function handleFocus() {
    inputRef.current.focus();
  }

  return (
    <div>
      <TextInput ref={inputRef} placeholder="入力してください" />
      <button onClick={handleFocus}>フォーカス</button>
    </div>
  );
}
TypeScript版
import { forwardRef, useRef, ComponentPropsWithoutRef } from 'react';

// 子コンポーネント: forwardRefでラップ
const TextInput = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'>>(
  function TextInput(props, ref) {
    return (
      <input
        ref={ref}
        type="text"
        className="custom-input"
        {...props}
      />
    );
  }
);

// 親コンポーネント
function Form(): React.JSX.Element {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleFocus(): void {
    inputRef.current?.focus();
  }

  return (
    <div>
      <TextInput ref={inputRef} placeholder="入力してください" />
      <button onClick={handleFocus}>フォーカス</button>
    </div>
  );
}

useImperativeHandle

子コンポーネントから公開するメソッドを制限できます。

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

const CustomInput = forwardRef(function CustomInput(props, ref) {
  const inputRef = useRef(null);

  // 公開するメソッドを定義
  useImperativeHandle(ref, () => ({
    focus() {
      inputRef.current.focus();
    },
    clear() {
      inputRef.current.value = '';
    },
    getValue() {
      return inputRef.current.value;
    }
  }));

  return <input ref={inputRef} {...props} />;
});

// 使用例
function Form() {
  const inputRef = useRef(null);

  return (
    <div>
      <CustomInput ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>フォーカス</button>
      <button onClick={() => inputRef.current.clear()}>クリア</button>
      <button onClick={() => alert(inputRef.current.getValue())}>値を取得</button>
    </div>
  );
}
TypeScript版
import { forwardRef, useImperativeHandle, useRef, ComponentPropsWithoutRef } from 'react';

interface CustomInputHandle {
  focus: () => void;
  clear: () => void;
  getValue: () => string;
}

const CustomInput = forwardRef<CustomInputHandle, ComponentPropsWithoutRef<'input'>>(
  function CustomInput(props, ref) {
    const inputRef = useRef<HTMLInputElement>(null);

    // 公開するメソッドを定義
    useImperativeHandle(ref, () => ({
      focus() {
        inputRef.current?.focus();
      },
      clear() {
        if (inputRef.current) inputRef.current.value = '';
      },
      getValue() {
        return inputRef.current?.value ?? '';
      }
    }));

    return <input ref={inputRef} {...props} />;
  }
);

// 使用例
function Form(): React.JSX.Element {
  const inputRef = useRef<CustomInputHandle>(null);

  return (
    <div>
      <CustomInput ref={inputRef} />
      <button onClick={() => inputRef.current?.focus()}>フォーカス</button>
      <button onClick={() => inputRef.current?.clear()}>クリア</button>
      <button onClick={() => alert(inputRef.current?.getValue())}>値を取得</button>
    </div>
  );
}

Portals

Portalを使うと、コンポーネントのDOMツリー階層外にレンダリングできます。

flowchart TB
    subgraph ReactTree["Reactコンポーネントツリー"]
        App["App"]
        Content["Content"]
        Modal["Modal (Portal)"]
    end

    subgraph DOMTree["DOM構造"]
        Root["#root"]
        ContentDOM["Content DOM"]
        ModalRoot["#modal-root"]
        ModalDOM["Modal DOM"]
    end

    App --> Content
    Content --> Modal
    Root --> ContentDOM
    ModalRoot --> ModalDOM

    Modal -.->|"Portal"| ModalDOM

    style Modal fill:#f59e0b,color:#fff
    style ModalDOM fill:#f59e0b,color:#fff

基本的な使い方

import { createPortal } from 'react-dom';

function Modal({ children, onClose }) {
  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.getElementById('modal-root')
  );
}

// index.html に <div id="modal-root"></div> を追加
TypeScript版
import { createPortal } from 'react-dom';
import { ReactNode } from 'react';

interface ModalProps {
  children: ReactNode;
  onClose: () => void;
}

function Modal({ children, onClose }: ModalProps): React.ReactPortal {
  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.getElementById('modal-root')!
  );
}

// index.html に <div id="modal-root"></div> を追加

モーダルの完全な実装

import { useState } from 'react';
import { createPortal } from 'react-dom';

function Modal({ isOpen, onClose, title, children }) {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay">
      <div className="modal-content">
        <div className="modal-header">
          <h2>{title}</h2>
          <button onClick={onClose} className="close-button">×</button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>,
    document.getElementById('modal-root')
  );
}

function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div>
      <h1>メインコンテンツ</h1>
      <button onClick={() => setIsModalOpen(true)}>
        モーダルを開く
      </button>

      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title="確認"
      >
        <p>本当に実行しますか?</p>
        <button onClick={() => setIsModalOpen(false)}>キャンセル</button>
        <button onClick={() => {
          console.log('実行');
          setIsModalOpen(false);
        }}>OK</button>
      </Modal>
    </div>
  );
}
TypeScript版
import { useState, ReactNode } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps): React.ReactPortal | null {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay">
      <div className="modal-content">
        <div className="modal-header">
          <h2>{title}</h2>
          <button onClick={onClose} className="close-button">×</button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>,
    document.getElementById('modal-root')!
  );
}

function App(): React.JSX.Element {
  const [isModalOpen, setIsModalOpen] = useState<boolean>(false);

  return (
    <div>
      <h1>メインコンテンツ</h1>
      <button onClick={() => setIsModalOpen(true)}>
        モーダルを開く
      </button>

      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title="確認"
      >
        <p>本当に実行しますか?</p>
        <button onClick={() => setIsModalOpen(false)}>キャンセル</button>
        <button onClick={() => {
          console.log('実行');
          setIsModalOpen(false);
        }}>OK</button>
      </Modal>
    </div>
  );
}

Portalの用途

用途 説明
モーダル 画面全体を覆うダイアログ
ツールチップ 要素に紐づくポップアップ
ドロップダウン 親のoverflow設定を回避
通知 画面端に固定表示

ベストプラクティス

Refを使うべき場面

// ✅ Refを使うべき
// - DOM操作(フォーカス、スクロール)
// - タイマーIDの保持
// - 前回の値の保持
// - 外部ライブラリとの連携

// ❌ Refを使うべきでない
// - UIに表示するデータ → Stateを使う
// - 派生データの計算 → 通常の変数を使う

Portalを使うべき場面

// ✅ Portalを使うべき
// - モーダル、ダイアログ
// - ツールチップ、ポップオーバー
// - 親のCSS制約を回避したい場合

// ❌ Portalを使うべきでない
// - 通常のコンポーネント構成で解決できる場合

まとめ

概念 説明
useRef 再レンダリングなしで値を保持するフック
DOM Ref DOM要素への直接アクセス
forwardRef 子コンポーネントにRefを転送
useImperativeHandle 公開するRef APIをカスタマイズ
Portal DOMツリー外にレンダリング

重要ポイント

  1. Refの変更は再レンダリングを引き起こさない
  2. DOM要素のRefはレンダリング後に利用可能
  3. forwardRefで子コンポーネントのDOMにアクセス
  4. useImperativeHandleで公開APIを制限
  5. Portalはz-indexやoverflowの問題を解決

練習問題

問題1: 基本

テキスト入力フィールドとボタンを持つコンポーネントを作成してください。ボタンをクリックすると、入力フィールドにフォーカスが当たり、内容が選択された状態になるようにしてください。

問題2: 応用

画像ギャラリーを作成してください。サムネイルをクリックすると、その画像のモーダルが開いて大きく表示されます(Portalを使用)。

チャレンジ問題

カスタム動画プレイヤーコンポーネントを作成してください。useImperativeHandleを使って、親コンポーネントからplay(), pause(), seekTo(time)メソッドを呼び出せるようにしてください。


参考リンク


次回予告: Day 8では「Context APIと状態管理」について学びます。コンポーネント間でデータを効率的に共有する方法を理解しましょう。