React useRefの実務的な使い方:DOM操作から状態管理まで実装パターン集

React / Next.js

React useRefの実務的な使い方:DOM操作から状態管理まで実装パターン集

\n\n

はじめに

\n

Reactの開発に携わっていると、useStateだけでは対応できない場面に何度も出くわします。フォーム入力のフォーカス制御、ビデオプレイヤーの再生制御、スクロール位置の記憶など、これらの実務的な課題を解決するのがuseRefです。本記事では、教科書的な説明を避け、実際のプロダクション環境で使えるコード例を中心に、useRefの活用パターンを紹介します。

\n\n

useRefの簡易的な解説

\n

useRefは、Reactコンポーネント内で値を保持し続けるHooksです。useState と異なるのは、値を更新してもコンポーネントの再レンダリングが発生しないという点です。これが実務上、非常に重要な特徴になります。

\n\n

useRefの主な用途は以下の3つです:

\n

    \n

  • DOM要素への直接アクセス:フォーカス設定、値の読み取り
  • \n

  • 変更可能な値の保持:レンダリングに影響しない値管理
  • \n

  • 前回のレンダリング値の保存:値の変化を検知する
  • \n

\n\n

基本的な構文は以下の通りです:

\n\n

const ref = useRef<HTMLInputElement>(null);\n\n// ref.currentでアクセス\nref.current?.focus();

\n\n

業務でよく遭遇するuseRefのユースケース

\n\n

1. 検索フォームの自動フォーカス

\n

ユーザーが検索ボタンをクリックしたり、特定の操作をした後、自動的に入力フォームにフォーカスを当てたいというのは日常茶飯事です。通常のHTMLでは簡単に実現できますが、Reactでは少し工夫が必要です。

\n\n

2. モーダルダイアログの制御

\n

モーダルの開閉状態の管理だけでなく、開く際には特定のボタンにフォーカスを当てるなど、複雑なDOM操作が必要になります。

\n\n

3. インターバルタイマーの保存

\n

setIntervalやsetTimeoutの返り値を保存して、後でクリアしたいときに使用します。useStateで保存するとレンダリングが発生するため、useRefが適切です。

\n\n

4. ファイルアップロードの入力要素操作

\n

ファイル選択ダイアログをプログラムから開く必要がある場合、input要素への参照が必須です。

\n\n

実務コード例:検索フォームコンポーネント

\n\n

実際にユースケース1を実装してみます。以下は、検索結果が更新されたときに検索入力にフォーカスを当てるコンポーネントです:

\n\n

import React, { useRef, useState, useCallback, useEffect } from 'react';\nimport axios from 'axios';\n\ninterface SearchResult {\n  id: string;\n  title: string;\n  url: string;\n}\n\nconst SearchComponent: React.FC = () => {\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const [searchTerm, setSearchTerm] = useState('');\n  const [results, setResults] = useState<SearchResult[]>([]);\n  const [isLoading, setIsLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  // 検索実行関数\n  const handleSearch = useCallback(async (query: string) => {\n    if (!query.trim()) {\n      setResults([]);\n      return;\n    }\n\n    setIsLoading(true);\n    setError(null);\n\n    try {\n      // 実際のAPI呼び出し\n      const response = await axios.get('/api/search', {\n        params: { q: query }\n      });\n      setResults(response.data.results);\n    } catch (err) {\n      setError('検索に失敗しました');\n      console.error(err);\n    } finally {\n      setIsLoading(false);\n    }\n  }, []);\n\n  // 検索ボタンクリック時\n  const onSearchClick = useCallback(() => {\n    handleSearch(searchTerm);\n    // 検索後、入力フィールドに再度フォーカスを当てる\n    searchInputRef.current?.focus();\n  }, [searchTerm, handleSearch]);\n\n  // エンターキー押下での検索\n  const onKeyPress = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter') {\n      onSearchClick();\n    }\n  }, [onSearchClick]);\n\n  // 結果が更新されたときのフォーカス管理\n  useEffect(() => {\n    if (results.length > 0) {\n      // 結果が得られたら、フォーカスは入力フィールドに\n      searchInputRef.current?.focus();\n    }\n  }, [results]);\n\n  return (\n    
\n
\n setSearchTerm(e.target.value)}\n onKeyPress={onKeyPress}\n placeholder=\"検索キーワードを入力\"\n className=\"search-input\"\n disabled={isLoading}\n />\n \n {isLoading ? '検索中...' : '検索'}\n \n
\n\n {error &&

{error}

}\n\n {results.length > 0 && (\n
\n {results.map((result) => (\n
\n

\n {result.title}\n

\n

{result.url}

\n
\n ))}\n
\n )}\n
\n );\n};\n\nexport default SearchComponent;

\n\n

このコンポーネントでは、searchInputRefを使って以下を実現しています:

