React 19 useEffectEvent và Activity: API mới và câu hỏi phỏng vấn 2026

Phân tích chi tiết useEffectEvent và Activity trong React 19.2. Giải pháp stale closure, pre-rendering nền, ví dụ mã nguồn và câu hỏi phỏng vấn thường gặp.

React 19 useEffectEvent and Activity API

React 19.2 giới thiệu hai API giải quyết những vấn đề tồn tại từ lâu trong hệ sinh thái React: useEffectEvent loại bỏ hiện tượng stale closure trong effect, và <Activity> cho phép pre-rendering nền với khả năng bảo toàn trạng thái. Cả hai được phát hành trong tháng 10 năm 2025 và đang thay đổi căn bản cách các ứng dụng React xử lý side effect và điều hướng trong môi trường sản xuất.

Yêu cầu React 19.2 trở lên

useEffectEvent và Activity yêu cầu React 19.2 hoặc phiên bản mới hơn. Cập nhật bằng lệnh npm install react@latest react-dom@latest. Plugin ESLint eslint-plugin-react-hooks@6+ hỗ trợ tự động useEffectEvent trong mảng dependency.

useEffectEvent giải quyết vấn đề gì: Stale Closure

Mọi nhà phát triển React đều đã gặp phải stale closure. Effect ghi nhận giá trị tại thời điểm render, và khi giá trị đó thay đổi, effect vẫn tham chiếu đến giá trị cũ. Giải pháp trước đây sử dụng useRef để lưu tham chiếu có thể thay đổi — hoạt động được nhưng dài dòng và linter không nhận diện được.

Xét một ứng dụng chat ghi nhận analytics khi có tin nhắn mới. Log cần bao gồm theme hiện tại, nhưng việc thay đổi theme không nên làm kết nối lại phòng chat:

hooks/useChatRoom.tstsx
// Before useEffectEvent: useRef workaround
import { useEffect, useRef } from 'react'

export function useChatRoom(roomId: string, theme: string) {
  const themeRef = useRef(theme)
  themeRef.current = theme

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('message', (msg) => {
      logAnalytics('new_message', { roomId, theme: themeRef.current })
      showNotification(msg)
    })
    connection.connect()
    return () => connection.disconnect()
  }, [roomId])
}

Cách này hoạt động, nhưng linter cảnh báo theme là dependency bị thiếu. Việc tắt cảnh báo này có nguy cơ ẩn đi các lỗi tiềm ẩn ở những vị trí khác trong mã nguồn.

useEffectEvent: Tách biệt logic reactive và non-reactive

Hook useEffectEvent tạo một hàm ổn định luôn đọc giá trị mới nhất của props và state, mà không kích hoạt việc đồng bộ lại effect. Cách tiếp cận này thay thế pattern useRef bằng một API khai báo mà linter hiểu được.

hooks/useChatRoom.tstsx
// After useEffectEvent: clean separation of concerns
import { useEffect, useEffectEvent } from 'react'

export function useChatRoom(roomId: string, theme: string) {
  const onMessage = useEffectEvent((msg: string) => {
    logAnalytics('new_message', { roomId, theme })
    showNotification(msg)
  })

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('message', onMessage)
    connection.connect()
    return () => connection.disconnect()
  }, [roomId])
}

Effect chỉ chạy lại khi roomId thay đổi. Callback onMessage luôn nhìn thấy theme hiện tại mà không cần khai báo trong mảng dependency. Tài liệu chính thức của React chỉ rõ rằng Effect Event chỉ được gọi từ bên trong effect, không bao giờ trong quá trình render hoặc truyền cho component con.

Quy tắc và ràng buộc của useEffectEvent

Ba quy tắc chi phối việc sử dụng useEffectEvent:

  1. Gọi tại cấp cao nhất của component hoặc custom hook — không đặt trong vòng lặp hay điều kiện
  2. Chỉ gọi hàm trả về từ bên trong useEffect hoặc một Effect Event khác
  3. Không truyền làm prop hoặc trả về từ hook để sử dụng bên ngoài

Vi phạm quy tắc thứ 2 gây ra lỗi khó phát hiện: danh tính hàm thay đổi mỗi lần render, nên việc lưu trong ref hoặc truyền xuống làm mất ý nghĩa của API. Plugin eslint-plugin-react-hooks@6+ thực thi các ràng buộc này tự động.

