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 \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\nexport default CountdownTimer;
\n\n
このコンポーネントの重要なポイント:
\n
- \n
- useStateではなくuseRefでタイマーIDを保存することで、レンダリングを無駄に誘発しない
- クリーンアップ関数でタイマーを確実にクリアして、メモリリークを防止
- useCallbackを組み合わせることで、不必要な再レンダリングを防ぐ
\n
\n
\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 {uploadState.files.length > 0 && (\n \n 選択ファイル
\n \n {uploadState.files.map((file, index) => (\n - \n {file.name}\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};\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