\n

    \n

  • 検索ボタンクリック後、入力フィールドへの再フォーカス
  • \n

  • 検索結果が得られた後、ユーザーが続けて検索できるようにフォーカス管理
  • \n

  • フォーカス管理によるユーザーエクスペリエンスの向上
  • \n

\n\n

実務コード例:タイマー管理コンポーネント

\n\n

次に、タイマーの開始・停止を管理するコンポーネントを実装します。setIntervalのIDを保存する必要があるため、useRefが活躍します:

\n\n

import React, { useRef, useState, useCallback } from 'react';\n\ninterface TimerState {\n  isRunning: boolean;\n  seconds: number;\n  totalSeconds: number;\n}\n\nconst CountdownTimer: React.FC<{ initialSeconds: number }> = ({\n  initialSeconds,\n}) => {\n  const timerIdRef = useRef<NodeJS.Timeout | null>(null);\n  const [timerState, setTimerState] = useState<TimerState>({\n    isRunning: false,\n    seconds: initialSeconds,\n    totalSeconds: initialSeconds,\n  });\n\n  // タイマー開始\n  const startTimer = useCallback(() => {\n    if (timerIdRef.current !== null) {\n      return; // すでに実行中\n    }\n\n    setTimerState((prev) => ({\n      ...prev,\n      isRunning: true,\n    }));\n\n    timerIdRef.current = setInterval(() => {\n      setTimerState((prev) => {\n        if (prev.seconds <= 1) {\n          // タイマー終了\n          if (timerIdRef.current) {\n            clearInterval(timerIdRef.current);\n            timerIdRef.current = null;\n          }\n          return {\n            ...prev,\n            seconds: 0,\n            isRunning: false,\n          };\n        }\n        return {\n          ...prev,\n          seconds: prev.seconds - 1,\n        };\n      });\n    }, 1000);\n  }, []);\n\n  // タイマー停止\n  const pauseTimer = useCallback(() => {\n    if (timerIdRef.current) {\n      clearInterval(timerIdRef.current);\n      timerIdRef.current = null;\n    }\n    setTimerState((prev) => ({\n      ...prev,\n      isRunning: false,\n    }));\n  }, []);\n\n  // タイマーリセット\n  const resetTimer = useCallback(() => {\n    if (timerIdRef.current) {\n      clearInterval(timerIdRef.current);\n      timerIdRef.current = null;\n    }\n    setTimerState({\n      isRunning: false,\n      seconds: initialSeconds,\n      totalSeconds: initialSeconds,\n    });\n  }, [initialSeconds]);\n\n  // コンポーネントアンマウント時にクリーンアップ\n  React.useEffect(() => {\n    return () => {\n      if (timerIdRef.current) {\n        clearInterval(timerIdRef.current);\n      }\n    };\n  }, []);\n\n  const formattedTime = {\n    minutes: Math.floor(timerState.seconds / 60),\n    seconds: timerState.seconds % 60,\n  };\n\n  const progressPercent =\n    (timerState.seconds / timerState.totalSeconds) * 100;\n\n  return (\n    
\n
\n
\n \n
\n\n
\n \n 開始\n \n \n 一時停止\n \n \n リセット\n \n
\n
\n );\n};\n\nexport default CountdownTimer;

\n\n

このコンポーネントの重要なポイント:

\n

    \n

  • useStateではなくuseRefでタイマーIDを保存することで、レンダリングを無駄に誘発しない
  • \n

  • クリーンアップ関数でタイマーを確実にクリアして、メモリリークを防止
  • \n

  • useCallbackを組み合わせることで、不必要な再レンダリングを防ぐ
  • \n

\n\n

実務コード例:ファイルアップロードコンポーネント

\n\n

ファイルアップロードは、hidden のinput要素を参照して、プログラムから開く必要があります:

\n\n