components/SearchTracker.tsxtsx
import { useEffect, useEffectEvent, useState } from 'react'

interface SearchTrackerProps {
  query: string
  userId: string
}

export function SearchTracker({ query, userId }: SearchTrackerProps) {
  const [results, setResults] = useState<string[]>([])

  const onSearchComplete = useEffectEvent((resultCount: number) => {
    analytics.track('search_complete', {
      query,
      userId,
      resultCount,
      timestamp: Date.now(),
    })
  })

  useEffect(() => {
    const controller = new AbortController()
    fetchSearchResults(query, controller.signal).then((data) => {
      setResults(data)
      onSearchComplete(data.length)
    })
    return () => controller.abort()
  }, [query])

  return <ResultsList results={results} />
}
Khi nào không nên dùng useEffectEvent

useEffectEvent không phải là cách để tắt cảnh báo dependency của linter. Nếu một giá trị thực sự quyết định thời điểm effect cần chạy lại, giá trị đó phải nằm trong mảng dependency. Chỉ tách logic vào Effect Event khi nó là hành động phụ (logging, thông báo, analytics) đọc giá trị reactive mà không cần kích hoạt lại effect.

Component Activity: Pre-rendering nền với bảo toàn trạng thái

Component <Activity> kiểm soát việc children được hiển thị hay ẩn đi. Khác với render có điều kiện (huỷ trạng thái), hay CSS display: none (giữ effect chạy), <Activity> bảo toàn trạng thái trong khi dọn dẹp effect và hoãn các cập nhật xuống mức ưu tiên thấp.

components/TabLayout.tsxtsx
import { useState } from 'react'
import { Activity } from 'react'

interface Tab {
  id: string
  label: string
  content: React.ReactNode
}

export function TabLayout({ tabs }: { tabs: Tab[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id)

  return (
    <div>
      <nav className="flex gap-2 border-b border-border">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            className={activeTab === tab.id ? 'border-b-2 border-primary' : ''}
          >
            {tab.label}
          </button>
        ))}
      </nav>
      {tabs.map((tab) => (
        <Activity key={tab.id} mode={activeTab === tab.id ? 'visible' : 'hidden'}>
          {tab.content}
        </Activity>
      ))}
    </div>
  )
}

Người dùng đang điền form trong tab "Profile" có thể chuyển sang tab "Settings" rồi quay lại mà không mất dữ liệu đã nhập. Effect của tab bị ẩn (timer, subscription, data fetching) được dọn dẹp, giải phóng tài nguyên. Khi tab hiển thị trở lại, effect được mount lại và trạng thái được phục hồi ngay lập tức.

Sẵn sàng chinh phục phỏng vấn React / Next.js?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Chế độ Activity: Visible và Hidden bên trong

Component <Activity> nhận prop mode với hai giá trị:

| Hành vi | mode="visible" | mode="hidden" | |----------|-------------------|------------------| | Render DOM | Bình thường | display: none qua CSS | | Trạng thái component | Hoạt động | Bảo toàn trong bộ nhớ | | Effect (useEffect) | Đã mount | Được dọn dẹp | | Mức ưu tiên cập nhật | Bình thường | Hoãn đến lúc rảnh | | Pre-rendering | Không áp dụng | Render ở mức ưu tiên thấp |

Khi component bắt đầu ở trạng thái ẩn (render lần đầu với mode="hidden"), React pre-render nó ở mức ưu tiên thấp mà không mount effect. Điều này cho phép điều hướng tức thì: trang đích đã được render sẵn trong nền khi người dùng nhấn vào.

components/PrerenderedRoute.tsxtsx
import { Activity, Suspense, use } from 'react'

interface PrerenderedRouteProps {
  isActive: boolean
  dataPromise: Promise<DashboardData>
}

export function PrerenderedRoute({ isActive, dataPromise }: PrerenderedRouteProps) {
  return (
    <Activity mode={isActive ? 'visible' : 'hidden'}>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent dataPromise={dataPromise} />
      </Suspense>
    </Activity>
  )
}

