Blog

/

Frontend

Next.js - useRef & useTransition

useRef useState ID useRef

Posted at

2024年10月28日

Posted on

Frontend

useRef 和 useTransition 兩個 Hook 解決了常見的性能和狀態管理問題。useRef 用來保存一個可變的引用,改變 ref 的值不會觸發重新渲染。useTransition 用於標記一個非同步狀態轉換,當你有一個需要等待的操作(比如搜尋、排序),可以用 useTransition 讓 UI 保持回應不會卡頓。

特性

useRef

useTransition

用途

保存可變引用

標記非同步轉換

觸發重新渲染

常見場景

DOM 操作、計時器、連接物件

搜尋、排序、伺服器操作

返回值

ref 物件(.current)

isPending 和 startTransition

性能影響

無(只是保存值)

優先級管理(不阻塞 UI)

場景

值改變不需要顯示更新

操作費時但需要 UI 回饋

useRef:

useRef 一個常見的用途是操作 DOM:

'use client'

import { useRef } from 'react'

export function TextInput() {
  const inputRef = useRef(null)

  const handleFocus = () => {
    inputRef.current.focus()
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleFocus}>Focus</button>
    </>
  )
}

useRef 真正的用處是跨越多次渲染保存一個值,同時改變它不會觸發重新渲染。

實例 1:POS 結帳倒計時

在 POS 系統中,如果說要幫結帳系統設計一個自動倒計時,30 秒後如果用戶沒有確認,則自動取消交易。這個計時器 ID 不需要顯示在 UI 上,改變它也不需要重新渲染。

'use client'

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

export function CheckoutTimer() {
  const [timeLeft, setTimeLeft] = useState(30)
  const timerIdRef = useRef(null)

  useEffect(() => {
    timerIdRef.current = setInterval(() => {
      setTimeLeft(prev => {
        if (prev <= 1) {
          clearInterval(timerIdRef.current)
          return 0
        }
        return prev - 1
      })
    }, 1000)

    return () => {
      clearInterval(timerIdRef.current)
    }
  }, [])

  return (
    <div>
      <p>自動完成交易: {timeLeft}</p>
      <button onClick={() => clearInterval(timerIdRef.current)}>
        取消倒計時
      </button>
    </div>
  )
}

這裡 timerIdRef 保存了計時器 ID,改變 timerIdRef.current 不會觸發重新渲染,因為只有 timeLeft 狀態改變才會重新渲染。組件卸載時,cleanup 函數通過 timerIdRef.current 來清理計時器。這個 case 如果不用 useRef,就必須把 timerIdRef 作為狀態,改變它每次都會重新渲染。

實例 2:記住上一次的值

在電商搜尋中,有時需要知道用戶上一次搜尋的關鍵詞(比如推薦相關商品),這個值沒有必要顯示。

'use client'

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

export function ProductSearch() {
  const [searchTerm, setSearchTerm] = useState('')
  const prevSearchTermRef = useRef('')

  useEffect(() => {
    prevSearchTermRef.current = searchTerm
  }, [searchTerm])

  const handleSearch = () => {
    console.log(`當前: ${searchTerm}, 上次: ${prevSearchTermRef.current}`)
    
    if (prevSearchTermRef.current && prevSearchTermRef.current !== searchTerm) {
      console.log('推薦與上次搜尋相關的商品')
    }
  }

  return (
    <div>
      <input
        value={searchTerm}
        onChange={e => setSearchTerm(e.target.value)}
        placeholder="搜尋商品..."
      />
      <button onClick={handleSearch}>搜尋</button>
    </div>
  )
}

prevSearchTermRef 在每次渲染後保存上一次的值,useEffect 在更新 DOM 後執行,所以當你使用 prevSearchTermRef.current 時,它總是前一次的搜尋詞。

實例 3:保存 WebSocket 連接

在 real time 通知系統中,WebSocket 連接物件需要在組件卸載時關閉。

'use client'

import { useRef, useEffect } from 'react'

export function RealtimeNotifications() {
  const wsRef = useRef(null)

  useEffect(() => {
    wsRef.current = new WebSocket('wss://api.example.com/notifications')

    wsRef.current.onmessage = (event) => {
      console.log('收到通知:', event.data)
    }

    wsRef.current.onerror = (error) => {
      console.error('連接錯誤:', error)
    }

    return () => {
      if (wsRef.current) {
        wsRef.current.close()
      }
    }
  }, [])

  return <div>實時通知已連接</div>
}

WebSocket 連接在組件掛載時建立,卸載時通過 wsRef.current 關閉。如果沒有 useRef,你無法在 cleanup 函數中存取這個連接物件。


useTransition:讓非同步操作不卡 UI

useTransition 的核心是讓 React 知道某個操作是低優先級的,React 會在這個操作進行時繼續回應用戶的高優先級操作(比如輸入框)。