import React, { useRef, useState, useCallback } from 'react';\n\ninterface FileUploadState {\n  files: File[];\n  uploading: boolean;\n  uploadProgress: number;\n  error: string | null;\n}\n\nconst FileUploadComponent: React.FC = () => {\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const [uploadState, setUploadState] = useState<FileUploadState>({\n    files: [],\n    uploading: false,\n    uploadProgress: 0,\n    error: null,\n  });\n\n  // ファイル選択ダイアログを開く\n  const triggerFileInput = useCallback(() => {\n    fileInputRef.current?.click();\n  }, []);\n\n  // ファイル選択時の処理\n  const handleFileChange = useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) => {\n      const selectedFiles = event.target.files;\n      if (!selectedFiles) return;\n\n      const newFiles = Array.from(selectedFiles);\n\n      // ファイルサイズの検証\n      const maxSize = 10 * 1024 * 1024; // 10MB\n      const validFiles = newFiles.filter((file) => {\n        if (file.size > maxSize) {\n          setUploadState((prev) => ({\n            ...prev,\n            error: `${file.name} は大きすぎます(最大10MB)`,\n          }));\n          return false;\n        }\n        return true;\n      });\n\n      setUploadState((prev) => ({\n        ...prev,\n        files: [...prev.files, ...validFiles],\n        error: null,\n      }));\n\n      // input要素をリセット(同じファイルを再度選択可能にするため)\n      event.target.value = '';\n    },\n    []\n  );\n\n  // ファイルアップロード実行\n  const uploadFiles = useCallback(async () => {\n    if (uploadState.files.length === 0) return;\n\n    setUploadState((prev) => ({\n      ...prev,\n      uploading: true,\n      uploadProgress: 0,\n      error: null,\n    }));\n\n    const formData = new FormData();\n    uploadState.files.forEach((file) => {\n      formData.append('files', file);\n    });\n\n    try {\n      const xhr = new XMLHttpRequest();\n\n      // アップロード進捗を監視\n      xhr.upload.addEventListener('progress', (event) => {\n        if (event.lengthComputable) {\n          const progress = (event.loaded / event.total) * 100;\n          setUploadState((prev) => ({\n            ...prev,\n            uploadProgress: Math.round(progress),\n          }));\n        }\n      });\n\n      xhr.addEventListener('load', () => {\n        if (xhr.status === 200) {\n          setUploadState({\n            files: [],\n            uploading: false,\n            uploadProgress: 100,\n            error: null,\n          });\n        } else {\n          throw new Error('Upload failed');\n        }\n      });\n\n      xhr.addEventListener('error', () => {\n        setUploadState((prev) => ({\n          ...prev,\n          uploading: false,\n          error: 'アップロードエラーが発生しました',\n        }));\n      });\n\n      xhr.open('POST', '/api/upload');\n      xhr.send(formData);\n    } catch (err) {\n      setUploadState((prev) => ({\n        ...prev,\n        uploading: false,\n        error: 'アップロード処理中にエラーが発生しました',\n      }));\n    }\n  }, [uploadState.files]);\n\n  // ファイル削除\n  const removeFile = useCallback((index: number) => {\n    setUploadState((prev) => ({\n      ...prev,\n      files: prev.files.filter((_, i) => i !== index),\n    }));\n  }, []);\n\n  return (\n    
\n \n\n \n ファイルを選択\n \n\n {uploadState.files.length > 0 && (\n
\n

選択ファイル

\n
    \n {uploadState.files.map((file, index) => (\n
  • \n {file.name}\n removeFile(index)}\n disabled={uploadState.uploading}\n className=\"btn-remove\"\n >\n 削除\n \n
  • \n ))}\n
\n
\n )}\n\n {uploadState.uploading && (\n
\n
\n \n
\n {uploadState.uploadProgress}%\n
\n )}\n\n {uploadState.error && (\n

{uploadState.error}

\n )}\n\n {uploadState.files.length > 0 && !uploadState.uploading && (\n \n )}\n
\n );\n};\n\nexport default FileUploadComponent;

\n\n

よくある応用パターン

\n\n

パターン1:前回のレンダリング値の保存

\n\n

useRefを使って、前回のレンダリング時の値を保存し、値の変化を検知することができます:

\n\n

import React, { useRef, useEffect, useState } from 'react';\n\nconst PreviousValueComponent: React.FC<{ count: number }> = ({ count }) => {\n  const prevCountRef = useRef<number>();\n  const [isIncreasing, setIsIncreasing] = useState(false);\n\n  useEffect(() => {\n    if (prevCountRef.current !== undefined) {\n      const isInc = count > prevCountRef.current;\n      setIsIncreasing(isInc);\n    }\n    prevCountRef.current = count;\n  }, [count]);\n\n  return (\n    
\n

現在値: {count}

\n

前回値: {prevCountRef.current}

\n

変化: {isIncreasing ? '増加' : '減少'}

\n
\n );\n};\n\nexport default PreviousValueComponent;

\n\n

パターン2:複数のDOM要素への参照管理

\n\n

フォーム内の複数の入力フィールドを管理する場合:

\n\n

