BlogBlog · react

React 19 thực chiến: Actions, useOptimistic, use() và Suspense — viết form và data loading theo cách mới

Từ async transition đến useActionState, useFormStatus, useOptimistic có rollback, use() với promise và cách đặt Suspense boundary, kèm những gì React 19.2/19.3 thêm và checklist migrate API cũ.

Part of Tech Radar series
June 14, 2026
25 min read
By Leo Pham
reactreact-19suspenseformsfrontend
Share this post:

Form "Cập nhật hồ sơ" trong một dự án React 18 điển hình trông thế này: isLoading, error, successMessage là ba useState, một handleSubmit với try/catch/finally, e.preventDefault(), tự đọc từng field, tự disable nút để chống double submit. Nhân với 30 form trong admin panel là 30 bản copy của cùng một đống boilerplate, mỗi bản lệch nhau một chút.

React 19 đưa phần boilerplate đó vào thư viện với khái niệm Action: một hàm (sync hoặc async) chạy trong transition, mà React tự quản lý pending state, lỗi, thứ tự và cập nhật lạc quan. Đi kèm là use() để đọc promise ngay trong render, cùng cách làm việc với Suspense đã chín hơn nhiều so với thời React 18.

Bài này dành cho bạn đang chạy React 18 hoặc đã lên 19 nhưng vẫn viết form theo kiểu cũ. Mình đi qua từng API với code chạy được, chỉ ra chỗ nó gãy, tóm tắt những gì 19.2 và 19.3 thêm vào, rồi chốt bằng checklist migrate. Các pattern hook chung (effect, custom hook, useSyncExternalStore) nằm ở bài React hooks nâng cao.

TL;DR:

  • startTransition nhận async function từ React 19. Mọi update trong đó thuộc cùng transition, nhưng update sau await phải bọc thêm một startTransition nữa.
  • <form action={fn}> + useActionState + useFormStatus thay cho onSubmit + ba useState. Lỗi nghiệp vụ hãy trả về dưới dạng state, chỉ ném lỗi cho sự cố bất ngờ.
  • useOptimistic tự quay về giá trị thật khi Action kết thúc. Thành công thì bạn phải cập nhật state thật, nếu không item sẽ biến mất.
  • use(promise) cần promise được tạo ngoài render hoặc đã cache. Đặt Suspense boundary theo đơn vị UI có nghĩa và luôn kèm Error Boundary.
  • 19.2 có <Activity> và useEffectEvent. 19.3 (9/2026) đưa <ViewTransition> và Fragment refs lên stable.

React 19.x đã thay đổi gì: mốc phiên bản

Phiên bản Ngày phát hành Điểm chính
19.0 5/12/2024 Actions, useActionState, useOptimistic, useFormStatus, use(), ref là prop, <Context> làm provider, metadata trong component, API preload tài nguyên, Server Components/Functions stable
19.1 28/3/2025 Owner Stack hỗ trợ debug, sửa lỗi Suspense và hydration
19.2 1/10/2025 <Activity>, useEffectEvent, cacheSignal, Performance Tracks trong Chrome DevTools, API resume cho partial pre-rendering, gom nhóm reveal Suspense khi SSR
19.3 9/9/2026 <ViewTransition> và addTransitionType stable, Fragment refs, browser() trong react-dom, render <Context> trực tiếp từ Server Component, hỗ trợ Trusted Types, các transition không còn bị gộp với nhau

Ngoài các bản minor, ngày 3/12/2025 React công bố lỗ hổng RCE trong giao thức React Server Components (CVE-2025-55182) và phát hành bản vá 19.0.1, 19.1.2, 19.2.1, sau đó tiếp tục có các bản vá cho những CVE liên quan. Nếu dùng framework có RSC (Next.js App Router, React Router RSC, Waku...), luôn giữ react-server-dom-* và framework ở bản patch mới nhất. Bản stable mới nhất trên npm lúc mình cập nhật bài là 19.3.1.

Transitions: từ React 18 đến async transition

React 18 đưa ra startTransition để đánh dấu update "không gấp": React render nó ở nền, có thể bỏ dở nếu có update gấp hơn (gõ phím, click), và không thay UI đang hiển thị bằng Suspense fallback. Nhưng ở React 18, hàm truyền vào phải là sync. Muốn có pending state cho một request, bạn vẫn phải tự setIsLoading.