import { useTransition, useState } from 'react'

const [isPending, startTransition] = useTransition()

isPending 是一個布林值,當轉換正在進行時為 true。startTransition 是一個函數,你在裡面放要延遲的代碼。

實例 1:電商搜尋

當用戶搜尋 1000 個商品並排序時,不應該讓輸入框卡頓,useTransition 可以確保輸入框回應。

'use client'

import { useTransition, useState } from 'react'

export function ProductList({ products }) {
  const [isPending, startTransition] = useTransition()
  const [sortBy, setSortBy] = useState('name')
  const [displayedProducts, setDisplayedProducts] = useState(products)

  const handleSort = (newSortBy) => {
    setSortBy(newSortBy)
    
    startTransition(() => {
      const sorted = products.sort((a, b) => {
        if (newSortBy === 'name') {
          return a.name.localeCompare(b.name)
        } else if (newSortBy === 'price') {
          return a.price - b.price
        }
        return 0
      })
      
      setDisplayedProducts([...sorted])
    })
  }

  return (
    <div>
      <div>
        <button onClick={() => handleSort('name')} disabled={isPending}>
          {isPending ? '排序中...' : '按名稱排序'}
        </button>
        <button onClick={() => handleSort('price')} disabled={isPending}>
          {isPending ? '排序中...' : '按價格排序'}
        </button>
      </div>
      
      {isPending && <p>正在排序 {products.length} 個商品...</p>}
      
      <ul>
        {displayedProducts.map(p => (
          <li key={p.id}>{p.name} - ${p.price}</li>
        ))}
      </ul>
    </div>
  )
}

關鍵是排序複雜計算被 startTransition 包裹。React 知道這個操作是低優先級的,如果用戶在排序時打字,輸入框會優先回應。

實例 2:POS 複雜訂單排序

在 POS 系統中,訂單列表可能有幾百條。按金額、日期、狀態排序時前端該怎麼互動呢?

'use client'

import { useTransition, useState } from 'react'

export function OrderList({ orders }) {
  const [isPending, startTransition] = useTransition()
  const [sortKey, setSortKey] = useState('date')
  const [sortedOrders, setSortedOrders] = useState(orders)

  const complexSort = (newSortKey) => {
    setSortKey(newSortKey)
    
    startTransition(() => {
      const sorted = orders.sort((a, b) => {
        if (newSortKey === 'amount') {
          return b.totalAmount - a.totalAmount
        } else if (newSortKey === 'date') {
          return new Date(b.date) - new Date(a.date)
        } else if (newSortKey === 'status') {
          const statusOrder = { pending: 1, completed: 2, cancelled: 3 }
          return statusOrder[a.status] - statusOrder[b.status]
        }
        return 0
      })
      
      setSortedOrders([...sorted])
    })
  }

  return (
    <div>
      <div>
        <button onClick={() => complexSort('amount')}>
          {isPending && sortKey === 'amount' ? '排序中...' : '按金額排序'}
        </button>
        <button onClick={() => complexSort('date')}>
          {isPending && sortKey === 'date' ? '排序中...' : '按日期排序'}
        </button>
      </div>
      
      {isPending && <p>正在處理 {orders.length} 個訂單...</p>}
      
      <table>
        <tbody>
          {sortedOrders.map(order => (
            <tr key={order.id}>
              <td>{order.id}</td>
              <td>${order.totalAmount}</td>
              <td>{order.date}</td>
              <td>{order.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

即使有幾百個訂單,排序時 UI 仍然回應迅速,按鈕不會卡。

常見錯誤

誤區 1:在 useRef 中放會影響 UI 的值

// 不好
const countRef = useRef(0)

const handleClick = () => {
  countRef.current += 1
  console.log(countRef.current)
}

// 頁面顯示 <p>Click count: {countRef.current}</p>
// 點擊按鈕,countRef.current 確實增加了
// 但頁面上的數字沒有變

如果你想讓 UI 更新應該要用 useState,useRef 只適合完全不影響 UI 的值。

誤區 2:在 startTransition 中忘記 await

// 不好
const handleFetch = () => {
  startTransition(() => {
    fetch('/api/data').then(r => {
      setData(r)
    })
  })
}

// isPending 會立即變成 false,因為 fetch() 返回的 Promise 沒有被等待

正確做法:

// 好
const handleFetch = async () => {
  startTransition(async () => {
    const r = await fetch('/api/data')
    const data = await r.json()
    setData(data)
  })
}

這樣 isPending 會在 fetch 完成後才變成 false。

誤區 3:useTransition 用於同步操作

// 不好
startTransition(() => {
  setSortBy('time')
  const filtered = simpleFilter(items)
  setItems(filtered)
})

// 這些操作如果都很快(幾毫秒),useTransition 沒有意義

useTransition 主要用於異步操作或複雜同步計算(超過 50ms)。簡單操作不需要。