function DashboardContent({ dataPromise }: { dataPromise: Promise<DashboardData> }) {
  const data = use(dataPromise)
  return (
    <div className="grid grid-cols-3 gap-4">
      <MetricsCard data={data.metrics} />
      <ChartPanel data={data.charts} />
      <RecentActivity items={data.activity} />
    </div>
  )
}

<Activity> ẩn sẽ pre-render dashboard ở mức ưu tiên idle. Kết hợp với Suspense và API use, việc tải dữ liệu diễn ra trong nền. Khi isActive chuyển thành true, nội dung hiển thị ngay lập tức mà không có loading spinner.

Activity và TanStack Query: Lưu ý về cache

Một lỗi thường gặp khi sử dụng <Activity> liên quan đến TanStack Query. Vì useQuery dựa vào useEffect bên trong, các truy vấn trong <Activity> ẩn sẽ không thực thi — effect đã bị unmount.

components/UserDashboard.tsxtsx
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity } from 'react'

function UserStats() {
  const { data } = useQuery({
    queryKey: ['user-stats'],
    queryFn: fetchUserStats,
  })
  return <StatsDisplay data={data} />
}

function DashboardWithPrefetch({ showStats }: { showStats: boolean }) {
  const queryClient = useQueryClient()

  queryClient.ensureQueryData({
    queryKey: ['user-stats'],
    queryFn: fetchUserStats,
  })

  return (
    <Activity mode={showStats ? 'visible' : 'hidden'}>
      <UserStats />
    </Activity>
  )
}

Giải pháp đơn giản: chuyển việc prefetch dữ liệu lên component cha hoặc sử dụng queryClient.ensureQueryData bên ngoài ranh giới <Activity>. Dữ liệu đã cache sẽ sẵn sàng khi component ẩn trở thành hiển thị.

Đánh đổi bộ nhớ

Activity đánh đổi bộ nhớ để lấy tốc độ. Mỗi cây component ẩn vẫn lưu trong bộ nhớ với toàn bộ DOM. Đối với ứng dụng có nhiều route ẩn, cần theo dõi mức sử dụng bộ nhớ. Đội ngũ React đang nghiên cứu cơ chế tự động loại bỏ các Activity ẩn ít sử dụng nhất trong các phiên bản tương lai.

Kết hợp useEffectEvent và Activity

Cả hai API bổ sung cho nhau trong các pattern điều hướng thực tế. Một tình huống phổ biến: dashboard có nhiều tab, mỗi tab có kết nối WebSocket và theo dõi analytics.

components/LiveDashboard.tsxtsx
import { useEffect, useEffectEvent, useState } from 'react'
import { Activity } from 'react'

