11.1 什麼是 useRef?

useRef 回傳一個可變的 ref 物件,其 .current 屬性在整個元件生命週期中保持不變。

useRef 的兩個主要用途

  1. 存取 DOM 元素(類似 document.getElementById
  2. 保存可變值(不會觸發重新渲染)
import { useRef } from 'react';

function BasicRef() {
  // 建立 ref
  const myRef = useRef(initialValue);
  // myRef = { current: initialValue }

  // 讀取值
  console.log(myRef.current);

  // 修改值(不會觸發重新渲染!)
  myRef.current = newValue;
}

useRef vs useState

比較 useState useRef
觸發渲染 ✅ setter 會觸發 ❌ 不會觸發
值保存 ✅ 渲染之間保存 ✅ 渲染之間保存
適用場景 UI 需要反映的資料 不需要顯示在 UI 的資料

11.2 用 useRef 存取 DOM

基本 DOM 操作

import { useRef } from 'react';

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

  const handleClick = () => {
    inputRef.current.focus();          // 聚焦
    inputRef.current.select();         // 選取文字
  };

  const handleScrollToTop = () => {
    inputRef.current.scrollIntoView({ behavior: 'smooth' });
  };

  return (
    <div>
      <input ref={inputRef} defaultValue="Hello, React!" />
      <button onClick={handleClick}>聚焦 & 選取</button>
    </div>
  );
}

影片播放器控制

import { useRef, useState } from 'react';

function VideoPlayer() {
  const videoRef = useRef(null);
  const [isPlaying, setIsPlaying] = useState(false);

  const togglePlay = () => {
    if (isPlaying) {
      videoRef.current.pause();
    } else {
      videoRef.current.play();
    }
    setIsPlaying(!isPlaying);
  };

  return (
    <div>
      <video
        ref={videoRef}
        src="https://www.w3schools.com/html/mov_bbb.mp4"
        width="400"
      />
      <div>
        <button onClick={togglePlay}>
          {isPlaying ? '⏸ 暫停' : '▶️ 播放'}
        </button>
        <button onClick={() => { videoRef.current.currentTime = 0; }}>
          ⏮ 回到開頭
        </button>
      </div>
    </div>
  );
}

測量 DOM 元素尺寸

import { useRef, useState, useEffect } from 'react';

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

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

  return (
    <div>
      <div
        ref={boxRef}
        style={{
          padding: '20px',
          backgroundColor: '#e2e8f0',
          borderRadius: '8px',
        }}
      >
        <p>這個元素的尺寸是:</p>
        <p>{dimensions.width} × {dimensions.height} px</p>
      </div>
    </div>
  );
}

11.3 用 useRef 保存可變值

保存前一個值

import { useState, useRef, useEffect } from 'react';

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

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

  return (
    <div>
      <p>目前值:{count}</p>
      <p>前一個值:{prevCountRef.current}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

追蹤渲染次數

import { useState, useRef, useEffect } from 'react';

function RenderCounter() {
  const [name, setName] = useState('');
  const renderCount = useRef(0);

  useEffect(() => {
    renderCount.current += 1;
  });

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>渲染次數:{renderCount.current}</p>
    </div>
  );
}

保存 Interval / Timeout ID

import { useState, useRef } from 'react';

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

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

  const stop = () => {
    clearInterval(intervalRef.current);
    setIsRunning(false);
  };

  const reset = () => {
    clearInterval(intervalRef.current);
    setIsRunning(false);
    setTime(0);
  };

  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.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
  };

  return (
    <div style={{ textAlign: 'center', padding: '20px' }}>
      <h1 style={{ fontSize: '4rem', fontFamily: 'monospace' }}>
        {formatTime(time)}
      </h1>
      <div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
        <button onClick={start} disabled={isRunning}>開始</button>
        <button onClick={stop} disabled={!isRunning}>暫停</button>
        <button onClick={reset}>重置</button>
      </div>
    </div>
  );
}

11.4 forwardRef — 將 ref 轉發給子元件

import { useRef, forwardRef } from 'react';

// 子元件用 forwardRef 接收 ref
const FancyInput = forwardRef(({ label, ...props }, ref) => {
  return (
    <div style={{ marginBottom: '12px' }}>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
        {label}
      </label>
      <input
        ref={ref}
        {...props}
        style={{
          padding: '10px 14px',
          border: '2px solid #e2e8f0',
          borderRadius: '8px',
          fontSize: '1rem',
          width: '100%',
          boxSizing: 'border-box',
        }}
      />
    </div>
  );
});

FancyInput.displayName = 'FancyInput';

// 父元件
function ParentComponent() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);

  const handleSubmit = () => {
    if (!nameRef.current.value) {
      nameRef.current.focus();
      alert('請輸入姓名');
      return;
    }
    if (!emailRef.current.value) {
      emailRef.current.focus();
      alert('請輸入 Email');
      return;
    }
    alert(`姓名:${nameRef.current.value},Email:${emailRef.current.value}`);
  };

  return (
    <div style={{ maxWidth: '400px', margin: '20px auto' }}>
      <FancyInput ref={nameRef} label="姓名" placeholder="請輸入姓名" />
      <FancyInput ref={emailRef} label="Email" type="email" placeholder="請輸入 Email" />
      <button onClick={handleSubmit}>送出</button>
    </div>
  );
}

11.5 useImperativeHandle — 自訂暴露給父元件的操作

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

const Counter = forwardRef((props, ref) => {
  const [count, setCount] = useState(0);

  useImperativeHandle(ref, () => ({
    increment: () => setCount((c) => c + 1),
    decrement: () => setCount((c) => c - 1),
    reset: () => setCount(0),
    getValue: () => count,
  }));

  return (
    <div style={{
      padding: '16px',
      border: '2px solid #e2e8f0',
      borderRadius: '8px',
      textAlign: 'center',
    }}>
      <h2>計數器:{count}</h2>
      <p style={{ color: '#999' }}>(由父元件控制)</p>
    </div>
  );
});

Counter.displayName = 'Counter';

function ParentController() {
  const counterRef = useRef(null);

  return (
    <div>
      <Counter ref={counterRef} />
      <div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
        <button onClick={() => counterRef.current.increment()}>+1</button>
        <button onClick={() => counterRef.current.decrement()}>-1</button>
        <button onClick={() => counterRef.current.reset()}>重置</button>
        <button onClick={() => alert(counterRef.current.getValue())}>取得值</button>
      </div>
    </div>
  );
}

11.6 練習題

練習 1:建立一個自動聚焦的搜尋框

頁面載入時自動聚焦,按 Escape 清空內容並重新聚焦。

練習 2:建立一個無限滾動列表

使用 ref 來觀察底部元素是否進入視窗(Intersection Observer),自動載入更多資料。


本課重點回顧

  1. useRef 建立不會觸發渲染的可變容器
  2. ref 屬性可以存取 DOM 元素
  3. 保存 timer ID、前一個值等跨渲染的可變資料
  4. forwardRef 讓子元件接收父元件的 ref
  5. useImperativeHandle 自訂暴露給父元件的方法