メインコンテンツまでスキップ

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つの用途があります。

  1. DOM要素への参照: input要素にフォーカスするなど
  2. 値の保持: タイマー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の違い

特徴useRefuseState
値の変更時の再レンダリングなしあり
値のアクセス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>
)
}

試してみよう: オートフォーカス

ページ読み込み時に自動的に入力フィールドにフォーカスが当たるログインフォームを作成してみましょう。

ヒント
  1. useRef<HTMLInputElement>(null)でrefを作成
  2. useEffectのマウント時(空の依存配列)にfocus()を呼ぶ
  3. 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を作成してみましょう。

ヒント
  1. メッセージリストの最下部に空のdivを配置
  2. その要素にrefを設定
  3. メッセージ追加時にscrollIntoView()を呼ぶ
  4. 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>
)
}

試してみよう: 前回の値を表示

現在の値と前回の値を両方表示するカウンターを作成してみましょう。

ヒント
  1. usePreviousカスタムフックを作成
  2. useRefuseEffectを組み合わせて前回の値を保持
  3. 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.currentundefinedで、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は以下の場面で有用です。

  1. DOM全体を公開したくない場合: セキュリティや安全性の観点から、特定の操作のみを許可
  2. 抽象化されたAPI: DOM操作の詳細を隠蔽し、わかりやすいメソッドを提供
  3. 複雑なコンポーネント: 複数の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によるグローバルな状態共有について学びます。

次に読む