useRefとDOM操作
この章では、DOMへの直接アクセスや値の保持に使用するuseRefフックと、関連するAPIについて学びます。
この章で学ぶこと
- useRefの2つの用途(DOM参照と値の保持)
- useStateとの違い
- refの転送(React 19 の ref props / React 18 以前の forwardRef)とuseImperativeHandle
- refを使うべき場面と避けるべき場面
この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。
useRefとは
useRefは、レンダリング間で値を保持し、変更してもコンポーネントを再レンダリングしないフックです。主に2つの用途があります。
- DOM要素への参照: input要素にフォーカスするなど
- 値の保持: タイマーIDの保持など、再レンダリングを引き起こしたくない値の管理
10章で触れた「非制御コンポーネント」はこのuseRefを使ってDOMから直接値を取得します。フォームの値管理についてはuseState(制御コンポーネント)が一般的ですが、ファイル入力などはuseRefが必要になります。
基本的な使い方
DOM要素への参照
import { useRef } from 'react'
function TextInput() {
// useRef<型>(初期値) でrefオブジェクトを作成
// <HTMLInputElement> は参照するDOM要素の型
// null は初期値(まだDOM要素が存在しないため)
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = () => {
// ref.current でDOM要素にアクセス
// ?. はオプショナルチェイニング(nullの場合はスキップ)
inputRef.current?.focus()
}
return (
<div>
{/* ref属性でDOM要素とrefオブジェクトを紐付け */}
<input ref={inputRef} type="text" />
<button onClick={handleClick}>フォーカス</button>
</div>
)
}
useRefの構造
const ref = useRef(initialValue)
// ref.current に値が格納される
console.log(ref.current) // initialValue
useRefとuseStateの違い
| 特徴 | useRef | useState |
|---|---|---|
| 値の変更時の再レンダリング | なし | あり |
| 値のアクセス | ref.current | 直接 |
| 主な用途 | DOM参照、前の値の保持 | UIに反映する状態 |
function Comparison() {
// useState: 値が変わると再レンダリングが発生
const [stateCount, setStateCount] = useState(0)
// useRef: 値が変わっても再レンダリングは発生しない
const refCount = useRef(0)
const handleStateClick = () => {
setStateCount(stateCount + 1) // 再レンダリングが発生 → 画面が更新される
console.log('state:', stateCount + 1)
}
const handleRefClick = () => {
refCount.current += 1 // 再レンダリングは発生しない → 画面は更新されない
console.log('ref:', refCount.current)
}
return (
<div>
<p>State: {stateCount}</p>
{/* refCount.current は変更されても、再レンダリングがないと画面に反映されない */}
<p>Ref: {refCount.current}</p>
<button onClick={handleStateClick}>State +1</button>
<button onClick={handleRefClick}>Ref +1</button>
</div>
)
}
実践的な使用例
入力フォームへのフォーカス
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
// マウント時にメールフィールドにフォーカス
useEffect(() => {
emailRef.current?.focus()
}, [])
const handleEmailKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
passwordRef.current?.focus()
}
}
return (
<form>
<input
ref={emailRef}
type="email"
placeholder="メールアドレス"
onKeyDown={handleEmailKeyDown}
/>
<input
ref={passwordRef}
type="password"
placeholder="パスワード"
/>
<button type="submit">ログイン</button>
</form>
)
}
試してみよう: オートフォーカス
ページ読み込み時に自動的に入力フィールドにフォーカスが当たるログインフォームを作成してみましょう。
ヒント
useRef<HTMLInputElement>(null)でrefを作成useEffectのマウント時(空の依存配列)にfocus()を呼ぶ- Enterキーでパスワードフィールドに移動すると便利
回答と解説
import { useRef, useEffect } from 'react'
import './App.css'
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
useEffect(() => {
emailRef.current?.focus()
}, [])
const handleEmailKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
passwordRef.current?.focus()
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('ログイン処理')
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
ref={emailRef}
type="email"
placeholder="メールアドレス"
onKeyDown={handleEmailKeyDown}
/>
</div>
<div>
<input
ref={passwordRef}
type="password"
placeholder="パスワード"
/>
</div>
<button type="submit">ログイン</button>
</form>
)
}
export default function App() {
return <LoginForm />
}
useEffectの依存配列を空にすることで、マウント時に1回だけfocus()が実行されます。Enterキーでパスワードフィールドに移動する機能も追加しました。
動画プレイヤー
function VideoPlayer() {
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
setIsPlaying(!isPlaying)
}
}
return (
<div>
<video ref={videoRef} src="/video.mp4" />
<button onClick={handlePlayPause}>
{isPlaying ? '一時停止' : '再生'}
</button>
</div>
)
}
スクロール位置の制御
function ScrollToBottom() {
const bottomRef = useRef<HTMLDivElement>(null)
const [messages, setMessages] = useState<string[]>([])
const addMessage = () => {
setMessages(prev => [...prev, `メッセージ ${prev.length + 1}`])
}
// メッセージ追加時に最下部にスクロール
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
return (
<div>
<div style={{ height: '200px', overflow: 'auto' }}>
{messages.map((msg, i) => (
<p key={i}>{msg}</p>
))}
<div ref={bottomRef} />
</div>
<button onClick={addMessage}>メッセージ追加</button>
</div>
)
}
試してみよう: チャットUIのスクロール
メッセージを追加するたびに自動で最下部にスクロールするチャットUIを作成してみましょう。
ヒント
- メッセージリストの最下部に空の
divを配置 - その要素にrefを設定
- メッセージ追加時に
scrollIntoView()を呼ぶ behavior: 'smooth'でスムーズにスクロール
回答と解説
import { useState, useRef, useEffect } from 'react'
import './App.css'
function ChatUI() {
const [messages, setMessages] = useState<string[]>([])
const [input, setInput] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim()) {
setMessages(prev => [...prev, input])
setInput('')
}
}
return (
<div>
<div style={{ height: '300px', overflow: 'auto', border: '1px solid #ccc' }}>
{messages.map((msg, index) => (
<div key={index} style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
{msg}
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="メッセージを入力"
/>
<button type="submit">送信</button>
</form>
</div>
)
}
export default function App() {
return <ChatUI />
}
メッセージリストの最後に空のdivを配置し、scrollIntoView()でスクロールします。behavior: 'smooth'でスムーズなスクロールアニメーションになります。
前回の値を保持
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined)
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
// 使用例
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
const handleIncrement = () => {
setCount(prev => prev + 1)
}
return (
<div>
<p>現在: {count}, 前回: {prevCount}</p>
<button onClick={handleIncrement}>+1</button>
</div>
)
}
試してみよう: 前回の値を表示
現在の値と前回の値を両方表示するカウンターを作成してみましょう。
ヒント
usePreviousカスタムフックを作成useRefとuseEffectを組み合わせて前回の値を保持useEffectはレンダリング後に実行されることを利用
回答と解説
import { useState, useRef, useEffect } from 'react'
import './App.css'
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined)
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
function CounterWithPrevious() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
const handleIncrement = () => {
setCount(prev => prev + 1)
}
const handleDecrement = () => {
setCount(prev => prev - 1)
}
const handleReset = () => {
setCount(0)
}
return (
<div>
<p>現在の値: {count}</p>
<p>前回の値: {prevCount ?? '(なし)'}</p>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
<button onClick={handleReset}>リセット</button>
</div>
)
}
export default function App() {
return <CounterWithPrevious />
}
usePreviousカスタムフックでは、useEffectがレンダリング後に実行されることを利用しています。最初のレンダリング時はref.currentはundefinedで、useEffect実行後に現在の値が保存されます。次のレンダリング時には、前回保存した値が返されます。
タイマーIDの保持
function Timer() {
const [seconds, setSeconds] = useState(0)
const [isRunning, setIsRunning] = useState(false)
// useRef でタイマーIDを保持(再レンダリングを引き起こさずに値を保持)
// number | null は setInterval の戻り値の型
const intervalRef = useRef<number | null>(null)
const start = () => {
if (!isRunning) {
setIsRunning(true)
// setInterval のIDを ref.current に保存
intervalRef.current = window.setInterval(() => {
setSeconds(prev => prev + 1)
}, 1000)
}
}
const stop = () => {
if (intervalRef.current) {
// 保存しておいたIDで clearInterval を呼び出し
clearInterval(intervalRef.current)
intervalRef.current = null
}
setIsRunning(false)
}
const reset = () => {
stop()
setSeconds(0)
}
// クリーンアップ: コンポーネントがアンマウントされるときにタイマーを停止
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [])
return (
<div>
<p>{seconds}秒</p>
<button onClick={start} disabled={isRunning}>開始</button>
<button onClick={stop} disabled={!isRunning}>停止</button>
<button onClick={reset}>リセット</button>
</div>
)
}
子コンポーネントへのref転送
子コンポーネントのDOM要素に親からアクセスしたい場合のパターンです。React 19 からは大幅にシンプルになりました。
React 19+: ref を props として受け取る(推奨)
React 19 からは、関数コンポーネントが通常の Props と同じように ref を受け取れます。forwardRef で包む必要がありません。
import { useRef } from 'react'
type CustomInputProps = {
placeholder?: string
ref?: React.Ref<HTMLInputElement>
}
// 普通のpropsとしてrefを受け取るだけ
function CustomInput({ ref, ...props }: CustomInputProps) {
return <input ref={ref} className="custom-input" {...props} />
}
// 親コンポーネントで使用
function Form() {
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = () => {
inputRef.current?.focus()
}
return (
<div>
<CustomInput ref={inputRef} placeholder="入力してください" />
<button onClick={handleClick}>フォーカス</button>
</div>
)
}
forwardRef(React 18 以前 / 互換性のため)
React 18 以下や、既存コードで forwardRef を読む場面があるため、参考として記しておきます。
import { forwardRef, useRef } from 'react'
// forwardRef: 親から受け取ったrefを子のDOM要素に転送する
// forwardRef<DOM要素の型, Propsの型>(コンポーネント関数)
const CustomInput = forwardRef<HTMLInputElement, { placeholder?: string }>(
// 第1引数: props、第2引数: 親から転送されたref
(props, ref) => {
return (
<input
ref={ref} // 親から渡されたrefをinput要素に紐付け
className="custom-input"
{...props}
/>
)
}
)
forwardRefを使用する場合、コンポーネントにdisplayNameを設定することで、React DevToolsでのデバッグが容易になります。
React 19 から forwardRef は非推奨ではないものの不要になりました。新規コードではシンプルな props 受け取り形式を使い、既存の forwardRef を書き換える必要はありません。段階的な移行で十分です。なお React 公式は、将来のバージョンで forwardRef を非推奨化・削除する予定を予告しています。
実用的な例: カスタムボタン
type ButtonProps = {
children: React.ReactNode
variant?: 'primary' | 'secondary'
ref?: React.Ref<HTMLButtonElement>
} & React.ButtonHTMLAttributes<HTMLButtonElement>
function Button({ children, variant = 'primary', className, ref, ...props }: ButtonProps) {
const baseClasses = 'px-4 py-2 rounded font-medium'
const variantClasses = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
}
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${className ?? ''}`}
{...props}
>
{children}
</button>
)
}
ref callbackとcleanup関数(React 19+)
ref 属性にはrefオブジェクトのほかに関数(ref callback)も渡せます。要素がDOMに追加されたタイミングで、その要素を引数に呼ばれます。さらに React 19 からは、ref callbackからcleanup関数を返せるようになりました。
<div
ref={node => {
// 要素がDOMに追加されたときに呼ばれる
console.log('attached:', node)
// cleanup関数を返すと、要素がDOMから取り除かれるとき(detach時)に呼ばれる(React 19以降)
return () => {
console.log('detached')
}
}}
/>
useImperativeHandle
useImperativeHandleを使うと、親に公開するrefの値をカスタマイズできます。これにより、DOM要素全体ではなく、特定のメソッドのみを公開できます。
import { useRef, useImperativeHandle } from 'react'
// 公開するメソッドの型定義(DOM要素そのものではなく、特定のメソッドのみ)
type CustomInputHandle = {
focus: () => void
clear: () => void
getValue: () => string
}
// ref を CustomInputHandle 型の props として受け取る
function CustomInput({ label, ref }: { label: string; ref?: React.Ref<CustomInputHandle> }) {
// 内部でDOM要素への参照を保持
const inputRef = useRef<HTMLInputElement>(null)
// useImperativeHandle: 親に公開するメソッドをカスタマイズ
// 第1引数: 親から受け取ったref
// 第2引数: 公開するオブジェクトを返す関数
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current?.focus()
},
clear: () => {
if (inputRef.current) {
inputRef.current.value = ''
}
},
getValue: () => {
return inputRef.current?.value ?? ''
}
}))
return (
<div>
<label>{label}</label>
<input ref={inputRef} type="text" />
</div>
)
}
// 親コンポーネント
function Form() {
// CustomInputHandle型のrefを作成(DOM要素ではなくカスタムメソッド)
const inputRef = useRef<CustomInputHandle>(null)
const handleFocus = () => {
// 子コンポーネントが公開したメソッドを呼び出し
inputRef.current?.focus()
}
const handleSubmit = () => {
const value = inputRef.current?.getValue()
console.log('入力値:', value)
inputRef.current?.clear()
}
return (
<div>
<CustomInput ref={inputRef} label="名前" />
<button onClick={handleFocus}>フォーカス</button>
<button onClick={handleSubmit}>送信</button>
</div>
)
}
使用場面
useImperativeHandleは以下の場面で有用です。
- DOM全体を公開したくない場合: セキュリティや安全性の観点から、特定の操作のみを許可
- 抽象化されたAPI: DOM操作の詳細を隠蔽し、わかりやすいメソッドを提供
- 複雑なコンポーネント: 複数のDOM要素を持つコンポーネントで、統一されたインターフェースを提供
useImperativeHandleの使用は必要な場合に限定してください。ほとんどの場合、Propsとコールバックで十分です。refによる命令的なコードは、Reactの宣言的なパラダイムから外れるため、過度な使用は避けましょう。
refを使うべき場面
適切な使用場面
- フォーカス、テキスト選択、メディア再生の制御
- アニメーションのトリガー
- サードパーティのDOMライブラリとの統合
- 前回の値やタイマーIDなど、レンダリングに影響しない値の保持
避けるべき使用場面
- 宣言的に解決できる問題(Stateで十分な場合)
- レンダリングに必要なデータの保持(Stateを使うべき)
- 頻繁にref.currentを読み書きする場合
// NG: refでUIを制御しようとしている
function BadExample() {
const divRef = useRef<HTMLDivElement>(null)
const handleClick = () => {
if (divRef.current) {
divRef.current.style.display = 'none' // 命令的
}
}
return <div ref={divRef}>...</div>
}
// OK: Stateで宣言的に制御
function GoodExample() {
const [isVisible, setIsVisible] = useState(true)
return isVisible ? <div>...</div> : null
}
まとめ
useRefは再レンダリングを引き起こさずに値を保持- DOM要素への参照取得に使用
- タイマーID、前回の値など、UIに影響しない値の保持に使用
- 子コンポーネントのDOM要素は
refを props として受け取って親に公開(forwardRefは React 18 以前の方法) useImperativeHandleで公開するメソッドをカスタマイズ- refの使用は必要な場面に限定し、可能な限り宣言的なアプローチを優先
次の章では、useContextによるグローバルな状態共有について学びます。