Từ React 19, transition nhận async function, và isPending đúng suốt thời gian hàm chạy:

import { useState, useTransition } from 'react'

async function updateCartLine(lineId: string, quantity: number): Promise<{ quantity: number }> {
  const res = await fetch(`/api/cart/lines/${lineId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ quantity }),
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return (await res.json()) as { quantity: number }
}

export function QuantityStepper({ lineId, initialQuantity }: { lineId: string; initialQuantity: number }) {
  const [quantity, setQuantity] = useState(initialQuantity)
  const [error, setError] = useState<string | null>(null)
  const [isPending, startTransition] = useTransition()

  function change(next: number) {
    startTransition(async () => {
      const result = await updateCartLine(lineId, next).then(
        (saved) => ({ ok: true as const, saved }),
        (e: unknown) => ({ ok: false as const, message: e instanceof Error ? e.message : 'Lỗi không xác định' }),
      )
      // Update sau await PHẢI bọc lại trong startTransition để thuộc transition này
      startTransition(() => {
        if (result.ok) {
          setQuantity(result.saved.quantity)
          setError(null)
        } else {
          setError(`Không cập nhật được số lượng: ${result.message}`)
        }
      })
    })
  }

  return (
    <div aria-busy={isPending}>
      <button onClick={() => change(quantity - 1)} disabled={isPending || quantity <= 1}>−</button>
      <span>{quantity}</span>
      <button onClick={() => change(quantity + 1)} disabled={isPending}>+</button>
      {error && <p role="alert">{error}</p>}
    </div>
  )
}

Hàm truyền vào startTransition lúc này được React gọi là một Action. Chỗ hay bị quên là dòng chú thích: JavaScript chưa có cách giữ "async context" qua await, nên React không biết các setState sau await thuộc transition nào. Đây là giới hạn hiện tại được ghi rõ trong docs, không phải bug của bạn.

Một điểm nữa: nhiều transition độc lập không đảm bảo thứ tự hoàn thành. Bấm "+" ba lần nhanh, ba request có thể về lệch thứ tự. Khi thứ tự quan trọng, dùng useActionState (xếp hàng tuần tự, xem phần sau) hoặc gửi giá trị tuyệt đối kèm version lên server. React 19.3 cũng thay đổi cách render: các transition không liên quan không còn bị gộp chung, nên một transition chậm không giữ chân những transition khác.

Actions và form: form action, useActionState, useFormStatus

Đây là form hồ sơ ở đầu bài, viết lại theo React 19:

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'

type ProfileValues = { displayName: string; phone: string }
type FieldErrors = Partial<Record<keyof ProfileValues, string>>
type ProfileState =
  | { status: 'idle' }
  | { status: 'success'; message: string; values: ProfileValues }
  | { status: 'error'; message: string; fieldErrors: FieldErrors; values: ProfileValues }

async function saveProfile(_prev: ProfileState, formData: FormData): Promise<ProfileState> {
  const values: ProfileValues = {
    displayName: String(formData.get('displayName') ?? '').trim(),
    phone: String(formData.get('phone') ?? '').trim(),
  }
  const fieldErrors: FieldErrors = {}
  if (values.displayName.length < 2) fieldErrors.displayName = 'Tên cần ít nhất 2 ký tự'
  if (!/^0\d{9}$/.test(values.phone)) fieldErrors.phone = 'Số điện thoại gồm 10 chữ số, bắt đầu bằng 0'
  if (Object.keys(fieldErrors).length > 0) {
    return { status: 'error', message: 'Kiểm tra lại thông tin', fieldErrors, values }
  }

  const res = await fetch('/api/profile', {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(values),
  })
  if (!res.ok) return { status: 'error', message: `Lưu thất bại (HTTP ${res.status})`, fieldErrors: {}, values }
  return { status: 'success', message: 'Đã lưu hồ sơ', values }
}

function SubmitButton() {
  // Đọc trạng thái của <form> CHA: phải là component con nằm trong form
  const { pending } = useFormStatus()
  return <button type="submit" disabled={pending}>{pending ? 'Đang lưu…' : 'Lưu hồ sơ'}</button>
}

export function ProfileForm({ user }: { user: ProfileValues }) {
  const [state, formAction, isPending] = useActionState(saveProfile, { status: 'idle' })
  const values = state.status === 'idle' ? user : state.values
  const fieldErrors: FieldErrors = state.status === 'error' ? state.fieldErrors : {}

  return (
    <form action={formAction} aria-busy={isPending}>
      <label>
        Tên hiển thị
        <input name="displayName" defaultValue={values.displayName} aria-invalid={Boolean(fieldErrors.displayName)} />
      </label>
      {fieldErrors.displayName && <p role="alert">{fieldErrors.displayName}</p>}

      <label>
        Số điện thoại
        <input name="phone" inputMode="tel" defaultValue={values.phone} aria-invalid={Boolean(fieldErrors.phone)} />
      </label>
      {fieldErrors.phone && <p role="alert">{fieldErrors.phone}</p>}

      <SubmitButton />
      {state.status !== 'idle' && <p aria-live="polite">{state.message}</p>}
    </form>
  )
}

Những gì React làm thay bạn:

  • Không cần preventDefault: truyền function vào action, React chặn submit mặc định và gọi hàm với FormData.
  • Pending state: isPending từ useActionState, hoặc pending từ useFormStatus cho component con như nút submit dùng chung trong design system. useFormStatus còn trả về data, method, action của lần submit đang chạy.
  • Xếp hàng: nhiều lần gọi formAction được thực thi tuần tự, mỗi lần nhận state của lần trước. Double click không tạo hai request song song ghi đè nhau.
  • Reset form: khi Action của <form action> hoàn tất, React tự reset các input uncontrolled. Đây là lý do mình trả values trong cả state lỗi lẫn state thành công rồi gắn vào defaultValue. Nếu không, người dùng gõ sai số điện thoại sẽ thấy cả form bị xóa trắng. Cần reset thủ công thì có requestFormReset(form) trong react-dom.

Về lỗi, docs nói rõ: nếu hàm của useActionState ném lỗi, React hủy mọi Action đang xếp hàng và hiện Error Boundary gần nhất. Vì vậy lỗi nghiệp vụ (validate, 409, 422) phải được trả về dưới dạng state như trên. Chỉ để lỗi bất ngờ (bug, mất mạng hoàn toàn) bay lên Error Boundary.

Cùng component này chạy được với Server Function: thay saveProfile bằng một hàm 'use server' import từ file riêng. Khi đó form submit được cả trước khi JavaScript hydrate (progressive enhancement), initialState và giá trị trả về phải serialize được, và tham số thứ ba permalink của useActionState cho biết trang đích nếu form submit trước khi JS tải xong. Cách viết Server Action an toàn (validate, authorization, revalidate cache) mình viết trong bài Next.js App Router và React Server Components.

useOptimistic: cập nhật lạc quan và tự rollback

Bình luận trên trang chi tiết đơn hàng: người dùng bấm "Gửi" thì bình luận hiện ngay, mờ đi cho tới khi server xác nhận.

import { startTransition, useOptimistic, useState } from 'react'

type OrderComment = { id: string; author: string; body: string; pending?: boolean }

async function postComment(orderId: string, body: string): Promise<OrderComment> {
  const res = await fetch(`/api/orders/${orderId}/comments`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ body }),
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return (await res.json()) as OrderComment
}

export function OrderComments({ orderId, initialComments }: { orderId: string; initialComments: OrderComment[] }) {
  const [comments, setComments] = useState(initialComments)
  const [failure, setFailure] = useState<{ message: string; draft: string } | null>(null)
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (current: OrderComment[], draft: OrderComment) => [...current, draft],
  )

  async function submitAction(formData: FormData) {
    const body = String(formData.get('body') ?? '').trim()
    if (!body) return
    setFailure(null)
    addOptimisticComment({ id: `tmp-${crypto.randomUUID()}`, author: 'Bạn', body, pending: true })
    try {
      const saved = await postComment(orderId, body)
      startTransition(() => setComments((prev) => [...prev, saved])) // cập nhật nguồn sự thật
    } catch {
      // Không đụng tới comments: khi Action kết thúc, bản lạc quan tự biến mất
      setFailure({ message: 'Không gửi được bình luận, thử lại nhé', draft: body })
    }
  }

  return (
    <section>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            <strong>{c.author}</strong>: {c.body}
          </li>
        ))}
      </ul>
      {failure && <p role="alert">{failure.message}</p>}
      <form action={submitAction}>
        <textarea name="body" required defaultValue={failure?.draft ?? ''} />
        <button type="submit">Gửi</button>
      </form>
    </section>
  )
}

Cơ chế rollback: optimisticComments chỉ khác comments trong lúc có Action đang chạy. Khi Action kết thúc, dù thành công hay ném lỗi, React render lại với giá trị thật comments. Thành công thì mình đã thêm saved vào comments trong cùng transition, nên UI chuyển từ bản mờ sang bản thật mà không nháy. Thất bại thì comments không đổi, bản lạc quan biến mất. Đó là "rollback tự động", bạn không phải viết code hoàn tác.

Ba chỗ hay sai:

  • Gọi addOptimisticComment ngoài Action hoặc transition: React cảnh báo "An optimistic state update occurred outside a Transition or Action" và bản lạc quan chỉ chớp lên rồi mất. Trong hàm truyền vào action thì đã là Action, gọi thẳng được.
  • Quên cập nhật nguồn sự thật khi thành công. Với Next.js, đó là revalidatePath/updateTag trong Server Action. Với SPA, đó là setComments như trên hoặc invalidate query.
  • Reducer của useOptimistic phải là hàm thuần. Reducer là tham số tùy chọn: không truyền thì setter nhận thẳng giá trị mới (setOptimisticLiked(true)).

Nếu dự án đã dùng TanStack Query, optimistic update qua onMutate/onError vẫn ổn và có cache chung giữa các màn hình. useOptimistic hợp nhất khi state nằm ngay trong component hoặc đến từ Server Component.

use(): đọc promise và context trong render

use không phải hook theo nghĩa chặt, nên gọi được trong if và vòng lặp. Với promise, component "suspend" cho tới khi promise resolve, và Suspense boundary gần nhất hiện fallback. Cách dùng đúng là tạo promise ở nơi khác rồi truyền xuống:

// app/products/[id]/page.tsx: Server Component
import { Suspense } from 'react'
import { getProduct, getReviews } from '@/lib/catalog'
import { Reviews } from './reviews'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const reviewsPromise = getReviews(id) // KHÔNG await: bắt đầu tải ngay, song song với getProduct
  const product = await getProduct(id)

  return (
    <article>
      <h1>{product.name}</h1>
      <Suspense fallback={<p>Đang tải đánh giá…</p>}>
        <Reviews reviewsPromise={reviewsPromise} />
      </Suspense>
    </article>
  )
}
// app/products/[id]/reviews.tsx: Client Component
'use client'
import { use } from 'react'

type Review = { id: string; rating: number; content: string }

export function Reviews({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
  const reviews = use(reviewsPromise) // suspend tới khi promise resolve
  if (reviews.length === 0) return <p>Chưa có đánh giá.</p>
  return (
    <ul>
      {reviews.map((r) => (
        <li key={r.id}>{'★'.repeat(r.rating)} {r.content}</li>
      ))}
    </ul>
  )
}

Trong SPA không có Server Component, promise phải được cache để mỗi lần render nhận lại cùng một instance:

const reviewCache = new Map<string, Promise<Review[]>>()

export function fetchReviews(productId: string): Promise<Review[]> {
  let promise = reviewCache.get(productId)
  if (!promise) {
    promise = fetch(`/api/products/${productId}/reviews`).then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json() as Promise<Review[]>
    })
    promise.catch(() => reviewCache.delete(productId)) // đừng cache vĩnh viễn một promise lỗi
    reviewCache.set(productId, promise)
  }
  return promise
}

// trong component: const reviews = use(fetchReviews(productId))

Viết use(fetch(...)) thẳng trong component, mỗi lần render tạo một promise mới, component suspend mãi và React cảnh báo "A component was suspended by an uncached promise". Cache tự viết như trên chưa có invalidation hay giới hạn bộ nhớ. Với app thật, dùng useSuspenseQuery của TanStack Query hoặc data layer của framework.

Hai luật còn lại: use không được đặt trong try/catch, vì promise reject sẽ đi thẳng lên Error Boundary gần nhất. Còn use(Context) thay được useContext và gọi được có điều kiện:

import { createContext, use } from 'react'

const CurrencyContext = createContext({ currency: 'VND', locale: 'vi-VN' })

export function PriceTag({ amount, raw }: { amount: number; raw?: boolean }) {
  if (raw) return <span>{amount}</span>
  const { currency, locale } = use(CurrencyContext) // gọi sau early return vẫn hợp lệ
  return <span>{new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)}</span>
}

Đặt Suspense boundary: tránh waterfall, kết hợp Error Boundary

Suspense boundary là quyết định UX: mỗi boundary là một vùng "hiện cùng lúc hoặc chờ cùng lúc". Dashboard đơn hàng có ba widget độc lập thì nên có ba boundary, mỗi cái kèm Error Boundary riêng để một API lỗi không kéo sập cả trang:

// app/dashboard/page.tsx: Server Component, render một lần cho mỗi request
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { getRevenue, getTopProducts, getPendingOrders } from '@/lib/dashboard'
import { RevenueChart, TopProducts, PendingOrders, WidgetSkeleton, WidgetError } from './widgets'

export default function DashboardPage() {
  // Khởi động cả ba request CÙNG LÚC, trước khi bất kỳ component nào suspend
  const revenuePromise = getRevenue({ range: '30d' })
  const topProductsPromise = getTopProducts({ limit: 10 })
  const pendingPromise = getPendingOrders()

  return (
    <div className="grid">
      <ErrorBoundary FallbackComponent={WidgetError}>
        <Suspense fallback={<WidgetSkeleton title="Doanh thu 30 ngày" />}>
          <RevenueChart dataPromise={revenuePromise} />
        </Suspense>
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={WidgetError}>
        <Suspense fallback={<WidgetSkeleton title="Sản phẩm bán chạy" />}>
          <TopProducts dataPromise={topProductsPromise} />
        </Suspense>
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={WidgetError}>
        <Suspense fallback={<WidgetSkeleton title="Đơn chờ xử lý" />}>
          <PendingOrders dataPromise={pendingPromise} />
        </Suspense>
      </ErrorBoundary>
    </div>
  )
}

Waterfall xảy ra khi request thứ hai chỉ bắt đầu sau khi request thứ nhất xong: component cha fetch trong render rồi suspend, component con chứa fetch thứ hai chưa từng được render. Cách chữa là khởi động mọi request độc lập ở cùng một chỗ, như route loader, Server Component, event handler, hoặc như trên, rồi truyền promise xuống. Ví dụ trên an toàn vì Server Component chỉ render một lần mỗi request. Trong SPA, đừng tạo promise thẳng trong một client component vì mỗi lần nó re-render sẽ tạo promise mới. Hãy khởi động request trong loader của router hoặc qua cache như fetchReviews ở phần trước. React 19 khi gặp component suspend sẽ commit fallback của boundary gần nhất ngay, sau đó mới "pre-warm" các sibling. Fallback hiện nhanh hơn, nhưng fetch nằm trong render của sibling vẫn bắt đầu muộn. Đó thêm một lý do để không fetch trong render.

Vài quy tắc mình áp dụng khi chọn vị trí boundary:

  • Không bọc từng component nhỏ. Mười skeleton nhảy lần lượt còn tệ hơn một skeleton cho cả khu vực. Gom những phần luôn hiện cùng nhau vào một boundary.
  • Điều hướng nên là transition. Update nằm trong transition không thay nội dung đã hiển thị bằng fallback, React giữ UI cũ cho tới khi UI mới sẵn sàng. Router của Next.js và React Router đã làm việc này.
  • Error Boundary vẫn phải là class component (hoặc dùng thư viện react-error-boundary). Đặt nó ngoài Suspense để bắt cả promise reject từ use().
  • Khi SSR, từ 19.2 React gom các boundary reveal gần nhau thành một đợt thay vì thay từng fallback ngay khi có dữ liệu. React tự ngừng gom nếu thời gian tải trang tiến gần ngưỡng LCP "tốt" 2,5 giây.

Những thay đổi khác của React 19 bạn sẽ dùng hằng ngày

import { createContext, useCallback, type ComponentPropsWithRef } from 'react'
import { preconnect, preload } from 'react-dom'

// 1. ref là prop bình thường của function component, không cần forwardRef
type TextFieldProps = ComponentPropsWithRef<'input'> & { label: string }
export function TextField({ label, ref, ...inputProps }: TextFieldProps) {
  return (
    <label>
      {label}
      <input ref={ref} {...inputProps} />
    </label>
  )
}

// 2. <Context> dùng trực tiếp làm provider
export const ThemeContext = createContext<'light' | 'dark'>('light')
export function AppShell({ children }: { children: React.ReactNode }) {
  return <ThemeContext value="dark">{children}</ThemeContext>
}

// 3. Metadata và stylesheet khai báo ngay trong component, React đưa lên <head>
export function ProductHead({ name, summary }: { name: string; summary: string }) {
  preconnect('https://cdn.shop.example')
  preload('/fonts/Inter-Variable.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })
  return (
    <>
      <title>{`${name} | Cửa hàng`}</title>
      <meta name="description" content={summary} />
      <link rel="stylesheet" href="/styles/product.css" precedence="default" />
    </>
  )
}

// 4. Ref callback trả về cleanup
export function AutoFocusSearch() {
  const ref = useCallback((input: HTMLInputElement | null) => {
    if (!input) return
    const timer = setTimeout(() => input.focus(), 0)
    return () => clearTimeout(timer)
  }, [])
  return <input ref={ref} type="search" placeholder="Tìm đơn hàng" />
}

Giải thích nhanh từng phần:

  • ref là prop: forwardRef vẫn chạy nhưng sẽ bị deprecate trong tương lai. Codemod chính thức chuyển giúp bạn.
  • <Context value>: <Context.Provider> vẫn dùng được, React dự kiến deprecate nó sau này.
  • Metadata: <title>, <meta>, <link> render ở đâu cũng được đưa lên <head>. Stylesheet có precedence được sắp xếp thứ tự, khử trùng lặp, và Suspense chờ nó tải xong trước khi hiện nội dung. Trong Next.js, ưu tiên Metadata API (generateMetadata) của framework.
  • Preload: prefetchDNS, preconnect, preload, preloadModule, preinit, preinitModule trong react-dom báo cho trình duyệt tải tài nguyên sớm, gọi được trong render hoặc event handler.
  • useDeferredValue(value, initialValue) nhận thêm giá trị khởi tạo. createRoot có thêm hai option onCaughtError, onUncaughtError (bên cạnh onRecoverableError đã có) để gửi lỗi về hệ thống monitoring.

React 19.2 và 19.3: Activity, useEffectEvent, ViewTransition

<Activity> (19.2) thay cho render có điều kiện khi bạn muốn giữ state của phần bị ẩn:

import { Activity, useState } from 'react'
import { OrderDetailsForm, OrderHistory } from './order-panels'

export function OrderWorkspace({ orderId }: { orderId: string }) {
  const [tab, setTab] = useState<'details' | 'history'>('details')
  return (
    <>
      <nav>
        <button onClick={() => setTab('details')}>Chi tiết</button>
        <button onClick={() => setTab('history')}>Lịch sử</button>
      </nav>
      <Activity mode={tab === 'details' ? 'visible' : 'hidden'}>
        <OrderDetailsForm orderId={orderId} />
      </Activity>
      <Activity mode={tab === 'history' ? 'visible' : 'hidden'}>
        <OrderHistory orderId={orderId} />
      </Activity>
    </>
  )
}

Ở mode hidden, React ẩn cây con bằng display: none, chạy cleanup của effect, giữ nguyên state và DOM, và chỉ xử lý update của cây đó khi rảnh. Chuyển tab qua lại, nội dung đang gõ dở trong form chi tiết vẫn còn. Một tab chưa mở còn có thể được pre-render ẩn để lần mở đầu tiên nhanh hơn. Cái giá là bộ nhớ: DOM và state của mọi tab ẩn vẫn nằm đó, nên đừng bọc danh sách hàng trăm item trong Activity. Next.js khi bật Cache Components cũng dùng Activity để giữ state của route khi điều hướng qua lại. Hệ quả là dropdown đang mở hay kết quả useActionState vẫn còn khi người dùng quay lại trang.

useEffectEvent (19.2) tách phần logic "sự kiện" ra khỏi effect để nó luôn đọc props mới nhất mà không làm effect chạy lại. Ví dụ và các luật đi kèm mình viết ở bài hooks. Cùng đợt còn có cacheSignal cho Server Components (biết khi nào lifetime của cache() kết thúc để abort fetch) và Performance Tracks: hai track Scheduler và Components trong tab Performance của Chrome DevTools, cho thấy React đang render gì ở mức ưu tiên nào.

React 19.3 (9/9/2026) đưa <ViewTransition> lên stable. Bọc phần tử trong <ViewTransition>, mọi update nằm trong transition (startTransition, Suspense reveal, useDeferredValue) sẽ được animate bằng View Transition API của trình duyệt. addTransitionType cho phép cùng một update animate khác nhau theo nguyên nhân:

import { addTransitionType, startTransition, useState, ViewTransition } from 'react'

export function InvoicePager({ pages }: { pages: React.ReactNode[] }) {
  const [page, setPage] = useState(0)

  function go(delta: 1 | -1) {
    startTransition(() => {
      addTransitionType(delta === 1 ? 'next' : 'previous')
      setPage((p) => Math.min(Math.max(p + delta, 0), pages.length - 1))
    })
  }

  return (
    <>
      <ViewTransition
        key={page}
        enter={{ next: 'slide-from-right', previous: 'slide-from-left' }}
        exit={{ next: 'slide-to-left', previous: 'slide-to-right' }}
      >
        <div>{pages[page]}</div>
      </ViewTransition>
      <button onClick={() => go(-1)}>Trang trước</button>
      <button onClick={() => go(1)}>Trang sau</button>
    </>
  )
}

Các class slide-* là CSS bạn tự định nghĩa với ::view-transition-*. Hiện tại <ViewTransition> chỉ chạy trên DOM. Cùng bản 19.3 còn có Fragment refs (<Fragment ref> trả về FragmentInstance để focus, gắn IntersectionObserver hay listener cho nhiều phần tử anh em), use(browser()) trong react-dom để một component chỉ render ở client và hiện Suspense fallback khi SSR, và khả năng render <Context value> trực tiếp từ Server Component với context tạo trong module 'use client'.

Migration: API bị xóa, codemod và cách cũ vs cách mới

Lộ trình mình khuyên: nâng lên React 18.3 trước. Bản này giống 18.2 nhưng in warning cho mọi API sắp bị xóa. Sửa sạch warning, chạy codemod, rồi mới lên 19.

# Codemod cho code React (ref, Context.Provider, các API bị xóa...)
npx codemod@latest react/19/migration-recipe

# Codemod cho type TypeScript (@types/react 19)
npx types-react-codemod@latest preset-19 ./src

npm install react@^19 react-dom@^19
npm install -D @types/react@^19 @types/react-dom@^19

Những thứ đã bị xóa trong React 19: string ref (ref="input"), propTypes của function component (bị bỏ qua hoàn toàn) và defaultProps của function component (thay bằng default parameter), legacy context (contextTypes, getChildContext), ReactDOM.render, ReactDOM.hydrate, unmountComponentAtNode, findDOMNode, React.createFactory, react-test-renderer/shallow. act chuyển từ react-dom/test-utils sang react. Lỗi trong render không còn bị ném lại (re-throw) mà được báo qua window.reportError hoặc console.error, tùy biến được bằng onUncaughtError/onCaughtError. Class component vẫn được hỗ trợ, kể cả defaultProps của class.

Về TypeScript: useRef() bắt buộc có argument (useRef<HTMLDivElement>(null)). Ref callback viết dạng arrow trả về giá trị ngầm như ref={(el) => (inputRef.current = el)} sẽ báo lỗi type vì React 19 hiểu giá trị trả về là cleanup, nên hãy dùng block body. Namespace JSX toàn cục chuyển thành React.JSX.

Việc cần làm React 18 React 19
Submit form onSubmit + preventDefault + useState loading/error <form action={fn}> + useActionState
Nút submit biết đang pending Truyền prop isLoading xuống useFormStatus() trong component con
Cập nhật lạc quan Tự lưu bản cũ, tự hoàn tác trong catch useOptimistic, tự quay về khi Action xong
Đọc dữ liệu async trong render useEffect + useState hoặc thư viện use(promise) + Suspense
Chuyền ref qua component forwardRef ref là prop
Provider <Ctx.Provider value> <Ctx value>
Đọc context có điều kiện Không được use(Ctx)
Thẻ <title>, <meta> react-helmet hoặc API của framework Render trực tiếp trong component
Giữ state khi ẩn UI Đẩy state lên cha hoặc CSS ẩn thủ công <Activity mode="hidden"> (19.2)
Mount ứng dụng ReactDOM.render (đã deprecated từ 18) hoặc createRoot Chỉ còn createRoot(...).render

Ai nên dùng ngay, ai nên chờ

Dùng ngay: dự án mới (khởi tạo thẳng với 19.3), dự án Next.js App Router (vốn đã chạy trên React 19 và Server Actions tích hợp sẵn với useActionState), và các form CRUD đơn giản trong admin panel nơi Actions xóa được nhiều boilerplate nhất.

Không cần vội viết lại: form phức tạp đang chạy ổn với react-hook-form và schema validation (bạn vẫn nâng React 19 được, chỉ không cần chuyển sang useActionState). Tương tự với mutation đang dùng TanStack Query có optimistic update và cache invalidation, useOptimistic không thay được cache chung giữa các màn hình.

Nên chờ hoặc làm từng bước: codebase còn nhiều thư viện cũ dùng findDOMNode, string ref hoặc đọc element.ref (thư viện animation, drag-and-drop đời cũ). Chạy React 18.3 trước, xem warning đến từ đâu, rồi kiểm tra bản tương thích React 19 của từng thư viện.

Những lỗi thường gặp

import { startTransition, use } from 'react'
import { useFormStatus } from 'react-dom'

type CouponState = { code: string | null; error: string | null }

// ❌ 1. useFormStatus trong CHÍNH component render <form>: pending luôn false
function CheckoutFormBad({ action }: { action: (fd: FormData) => Promise<void> }) {
  const { pending } = useFormStatus()
  return <form action={action}><button disabled={pending}>Đặt hàng</button></form>
}
// ✅ Tách nút ra component con nằm trong form
function PlaceOrderButton() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? 'Đang đặt…' : 'Đặt hàng'}</button>
}

// ❌ 2. Ném lỗi validate trong action của useActionState → Error Boundary, Action xếp hàng bị hủy
async function applyCouponBad(prev: CouponState, fd: FormData): Promise<CouponState> {
  if (!fd.get('code')) throw new Error('Thiếu mã')
  // ...
  return prev
}
// ✅ Trả về state lỗi
async function applyCoupon(prev: CouponState, fd: FormData): Promise<CouponState> {
  if (!fd.get('code')) return { ...prev, error: 'Nhập mã giảm giá' }
  // ...
  return prev
}

// ❌ 3. Tạo promise trong render: suspend vô hạn
function InvoiceTotalBad({ id }: { id: string }) {
  const invoice = use(fetch(`/api/invoices/${id}`).then((r) => r.json()))
  return <span>{invoice.total}</span>
}
// ✅ Promise được cache hoặc truyền từ ngoài vào: use(fetchInvoiceCached(id))

// ❌ 4. use() trong try/catch: không bắt được, và vi phạm luật của use
// try { const data = use(promise) } catch { ... }
// ✅ Bọc component bằng <ErrorBoundary> đặt ngoài <Suspense>

// ❌ 5. setState sau await không nằm trong transition: isPending tắt trước khi UI cập nhật
startTransition(async () => {
  const saved = await saveDraft(draft)
  setDraft(saved) // update gấp, tách khỏi transition
})
// ✅
startTransition(async () => {
  const saved = await saveDraft(draft)
  startTransition(() => setDraft(saved))
})

Một lỗi nữa không cần code minh họa: kỳ vọng form giữ nguyên giá trị sau khi Action chạy xong. React reset các input uncontrolled, nên hãy trả giá trị đã nhập trong state và gắn vào defaultValue, hoặc dùng controlled input cho các field cần giữ.

Checklist

  • Nâng lên React 18.3, sửa hết warning deprecation, chạy npx codemod@latest react/19/migration-recipe và types-react-codemod preset-19.
  • Chọn một form CRUD đơn giản, chuyển sang <form action> + useActionState + nút submit dùng useFormStatus.
  • Rà mọi action của useActionState: lỗi nghiệp vụ được trả về dưới dạng state, không ném lỗi.
  • Với mọi setState sau await trong startTransition, bọc thêm startTransition.
  • Mỗi useOptimistic đều có đường cập nhật nguồn sự thật khi thành công (setState, revalidate hoặc invalidate query).
  • Không có use(fetch(...)) hay promise tạo mới trong render. Promise đến từ Server Component, loader, hoặc cache có invalidation.
  • Mỗi Suspense boundary có Error Boundary bọc ngoài, và các request độc lập được khởi động cùng lúc.
  • Nếu dùng RSC: framework và react-server-dom-* ở bản đã vá các CVE từ 12/2025 trở đi.
  • Thử <Activity> cho các tab hoặc panel cần giữ state, đo bộ nhớ trước khi áp dụng rộng.

Đọc thêm

Last updated: June 14, 2026

Comments