import React, { useRef, useCallback } from 'react';\n\ninterface FormRefs {\n  email: HTMLInputElement | null;\n  password: HTMLInputElement | null;\n  confirmPassword: HTMLInputElement | null;\n}\n\nconst LoginForm: React.FC = () => {\n  const formRefs = useRef<FormRefs>({\n    email: null,\n    password: null,\n    confirmPassword: null,\n  });\n\n  const focusFirstError = useCallback(() => {\n    // バリデーション処理と組み合わせて、最初のエラー要素にフォーカス\n    const emailValue = formRefs.current.email?.value || '';\n    const passwordValue = formRefs.current.password?.value || '';\n\n    if (!emailValue) {\n      formRefs.current.email?.focus();\n      return;\n    }\n    if (!passwordValue) {\n      formRefs.current.password?.focus();\n      return;\n    }\n  }, []);\n\n  const handleSubmit = useCallback(\n    (e: React.FormEvent) => {\n      e.preventDefault();\n      focusFirstError();\n    },\n    [focusFirstError]\n  );\n\n  return (\n    
\n {\n formRefs.current.email = el;\n }}\n type=\"email\"\n placeholder=\"メールアドレス\"\n />\n {\n formRefs.current.password = el;\n }}\n type=\"password\"\n placeholder=\"パスワード\"\n />\n \n \n );\n};\n\nexport default LoginForm;

\n\n

パターン3:スクロール位置の保存と復元

\n\n

リスト表示時にスクロール位置を保存し、戻った時に復元するパターン:

\n\n

import React, { useRef, useEffect, useCallback } from 'react';\n\nconst ScrollRestorationList: React.FC<{ items: string[] }> = ({ items }) => {\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n  const scrollPositionRef = useRef<number>(0);\n\n  // スクロール位置を記録\n  const handleScroll = useCallback(() => {\n    if (scrollContainerRef.current) {\n      scrollPositionRef.current = scrollContainerRef.current.scrollTop;\n    }\n  }, []);\n\n  // スクロール位置を復元\n  useEffect(() => {\n    if (scrollContainerRef.current) {\n      scrollContainerRef.current.scrollTop = scrollPositionRef.current;\n    }\n  }, [items]);\n\n  return (\n    \n      {items.map((item, index) => (\n        
\n {item}\n
\n ))}\n
\n );\n};\n\nexport default ScrollRestorationList;

\n\n

useRef使用時の注意点

\n\n

注意点1:レンダリングの最適化を逃さない

\n\n

useRefを多用すると、本来useStateで管理すべき値を見落とす可能性があります。状態の変化がUIに反映される必要がある場合は、必ずuseStateを使用してください。

\n\n

// ❌ 間違い:入力値の変化をUIに反映したいのにuseRefを使用\nconst BadExample = () => {\n  const inputRef = useRef('');\n  return (\n     {\n        inputRef.current = e.target.value;\n      }}\n    />\n  );\n};\n\n// ✅ 正解:入力値の変化をUIに反映させるにはuseStateを使用\nconst GoodExample = () => {\n  const [input, setInput] = useState('');\n  return (\n     setInput(e.target.value)}\n    />\n  );\n};

\n\n

注意点2:SSR環境での問題

\n\n

Next.jsなどのSSR環境では、サーバー側のレンダリングでref.currentは常にnullです。useEffectやコンポーネント内でnullチェックを忘れずに行いましょう:

\n\n

// ❌ 危険:SSRでエラーが発生する可能性\nconst UnsafeComponent = () => {\n  const divRef = useRef<HTMLDivElement>(null);\n  const width = divRef.current!.offsetWidth; // サーバー側ではnull\n  return 
Content
;\n};\n\n// ✅ 安全:useEffect内で処理することで、クライアント側のみで実行\nconst SafeComponent = () => {\n const divRef = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n\n useEffect(() => {\n if (divRef.current) {\n setWidth(divRef.current.offsetWidth);\n }\n }, []);\n\n return
Content
;\n};

\n\n

注意点3:関数型の参照の扱い

\n\n

refコールバック関数を使用する場合、毎回新しい関数を渡さないよう注意が必要です:

\n\n

// ❌ 危険:毎回新しい関数が作られ、再マウントが発生\nconst BadRefCallback = () => {\n  const handleRef = (el: HTMLDivElement) => {\n    console.log('mounted');\n  };\n  return 
Content
;\n};\n\n// ✅ 推奨:useCallbackでメモ化する\nconst GoodRefCallback = () => {\n const handleRef = useCallback((el: HTMLDivElement) => {\n console.log('mounted');\n }, []);\n return
Content
;\n};

\n\n

注意点4:クリーンアップの重要性

\n\n

タイマーやイベントリスナーをuseRefで管理している場合、必ずuseEffectの戻り値で清掃してください:

\n\n

const ProperCleanup = () => {\n  const listenerRef = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    listenerRef.current = () => {\n      console.log('window resized');\n    };\n    window.addEventListener('resize', listenerRef.current);\n\n    // クリーンアップ関数で確実にリスナーを削除\n    return () => {\n      if (listenerRef.current) {\n        window.removeEventListener('resize', listenerRef.current);\n      }\n    };\n  }, []);\n\n  return 
Resize listener attached
;\n};

\n\n

注意点5:useRefと依存配列

\n\n

useRef自体は再レンダリング時に同じオブジェクト参照を返すため、useEffect の依存配列に含めてはいけません:

\n\n


      
タイトルとURLをコピーしました