function LiveFeed({ channel, userId }: { channel: string; userId: string }) {
  const [messages, setMessages] = useState<Message[]>([])

  const onNewMessage = useEffectEvent((msg: Message) => {
    analytics.track('live_message', { channel, userId })
    setMessages((prev) => [...prev, msg])
  })

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/${channel}`)
    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data) as Message
      onNewMessage(msg)
    }
    return () => ws.close()
  }, [channel])

  return <MessageList messages={messages} />
}

export function LiveDashboard({ userId }: { userId: string }) {
  const [activeChannel, setActiveChannel] = useState('general')
  const channels = ['general', 'alerts', 'metrics']

  return (
    <div>
      <nav className="flex gap-2">
        {channels.map((ch) => (
          <button key={ch} onClick={() => setActiveChannel(ch)}>
            {ch}
          </button>
        ))}
      </nav>
      {channels.map((ch) => (
        <Activity key={ch} mode={activeChannel === ch ? 'visible' : 'hidden'}>
          <LiveFeed channel={ch} userId={userId} />
        </Activity>
      ))}
    </div>
  )
}

Khi chuyển kênh, WebSocket của feed bị ẩn sẽ ngắt kết nối (dọn dẹp effect bởi <Activity>). Lịch sử tin nhắn vẫn tồn tại trong state. Effect Event onNewMessage đảm bảo analytics luôn tham chiếu đến userId hiện tại mà không buộc WebSocket phải kết nối lại.

Câu hỏi phỏng vấn: useEffectEvent và Activity

Các câu hỏi này kiểm tra sự hiểu biết về hai API mới và tương tác của chúng với mô hình render của React. Chúng ngày càng xuất hiện thường xuyên trong các buổi phỏng vấn React tại các công ty sử dụng React 19.2.

Câu 1: useEffectEvent giải quyết vấn đề gì mà useCallback không thể?

useCallback tạo hàm đã được memo hoá, nhưng hàm đó vẫn phải nằm trong mảng dependency của effect. Khi bất kỳ dependency nào của useCallback thay đổi, callback thay đổi theo, kích hoạt lại effect. useEffectEvent tạo hàm luôn đọc giá trị mới nhất mà không là dependency — effect không bao giờ chạy lại vì nó. Sự tách biệt này không thể đạt được chỉ với useCallback.

Câu 2: Có thể truyền Effect Event làm prop cho component con không?

Không. Effect Event được thiết kế để chỉ gọi từ bên trong useEffect hoặc các Effect Event khác. Danh tính của chúng thay đổi mỗi lần render, nên việc truyền làm prop gây ra re-render không cần thiết và phá vỡ mô hình thiết kế. Plugin ESLint thực thi quy tắc này tự động.

Câu 3: Activity khác gì với render có điều kiện và CSS display:none?

Render có điều kiện ({show && <Component />}) unmount hoàn toàn component — trạng thái bị huỷ. CSS display: none ẩn giao diện nhưng giữ tất cả effect chạy, lãng phí tài nguyên. <Activity mode="hidden"> bảo toàn trạng thái, dọn dẹp effect, hoãn cập nhật xuống mức ưu tiên idle và có thể pre-render nội dung trong nền.

Câu 4: Điều gì xảy ra với useEffect bên trong Activity ẩn?

Khi <Activity> chuyển sang mode="hidden", React chạy tất cả các hàm dọn dẹp effect (giá trị trả về của useEffect). Không có effect mới nào được mount khi ẩn. Khi component hiển thị trở lại, effect được mount lại với trạng thái đã bảo toàn. Đây là lý do tại sao các thư viện tải dữ liệu dựa vào useEffect cần chiến lược prefetch bên ngoài ranh giới Activity.

Câu 5: Làm thế nào để pre-render một route với Activity và Suspense?

Bọc route trong <Activity mode="hidden"> với ranh giới <Suspense> bên trong. Sử dụng API use() hoặc nguồn dữ liệu hỗ trợ Suspense để tải dữ liệu. React render cây ẩn ở mức ưu tiên thấp, giải quyết Suspense boundary trong nền. Khi người dùng điều hướng và mode chuyển sang "visible", nội dung đã render hoàn chỉnh hiển thị ngay lập tức mà không có trạng thái loading.

Câu 6: useEffectEvent có thay thế quy tắc exhaustive-deps không?

Không. Quy tắc exhaustive-deps vẫn rất quan trọng để phát hiện dependency bị thiếu thực sự. useEffectEvent xử lý một trường hợp cụ thể: logic đọc giá trị reactive nhưng không nên kiểm soát thời điểm effect chạy lại (analytics, thông báo, logging). Sử dụng nó để tắt mọi cảnh báo dependency sẽ ẩn đi lỗi và làm mất mục đích của API.

Kết luận

  • useEffectEvent thay thế pattern useRef cho stale closure trong effect, với hỗ trợ linter tự động trong eslint-plugin-react-hooks@6+
  • Effect Event luôn đọc giá trị props và state mới nhất mà không kích hoạt đồng bộ lại effect — sử dụng cho callback analytics, logging và thông báo
  • <Activity> bảo toàn trạng thái component trong khi dọn dẹp effect, là giải pháp trung gian giữa render có điều kiện và CSS ẩn
  • Activity ẩn pre-render ở mức ưu tiên idle, cho phép điều hướng tức thì khi kết hợp với Suspense và API use
  • TanStack Query và các thư viện dựa trên effect cần prefetch bên ngoài ranh giới Activity vì useEffect không chạy trong chế độ ẩn
  • Cả hai API có trong React 19.2 — cập nhật ESLint và React đồng thời để có hỗ trợ công cụ đầy đủ

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

#react
#react-19
#useEffectEvent
#activity
#hooks
#interview

Chia sẻ

Bài viết liên quan