# 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. - Published: 2026-06-03 - Updated: 2026-06-03 - Author: SharpSkill - Tags: react, react-19, useEffectEvent, activity, hooks, interview - Reading time: 8 min --- 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à `` 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](https://react.dev/blog/2025/10/01/react-19-2) 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: ```tsx // hooks/useChatRoom.ts // 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. ```tsx // hooks/useChatRoom.ts // 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](https://react.dev/reference/react/useEffectEvent) 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](https://react.dev/reference/react/useEffectEvent) tự động. ```tsx // components/SearchTracker.tsx import { useEffect, useEffectEvent, useState } from 'react' interface SearchTrackerProps { query: string userId: string } export function SearchTracker({ query, userId }: SearchTrackerProps) { const [results, setResults] = useState([]) 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 } ``` > **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 `` 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), `` 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. ```tsx // components/TabLayout.tsx 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 (
{tabs.map((tab) => ( {tab.content} ))}
) } ``` 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. ## Chế độ Activity: Visible và Hidden bên trong Component `` 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. ```tsx // components/PrerenderedRoute.tsx import { Activity, Suspense, use } from 'react' interface PrerenderedRouteProps { isActive: boolean dataPromise: Promise } export function PrerenderedRoute({ isActive, dataPromise }: PrerenderedRouteProps) { return ( }> ) } function DashboardContent({ dataPromise }: { dataPromise: Promise }) { const data = use(dataPromise) return (
) } ``` `` ẩ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 `` liên quan đến [TanStack Query](https://tanstack.com/query/latest). Vì `useQuery` dựa vào `useEffect` bên trong, các truy vấn trong `` ẩn sẽ không thực thi — effect đã bị unmount. ```tsx // components/UserDashboard.tsx import { useQuery, useQueryClient } from '@tanstack/react-query' import { Activity } from 'react' function UserStats() { const { data } = useQuery({ queryKey: ['user-stats'], queryFn: fetchUserStats, }) return } function DashboardWithPrefetch({ showStats }: { showStats: boolean }) { const queryClient = useQueryClient() queryClient.ensureQueryData({ queryKey: ['user-stats'], queryFn: fetchUserStats, }) return ( ) } ``` 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 ``. 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. ```tsx // components/LiveDashboard.tsx import { useEffect, useEffectEvent, useState } from 'react' import { Activity } from 'react' function LiveFeed({ channel, userId }: { channel: string; userId: string }) { const [messages, setMessages] = useState([]) 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 } export function LiveDashboard({ userId }: { userId: string }) { const [activeChannel, setActiveChannel] = useState('general') const channels = ['general', 'alerts', 'metrics'] return (
{channels.map((ch) => ( ))}
) } ``` 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 ``). 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](/technologies/react-next/interview-questions/react-hooks) 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](https://react.dev/reference/react/useEffectEvent) 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 && }`) 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. `` 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 `` 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](/technologies/react-next/interview-questions/react-query-tanstack) 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 `` với ranh giới `` 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](https://react.dev/reference/react/useEffect) 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 - `` 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](/technologies/react-next/interview-questions/react-query-tanstack) 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 đủ --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/vi/blog/react-next/react-19-useeffectevent-activity-new-apis-interview-questions