BlogBlog · react

React patterns nâng cao: Compound Components, custom hooks và headless components

Xây Tabs dạng compound component bằng TypeScript với ARIA và điều hướng bàn phím đúng chuẩn, hook useControllableState cho controlled/uncontrolled, useDisclosure, useClickOutside, prop getters, asChild/Slot, test bằng Testing Library theo role, và khi nào không nên dùng các pattern này.

Part of Deep Dive series
August 20, 2026
30 min read
By Leo Pham
reactdesign-patternstypescriptaccessibilityfrontend
Share this post:

Component <Tabs> trong design system nội bộ của team mình bắt đầu với 3 props: tabs, activeIndex, onChange. Mười tám tháng sau nó có 23 props. Có renderTabLabel vì một màn cần badge đếm số, tabClassName và activeTabClassName vì marketing muốn tab kiểu "pill", disabledTabs và disabledTooltip, vertical, lazyPanels, tabAs để một tab là link... Mỗi yêu cầu mới lại thêm một prop, mỗi prop lại thêm một nhánh if bên trong. Đến lúc cần đặt một nút "Xuất CSV" nằm cùng hàng với các tab thì không prop nào làm được nữa.

Đây là vấn đề về API chứ không phải về code. Bài này xây lại component đó theo ba pattern mà Radix UI, React Aria hay Headless UI đều dùng. Compound components cho phép người dùng tự sắp xếp các phần. Custom hook và headless component tách behavior khỏi markup. asChild cho phép đổi phần tử bên dưới. Code đầy đủ bằng TypeScript, có ARIA và điều hướng bàn phím theo WAI-ARIA Authoring Practices, test bằng Testing Library, và cuối bài có bảng để quyết định khi nào dùng pattern nào, khi nào không nên dùng.

TL;DR:

  • Prop explosion xảy ra khi component vừa giữ behavior vừa quyết định toàn bộ markup. Tách hai thứ đó ra.
  • Compound component = component cha giữ state trong context, các component con (Tabs.List, Tabs.Trigger, Tabs.Panel) đọc context. Người dùng tự sắp xếp JSX.
  • useControllableState cho phép cùng một component chạy được cả ở chế độ controlled lẫn uncontrolled.
  • Prop getters (getToggleProps()) trả về props đã gắn sẵn ARIA và handler, merge an toàn với props của người dùng.
  • Test theo role (getByRole('tab', { name })) để test chính contract accessibility, không phụ thuộc class name.

Bài toán: prop explosion

Đây là API của component cũ, rút gọn từ code thật:

type TabItem = {
  key: string
  label: string
  content: React.ReactNode
  icon?: React.ReactNode
  badge?: number
  disabled?: boolean
  disabledTooltip?: string
  href?: string
}

type LegacyTabsProps = {
  tabs: TabItem[]
  activeKey?: string
  defaultActiveKey?: string
  onChange?: (key: string) => void
  vertical?: boolean
  lazyPanels?: boolean
  variant?: 'underline' | 'pill'
  tabClassName?: string
  activeTabClassName?: string
  panelClassName?: string
  renderTabLabel?: (tab: TabItem, active: boolean) => React.ReactNode
  renderExtraContent?: () => React.ReactNode // cái "nút Xuất CSV" được thêm vào sau
  // ... thêm 10 props nữa
}

Vấn đề không nằm ở số lượng prop mà ở chỗ:

  • Mọi điểm tuỳ biến phải được đoán trước. Muốn chèn tooltip quanh một tab? Thêm prop. Muốn tab thứ ba là link? Thêm prop.
  • Render props cho từng "khe". renderTabLabel, renderExtraContent là dấu hiệu component đang cố trả lại quyền điều khiển markup cho người dùng, nhưng từng mảnh một.
  • Behavior dính vào markup. Logic bàn phím, ARIA và state bị trộn với từng nhánh variant, nên sửa một chỗ dễ làm hỏng chỗ khác.
  • Dữ liệu và UI bị ép vào một mảng. content nằm trong object config, nên panel không thể nhận props riêng một cách tự nhiên.

Compound Components: xây Tabs đầy đủ bằng TypeScript

Bắt đầu từ API mình muốn người dùng viết:

// app/(admin)/customers/[id]/customer-tabs.tsx
'use client'

import { Tabs } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { ExportCsvButton } from './export-csv-button'
import { OrdersTab, InvoicesTab, NotesTab } from './tabs-content'

export function CustomerTabs({ customerId, openOrders }: { customerId: string; openOrders: number }) {
  return (
    <Tabs defaultValue="orders">
      <div className="flex items-center justify-between border-b">
        <Tabs.List aria-label="Thông tin khách hàng" className="flex gap-1">
          <Tabs.Trigger value="orders">
            Đơn hàng <Badge>{openOrders}</Badge>
          </Tabs.Trigger>
          <Tabs.Trigger value="invoices">Hoá đơn</Tabs.Trigger>
          <Tabs.Trigger value="audit" disabled>
            Nhật ký
          </Tabs.Trigger>
          <Tabs.Trigger value="notes">Ghi chú</Tabs.Trigger>
        </Tabs.List>
        <ExportCsvButton customerId={customerId} />
      </div>
      <Tabs.Panel value="orders"><OrdersTab customerId={customerId} /></Tabs.Panel>
      <Tabs.Panel value="invoices"><InvoicesTab customerId={customerId} /></Tabs.Panel>
      <Tabs.Panel value="audit">Đang phát triển</Tabs.Panel>
      <Tabs.Panel value="notes"><NotesTab customerId={customerId} /></Tabs.Panel>
    </Tabs>
  )
}

Badge, nút xuất CSV, tab bị disable, class tuỳ ý: không cần prop riêng nào, vì người dùng tự viết JSX. Component chỉ chịu trách nhiệm phần khó: state, ARIA, bàn phím.

Yêu cầu accessibility theo WAI-ARIA Authoring Practices (pattern Tabs):

  • role="tablist" cho container, role="tab" cho mỗi nút, role="tabpanel" cho mỗi panel.
  • Tab có aria-selected và aria-controls trỏ tới id của panel. Panel có aria-labelledby trỏ ngược về tab.
  • Roving tabindex: chỉ tab đang chọn có tabIndex={0}, các tab khác -1. Phím Tab đi vào tablist một lần rồi nhảy thẳng sang panel, không đi qua từng tab.
  • Mũi tên trái/phải (hoặc lên/xuống nếu dọc) chuyển focus giữa các tab, có wrap ở hai đầu, Home/End về đầu và cuối. Tab disabled bị bỏ qua.
  • Activation tự động (focus là chọn) phù hợp khi panel hiển thị nhanh. Activation thủ công (focus rồi bấm Enter/Space) phù hợp khi đổi panel tốn kém, ví dụ phải fetch dữ liệu.

Code đầy đủ:

// components/ui/tabs.tsx
'use client'

import { createContext, use, useId, useMemo, useRef, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode } from 'react'
import { useControllableState } from '@/hooks/use-controllable-state'

type Orientation = 'horizontal' | 'vertical'
type ActivationMode = 'automatic' | 'manual'

type TabsContextValue = {
  baseId: string
  value: string
  setValue: (value: string) => void
  orientation: Orientation
  activationMode: ActivationMode
}

const TabsContext = createContext<TabsContextValue | null>(null)

function useTabsContext(component: string): TabsContextValue {
  const context = use(TabsContext)
  if (!context) throw new Error(`<${component}> phải nằm bên trong <Tabs>`)
  return context
}

// value có thể chứa dấu cách hoặc ký tự đặc biệt: encode trước khi ghép id
const makeId = (baseId: string, kind: 'tab' | 'panel', value: string) => `${baseId}-${kind}-${encodeURIComponent(value)}`

/* ---------- Root ---------- */

type TabsRootProps = {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  orientation?: Orientation
  activationMode?: ActivationMode
  className?: string
  children: ReactNode
}

function TabsRoot({
  value,
  defaultValue = '',
  onValueChange,
  orientation = 'horizontal',
  activationMode = 'automatic',
  className,
  children,
}: TabsRootProps) {
  const baseId = useId()
  const [current, setCurrent] = useControllableState({ value, defaultValue, onChange: onValueChange })

  const context = useMemo<TabsContextValue>(
    () => ({ baseId, value: current, setValue: setCurrent, orientation, activationMode }),
    [baseId, current, setCurrent, orientation, activationMode],
  )

  // React 19: render context trực tiếp làm provider
  return (
    <TabsContext value={context}>
      <div className={className} data-orientation={orientation}>
        {children}
      </div>
    </TabsContext>
  )
}

/* ---------- List ---------- */

export function TabsList({ onKeyDown, children, ...props }: ComponentPropsWithoutRef<'div'>) {
  const { orientation, activationMode, setValue } = useTabsContext('Tabs.List')
  const listRef = useRef<HTMLDivElement>(null)

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event)
    if (event.defaultPrevented || !listRef.current) return

    const tabs = Array.from(listRef.current.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'))
    const currentIndex = tabs.findIndex((tab) => tab === document.activeElement)
    if (currentIndex === -1) return

    const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
    const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'

    let nextIndex: number | null = null
    if (event.key === nextKey) nextIndex = (currentIndex + 1) % tabs.length
    else if (event.key === prevKey) nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
    else if (event.key === 'Home') nextIndex = 0
    else if (event.key === 'End') nextIndex = tabs.length - 1
    if (nextIndex === null) return

    event.preventDefault() // chặn trang cuộn khi bấm mũi tên/Home/End
    const target = tabs[nextIndex]
    target.focus()
    if (activationMode === 'automatic' && target.dataset.value !== undefined) {
      setValue(target.dataset.value)
    }
  }

  return (
    <div {...props} ref={listRef} role="tablist" aria-orientation={orientation} onKeyDown={handleKeyDown}>
      {children}
    </div>
  )
}

/* ---------- Trigger ---------- */

type TabsTriggerProps = Omit<ComponentPropsWithoutRef<'button'>, 'value'> & { value: string }

export function TabsTrigger({ value, disabled, onClick, children, ...props }: TabsTriggerProps) {
  const { baseId, value: selectedValue, setValue } = useTabsContext('Tabs.Trigger')
  const selected = selectedValue === value

  return (
    <button
      {...props}
      type="button"
      role="tab"
      id={makeId(baseId, 'tab', value)}
      aria-selected={selected}
      aria-controls={makeId(baseId, 'panel', value)}
      tabIndex={selected ? 0 : -1}
      disabled={disabled}
      data-state={selected ? 'active' : 'inactive'}
      data-value={value}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) setValue(value)
      }}
    >
      {children}
    </button>
  )
}

/* ---------- Panel ---------- */

type TabsPanelProps = ComponentPropsWithoutRef<'div'> & { value: string; keepMounted?: boolean }

export function TabsPanel({ value, keepMounted = false, children, ...props }: TabsPanelProps) {
  const { baseId, value: selectedValue } = useTabsContext('Tabs.Panel')
  const selected = selectedValue === value

  return (
    <div
      {...props}
      role="tabpanel"
      id={makeId(baseId, 'panel', value)}
      aria-labelledby={makeId(baseId, 'tab', value)}
      tabIndex={0}
      hidden={!selected}
      data-state={selected ? 'active' : 'inactive'}
    >
      {selected || keepMounted ? children : null}
    </div>
  )
}

/* ---------- Export ---------- */

export const Tabs = Object.assign(TabsRoot, { List: TabsList, Trigger: TabsTrigger, Panel: TabsPanel })
export { TabsRoot }

Các quyết định thiết kế và edge case

Vì sao dùng context mà không dùng Children.map + cloneElement. Cách cũ là duyệt children rồi tiêm isActive, onSelect vào từng phần tử. Nó vỡ ngay khi người dùng bọc Tabs.Trigger trong <Tooltip>, trong Fragment, hay tách ra một component riêng, vì cloneElement chỉ thấy con trực tiếp. Context đi xuyên qua mọi lớp bọc.

Điều hướng bằng DOM query. TabsList tìm [role="tab"]:not([disabled]) lúc bấm phím, thay vì bắt mỗi Trigger "đăng ký" vào một mảng trong context. Thứ tự DOM chính là thứ tự hiển thị, tab disabled tự bị bỏ qua, và không có state đăng ký nào phải đồng bộ khi tab thêm bớt động. Cái giá: Tabs lồng trong label của Tabs khác sẽ bị lẫn. Đó là trường hợp mình chấp nhận không hỗ trợ.

Panel ẩn bằng hidden thay vì không render. Phần tử panel luôn tồn tại để aria-controls của tab trỏ tới một id có thật. Nội dung chỉ render khi panel được chọn, trừ khi bạn truyền keepMounted (ví dụ form đang nhập dở trong panel không được mất state khi đổi tab).

tabIndex={0} trên panel để người dùng bàn phím có thể Tab từ tablist vào panel kể cả khi panel không chứa phần tử focus được nào.

defaultValue phải khớp một tab. Nếu không tab nào có value khớp, không tab nào có tabIndex={0}, và tablist không thể vào được bằng bàn phím. Mình bắt lỗi này bằng test, và với Tabs nhận dữ liệu động thì luôn truyền defaultValue={items[0]?.id}.

useId cho id, không dùng bộ đếm hay Math.random(). Id phải giống nhau giữa HTML render trên server và lần hydrate trên client. Math.random() hay một biến đếm ở module cho ra giá trị khác nhau ở hai môi trường, và bạn nhận lỗi hydration mismatch. useId sinh id dựa trên vị trí của component trong cây, nên ổn định ở cả hai phía. Nó cũng là duy nhất khi bạn render hai Tabs trên cùng một trang.

Activation thủ công. Với activationMode="manual", phím mũi tên chỉ di chuyển focus. Người dùng bấm Enter hoặc Space để chọn tab. Mình không phải viết thêm code cho phần này: Enter và Space trên <button> sinh ra sự kiện click theo hành vi gốc của trình duyệt, và onClick của Trigger đã gọi setValue. Đây là một lý do nữa để Trigger luôn là <button> thật thay vì <div role="tab">.

Chi phí re-render. Mỗi lần đổi tab, mọi Trigger và Panel đọc context đều render lại. Với 4 đến 10 tab, chi phí này không đáng kể. Nếu bạn áp dụng cùng pattern cho một Select có hàng nghìn option, mỗi option render lại khi giá trị đổi sẽ thành vấn đề thật. Lúc đó cần store có selector để mỗi option chỉ render khi trạng thái của chính nó đổi. Chi tiết nằm trong bài Tối ưu hiệu năng React.

Dot notation và Server Components. Tabs.List chạy tốt khi được dùng trong một client component như ví dụ trên. Nhưng nếu bạn import Tabs vào một Server Component trong Next.js App Router rồi viết <Tabs.List>, React sẽ báo lỗi kiểu "Cannot access List on the server. You cannot dot into a client module from a server component". Thứ Server Component nhận được là một client reference, không phải object thật. Vì vậy file trên export thêm TabsRoot, TabsList, TabsTrigger, TabsPanel dạng named export. Đây cũng là lý do shadcn/ui dùng tên phẳng như TabsList thay vì Tabs.List.

Kiểu an toàn cho value bằng factory

value: string nghĩa là gõ nhầm value="invocies" vẫn compile, và tab đó không bao giờ được chọn. Context không mang được generic, nên mình dùng một factory nhỏ để "khoá" kiểu value cho từng bộ tab:

// components/ui/typed-tabs.tsx
'use client'

import type { ComponentProps } from 'react'
import { TabsList, TabsPanel, TabsRoot, TabsTrigger } from './tabs'

export function createTypedTabs<TValue extends string>() {
  type RootProps = Omit<ComponentProps<typeof TabsRoot>, 'value' | 'defaultValue' | 'onValueChange'> & {
    value?: TValue
    defaultValue?: TValue
    onValueChange?: (value: TValue) => void
  }
  type TriggerProps = Omit<ComponentProps<typeof TabsTrigger>, 'value'> & { value: TValue }
  type PanelProps = Omit<ComponentProps<typeof TabsPanel>, 'value'> & { value: TValue }

  function Root({ onValueChange, ...props }: RootProps) {
    // an toàn: giá trị truyền vào onValueChange luôn đến từ một Trigger có kiểu TValue
    return <TabsRoot {...props} onValueChange={onValueChange as ((value: string) => void) | undefined} />
  }
  function Trigger(props: TriggerProps) {
    return <TabsTrigger {...props} />
  }
  function Panel(props: PanelProps) {
    return <TabsPanel {...props} />
  }

  return { Root, List: TabsList, Trigger, Panel }
}

// Gọi một lần ở cấp module, không gọi bên trong component
export const CustomerTabs = createTypedTabs<'orders' | 'invoices' | 'notes'>()

// <CustomerTabs.Trigger value="invocies"> => lỗi TypeScript ngay trong editor
// onValueChange={(tab) => ...} => tab có kiểu 'orders' | 'invoices' | 'notes'

Factory phải được gọi ở cấp module. Gọi nó bên trong một component sẽ tạo ra các component type mới mỗi lần render, và React sẽ unmount rồi mount lại toàn bộ cây tab. Kiểu value giờ cũng chảy thẳng vào onValueChange, nên đoạn đồng bộ URL ở phần sau không phải tự ép kiểu nữa.

Controlled và uncontrolled với useControllableState

Một component tốt phải chạy được ở cả hai chế độ, giống <input>. Uncontrolled (defaultValue) khi người dùng không quan tâm state. Controlled (value + onValueChange) khi state cần đồng bộ với URL, form hay server.

// hooks/use-controllable-state.ts
import { useCallback, useEffect, useRef, useState } from 'react'

type UseControllableStateParams<T> = {
  value?: T // undefined = uncontrolled
  defaultValue: T
  onChange?: (value: T) => void
}

export function useControllableState<T>({ value, defaultValue, onChange }: UseControllableStateParams<T>) {
  const [internalValue, setInternalValue] = useState(defaultValue)
  const isControlled = value !== undefined
  const current = isControlled ? value : internalValue

  // Cảnh báo ở dev khi component đổi chế độ giữa chừng, giống React làm với <input>
  const wasControlledRef = useRef(isControlled)
  useEffect(() => {
    if (process.env.NODE_ENV !== 'production' && wasControlledRef.current !== isControlled) {
      console.warn(
        `useControllableState: component chuyển từ ${wasControlledRef.current ? 'controlled' : 'uncontrolled'} sang ${
          isControlled ? 'controlled' : 'uncontrolled'
        }. Hãy giữ một chế độ trong suốt vòng đời component.`,
      )
    }
    wasControlledRef.current = isControlled
  }, [isControlled])

  const setValue = useCallback(
    (next: T) => {
      if (Object.is(next, current)) return
      if (!isControlled) setInternalValue(next)
      onChange?.(next) // controlled: cha quyết định có cập nhật value hay không
    },
    [current, isControlled, onChange],
  )

  return [current, setValue] as const
}

Ba điểm cần nói rõ:

  • Ở chế độ controlled, hook không tự đổi state. Nó chỉ gọi onChange. Nếu cha không cập nhật value, tab không đổi. Đây là đúng hành vi (cha có thể từ chối, ví dụ khi form chưa lưu), và mình viết test cho nó ở phần dưới.
  • undefined nghĩa là uncontrolled. Bạn không thể "control" bằng giá trị undefined. Lỗi kinh điển là value={selectedTab ?? undefined}: lúc chưa có dữ liệu thì component uncontrolled, có dữ liệu rồi thì chuyển sang controlled. Cảnh báo trong effect sinh ra để bắt đúng lỗi này.
  • setValue đổi identity khi onChange đổi. Nếu cha truyền inline function, context của Tabs có value mới mỗi lần cha render. Với số lượng consumer nhỏ như Tabs thì không đáng kể. Nếu cha đã bật React Compiler thì inline callback cũng được memo tự động.

Dùng controlled để đồng bộ tab với URL trong Next.js, khi đó F5 hay chia sẻ link vẫn mở đúng tab:

// app/(admin)/customers/[id]/customer-tabs-url.tsx
'use client'

import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { Tabs } from '@/components/ui/tabs'

const TAB_VALUES = ['orders', 'invoices', 'notes'] as const

export function CustomerTabsWithUrl() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const requested = searchParams.get('tab')
  const tab = TAB_VALUES.find((t) => t === requested) ?? 'orders' // không tin query string

  return (
    <Tabs
      value={tab}
      onValueChange={(next) => {
        const params = new URLSearchParams(searchParams)
        params.set('tab', next)
        router.replace(`${pathname}?${params.toString()}`, { scroll: false })
      }}
    >
      {/* Tabs.List / Tabs.Panel như ví dụ trước */}
    </Tabs>
  )
}

Lưu ý: component dùng useSearchParams trong một route render tĩnh cần được bọc trong <Suspense>, nếu không Next.js báo lỗi lúc build.

Custom hooks: tách behavior ra khỏi component

Compound component giải quyết bài toán "nhiều phần phối hợp". Còn khi cùng một behavior (mở/đóng, bấm ra ngoài thì đóng) xuất hiện ở dropdown, popover, filter panel, mobile menu, thì đó là việc của custom hook.

useClickOutside

// hooks/use-click-outside.ts
import { useEffect, useEffectEvent, type RefObject } from 'react'

export function useClickOutside(
  refs: ReadonlyArray<RefObject<HTMLElement | null>>,
  onOutside: (event: PointerEvent) => void,
  enabled = true,
) {
  // React 19.2+: luôn đọc refs/handler mới nhất mà không cần đăng ký lại listener
  const handlePointerDown = useEffectEvent((event: PointerEvent) => {
    const path = event.composedPath() // đúng cả khi phần tử nằm trong Shadow DOM
    const inside = refs.some((ref) => ref.current !== null && path.includes(ref.current))
    if (!inside) onOutside(event)
  })

  useEffect(() => {
    if (!enabled) return
    const listener = (event: PointerEvent) => handlePointerDown(event)
    document.addEventListener('pointerdown', listener)
    return () => document.removeEventListener('pointerdown', listener)
  }, [enabled])
}

Các lựa chọn ở đây đều có lý do:

  • pointerdown thay vì click. click chỉ bắn khi nhả chuột. Người dùng nhấn chuột trong panel, kéo ra ngoài rồi thả (bôi đen text chẳng hạn), thì click bắn trên phần tử bên ngoài và panel đóng oan. pointerdown còn bao cả chuột, cảm ứng lẫn bút.
  • Nhận nhiều ref. Nút mở menu phải nằm trong danh sách "bên trong". Nếu không, bấm nút khi menu đang mở sẽ đóng menu ở pointerdown rồi mở lại ở click.
  • Portal không phải là DOM con. Một date picker render qua portal vào document.body nằm ngoài panelRef trong DOM, nên bấm vào lịch sẽ đóng panel filter. Hãy truyền thêm ref của phần tử portal vào refs. Đây cũng là chỗ thư viện như Radix hay React Aria đã xử lý giúp bạn.
  • useEffectEvent ổn định từ React 19.2. Ở React 18, thay bằng một useRef lưu handler và cập nhật ref trong useEffect.

useDisclosure kết hợp với useClickOutside

// app/(admin)/orders/filter-popover.tsx
'use client'

import { useEffect, useRef } from 'react'
import { useClickOutside } from '@/hooks/use-click-outside'
import { useDisclosure } from '@/hooks/use-disclosure'

export function FilterPopover({ children }: { children: React.ReactNode }) {
  const filters = useDisclosure()
  const triggerRef = useRef<HTMLButtonElement>(null)
  const panelRef = useRef<HTMLDivElement>(null)

  useClickOutside([triggerRef, panelRef], filters.close, filters.isOpen)

  // Escape: đóng và trả focus về nút mở, để người dùng bàn phím không bị "lạc"
  useEffect(() => {
    if (!filters.isOpen) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Escape') return
      filters.close()
      triggerRef.current?.focus()
    }
    document.addEventListener('keydown', onKeyDown)
    return () => document.removeEventListener('keydown', onKeyDown)
  }, [filters.isOpen, filters.close])

  return (
    <div className="relative">
      <button ref={triggerRef} {...filters.getToggleProps({ className: 'rounded-md border px-3 py-1.5' })}>
        Bộ lọc
      </button>
      <div ref={panelRef} {...filters.getPanelProps({ className: 'absolute right-0 z-10 mt-2 w-80 rounded-lg border bg-white p-4 shadow-lg' })}>
        {children}
      </div>
    </div>
  )
}

getToggleProps và getPanelProps đến từ phần tiếp theo. Đây chính là điểm nối giữa custom hook và headless component.

Headless components và prop getters

Một hook trả về isOpen và toggle là chưa đủ. Người dùng vẫn phải tự nhớ gắn aria-expanded, aria-controls, type="button" và id khớp nhau. Prop getter đóng gói luôn phần đó:

// hooks/use-disclosure.ts
import { useCallback, useId, useState, type ButtonHTMLAttributes, type HTMLAttributes } from 'react'

// Gọi handler của người dùng trước; nếu họ preventDefault thì bỏ qua handler của hook
function composeHandlers<E extends { defaultPrevented: boolean }>(
  userHandler: ((event: E) => void) | undefined,
  ownHandler: (event: E) => void,
) {
  return (event: E) => {
    userHandler?.(event)
    if (!event.defaultPrevented) ownHandler(event)
  }
}

export function useDisclosure({ defaultOpen = false }: { defaultOpen?: boolean } = {}) {
  const [isOpen, setIsOpen] = useState(defaultOpen)
  const panelId = useId()

  const open = useCallback(() => setIsOpen(true), [])
  const close = useCallback(() => setIsOpen(false), [])
  const toggle = useCallback(() => setIsOpen((prev) => !prev), [])

  function getToggleProps({ onClick, ...props }: ButtonHTMLAttributes<HTMLButtonElement> = {}): ButtonHTMLAttributes<HTMLButtonElement> {
    return {
      type: 'button',
      ...props,
      'aria-expanded': isOpen,
      'aria-controls': panelId,
      onClick: composeHandlers(onClick, toggle),
    }
  }

  function getPanelProps(props: HTMLAttributes<HTMLDivElement> = {}): HTMLAttributes<HTMLDivElement> {
    return { ...props, id: panelId, hidden: !isOpen }
  }

  return { isOpen, open, close, toggle, getToggleProps, getPanelProps }
}

Tại sao không trả về một object toggleProps để người dùng spread? Vì thứ tự spread quyết định ai thắng. <button {...toggleProps} onClick={track}> sẽ ghi đè onClick của hook và nút không mở được nữa. Prop getter nhận props của người dùng làm tham số nên nó merge có chủ đích: handler được compose, ARIA luôn được giữ. Ngoài ra hook có thể thêm onKeyDown trong tương lai mà người dùng không phải sửa dòng nào. Downshift là thư viện đã phổ biến pattern này (getToggleButtonProps, getMenuProps, getItemProps).

"Headless" nghĩa là component hoặc hook cung cấp toàn bộ behavior và accessibility nhưng không có style hay markup cố định. useDisclosure ở trên là một headless hook. Radix UI, React Aria và Headless UI là các thư viện headless component.

Render props hay hooks?

Cùng một behavior có thể được đóng gói dưới dạng render prop:

import type { ReactNode } from 'react'
import { useDisclosure } from '@/hooks/use-disclosure'

type DisclosureApi = ReturnType<typeof useDisclosure>

export function Disclosure({ defaultOpen, children }: { defaultOpen?: boolean; children: (api: DisclosureApi) => ReactNode }) {
  const api = useDisclosure({ defaultOpen })
  return <>{children(api)}</>
}

// Dùng ngay trong vòng lặp mà không phải tách component cho mỗi dòng
export function FaqList({ items }: { items: { id: string; question: string; answer: string }[] }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <Disclosure>
            {({ isOpen, getToggleProps, getPanelProps }) => (
              <>
                <button {...getToggleProps()}>
                  {item.question} {isOpen ? '−' : '+'}
                </button>
                <div {...getPanelProps()}>{item.answer}</div>
              </>
            )}
          </Disclosure>
        </li>
      ))}
    </ul>
  )
}

Quy tắc của mình: mặc định dùng hook, vì nó gọn hơn, không thêm tầng lồng JSX và dễ kết hợp nhiều hook với nhau. Render props vẫn có chỗ đứng khi bạn cần state ngay tại chỗ trong JSX (như trong vòng lặp ở trên, nơi không gọi được hook). Hoặc khi component cần render thêm wrapper hay provider bao quanh. Hoặc cho API kiểu className={({ isSelected }) => ...} mà React Aria Components dùng để style theo state.

asChild / Slot: đổi phần tử bên dưới

Tabs.Trigger luôn render <button>. Nhưng đôi khi bạn cần một Button có style của design system mà thực chất là <Link> của Next.js. Có hai cách:

  • as prop (<Button as={Link} href="...">): kiểu TypeScript cho component đa hình rất phức tạp, và props của hai component dễ xung đột.
  • asChild (<Button asChild><Link href="..." /></Button>): Button không render phần tử của mình mà merge props (class, handler, ARIA) vào phần tử con duy nhất.
// components/ui/button.tsx
import { Slot } from '@radix-ui/react-slot'
import type { ButtonHTMLAttributes } from 'react'

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; variant?: 'primary' | 'ghost' }

export function Button({ asChild = false, variant = 'primary', className, ...props }: ButtonProps) {
  const Comp = asChild ? Slot : 'button'
  const base = variant === 'primary' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'hover:bg-gray-100'
  return <Comp className={`inline-flex items-center rounded-md px-4 py-2 ${base} ${className ?? ''}`} {...props} />
}

// Dùng:
// <Button asChild><Link href="/invoices/new">Tạo hoá đơn</Link></Button>
// => render một thẻ <a> duy nhất mang class của Button, không có <button> bọc <a>

Bên trong, Slot dùng cloneElement để merge props: nối className, gộp style, compose các handler on* (handler của con chạy trước), và compose ref của Slot với ref của con. Phần compose ref là lý do mình dùng @radix-ui/react-slot thay vì tự viết. Tự viết rất dễ để ref của một bên ghi đè bên kia. Ràng buộc cần nhớ: con phải là đúng một React element và phải forward props xuống DOM. Một component con nuốt onClick hay className sẽ làm asChild mất tác dụng mà không báo lỗi gì.

Thư viện đã làm sẵn các pattern này

Thư viện Cách tiếp cận Đổi phần tử Phù hợp khi
Radix UI Primitives Compound components, unstyled, data attribute cho state (data-state="active") asChild Xây design system riêng; là nền của shadcn/ui
React Aria (Adobe) Hooks (react-aria) và components (react-aria-components) có render props theo state Render props, hooks Yêu cầu accessibility và i18n khắt khe, cần component phức tạp (date picker, combobox, table)
Headless UI (Tailwind Labs) Compound components (TabGroup, TabList, Tab, TabPanels, TabPanel) as prop Project Tailwind cần bộ component cơ bản, ít cấu hình

Lời khuyên thực tế: với Tabs, Dialog, Menu, Combobox, Popover, hãy dùng thư viện. Phần bàn phím, focus trap, portal, scroll lock và screen reader có hàng trăm edge case mà bạn sẽ không muốn tự tìm ra. Tự viết như trong bài đáng làm khi bạn cần hiểu thư viện đang làm gì, hoặc khi component đủ đơn giản và đặc thù.

Test compound component bằng Testing Library theo role

Test theo role kiểm tra đúng contract mà người dùng bàn phím và screen reader dựa vào. Nếu getByRole('tab', { name: 'Hoá đơn' }) tìm được phần tử, thì role, accessible name và liên kết ARIA đều đang đúng.

// vitest.config.ts
import { fileURLToPath } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { '@': fileURLToPath(new URL('./', import.meta.url)) } },
  test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] },
})

// vitest.setup.ts chỉ cần một dòng: import '@testing-library/jest-dom/vitest'
// components/ui/tabs.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Tabs } from './tabs'

function CustomerTabsFixture(props: { value?: string; onValueChange?: (value: string) => void }) {
  return (
    <Tabs defaultValue="orders" {...props}>
      <Tabs.List aria-label="Thông tin khách hàng">
        <Tabs.Trigger value="orders">Đơn hàng</Tabs.Trigger>
        <Tabs.Trigger value="invoices">Hoá đơn</Tabs.Trigger>
        <Tabs.Trigger value="audit" disabled>
          Nhật ký
        </Tabs.Trigger>
        <Tabs.Trigger value="notes">Ghi chú</Tabs.Trigger>
      </Tabs.List>
      <Tabs.Panel value="orders">Danh sách đơn hàng</Tabs.Panel>
      <Tabs.Panel value="invoices">Danh sách hoá đơn</Tabs.Panel>
      <Tabs.Panel value="audit">Nhật ký thay đổi</Tabs.Panel>
      <Tabs.Panel value="notes">Ghi chú nội bộ</Tabs.Panel>
    </Tabs>
  )
}

afterEach(() => vi.restoreAllMocks())

describe('Tabs', () => {
  it('liên kết tab và panel bằng ARIA', () => {
    render(<CustomerTabsFixture />)

    expect(screen.getByRole('tablist', { name: 'Thông tin khách hàng' })).toBeInTheDocument()
    expect(screen.getByRole('tab', { name: 'Đơn hàng' })).toHaveAttribute('aria-selected', 'true')
    // tên của panel đến từ aria-labelledby => chứng minh liên kết hai chiều đúng
    expect(screen.getByRole('tabpanel', { name: 'Đơn hàng' })).toHaveTextContent('Danh sách đơn hàng')
    // panel ẩn không có trong accessibility tree
    expect(screen.queryByRole('tabpanel', { name: 'Hoá đơn' })).not.toBeInTheDocument()
  })

  it('điều hướng bằng mũi tên, bỏ qua tab disabled, wrap và Home/End', async () => {
    const user = userEvent.setup()
    render(<CustomerTabsFixture />)

    await user.tab()
    expect(screen.getByRole('tab', { name: 'Đơn hàng' })).toHaveFocus()

    await user.keyboard('{ArrowRight}')
    expect(screen.getByRole('tab', { name: 'Hoá đơn' })).toHaveFocus()
    expect(screen.getByRole('tabpanel', { name: 'Hoá đơn' })).toBeVisible()

    await user.keyboard('{ArrowRight}') // "Nhật ký" bị disable => nhảy sang "Ghi chú"
    expect(screen.getByRole('tab', { name: 'Ghi chú' })).toHaveAttribute('aria-selected', 'true')

    await user.keyboard('{ArrowRight}') // wrap về đầu
    expect(screen.getByRole('tab', { name: 'Đơn hàng' })).toHaveFocus()

    await user.keyboard('{End}')
    expect(screen.getByRole('tab', { name: 'Ghi chú' })).toHaveFocus()
    await user.keyboard('{Home}')
    expect(screen.getByRole('tab', { name: 'Đơn hàng' })).toHaveFocus()
  })

  it('phím Tab đi từ tablist thẳng vào panel (roving tabindex)', async () => {
    const user = userEvent.setup()
    render(<CustomerTabsFixture />)

    await user.tab()
    await user.tab()
    expect(screen.getByRole('tabpanel', { name: 'Đơn hàng' })).toHaveFocus()
  })

  it('controlled: gọi onValueChange nhưng không tự đổi tab', async () => {
    const user = userEvent.setup()
    const onValueChange = vi.fn()
    render(<CustomerTabsFixture value="orders" onValueChange={onValueChange} />)

    await user.click(screen.getByRole('tab', { name: 'Hoá đơn' }))
    expect(onValueChange).toHaveBeenCalledWith('invoices')
    expect(screen.getByRole('tab', { name: 'Đơn hàng' })).toHaveAttribute('aria-selected', 'true')
  })

  it('báo lỗi rõ ràng khi Trigger nằm ngoài Tabs', () => {
    vi.spyOn(console, 'error').mockImplementation(() => {}) // React log lỗi render ra console
    expect(() => render(<Tabs.Trigger value="orders">Đơn hàng</Tabs.Trigger>)).toThrow('phải nằm bên trong <Tabs>')
  })
})

Mấy điều mình rút ra khi test loại component này:

  • userEvent.setup() mô phỏng chuỗi sự kiện thật (keydown, focus, click), sát với trình duyệt hơn fireEvent. Luôn await các lệnh của nó.
  • Test hành vi bàn phím là phần quan trọng nhất, vì đây là thứ dễ hỏng khi refactor nhất mà nhìn bằng mắt không thấy.
  • jsdom không tính layout, nên test thứ tự focus dựa vào DOM là ổn, còn test vị trí hiển thị thì không. Phần đó để Playwright lo. Chiến lược tổng thể giữa unit, integration và E2E có trong bài Chiến lược testing.

Chọn pattern nào cho bài toán nào

Bài toán Pattern Ví dụ Không nên dùng khi
Nhiều phần phối hợp, người dùng cần tự sắp xếp layout Compound components Tabs, Accordion, Select, Card có Header/Body/Footer Chỉ có một layout cố định và vài prop
Cùng behavior lặp lại ở nhiều component Custom hook useDisclosure, useClickOutside, useMediaQuery Logic chỉ dùng ở một chỗ, tách ra chỉ thêm một file phải nhảy qua
Behavior + ARIA, người dùng toàn quyền markup Headless hook với prop getters Combobox, menu, disclosure Team chỉ cần một component có style sẵn và dùng như nhau ở mọi nơi
Component cần chạy cả khi được điều khiển từ ngoài useControllableState Tabs đồng bộ URL, Dialog mở từ server action Component nội bộ mà không ai cần điều khiển state
Giữ style/behavior nhưng đổi phần tử DOM asChild / Slot Button là Link, Trigger là <a> Con không forward props xuống DOM
Cần state ngay trong JSX, hoặc style theo state Render props Danh sách FAQ, className={({ isSelected }) => ...} Hook làm được mà không cần lồng thêm JSX
UI đơn giản, cố định Props thường Badge, Avatar, Spinner Bắt đầu có prop thứ 8 dạng renderX

Cũng cần nói thẳng một điều: linh hoạt không phải lúc nào cũng tốt. Một design system cần nhất quán có thể chủ động giữ API dạng props hạn chế. Mục đích là để mọi màn hình trông giống nhau, và việc không cho người dùng tự sắp xếp layout ở đây là một tính năng. Compound components chuyển quyền quyết định layout cho người dùng, kéo theo cả khả năng làm sai. Mình dùng nó cho primitive (Tabs, Menu) và giữ props thường cho component cấp cao (<CustomerHeader customer={...} />).

Migrate component cũ mà không phá code đang chạy

Component <LegacyTabs> ở đầu bài đang được dùng ở hơn 40 chỗ. Viết lại tất cả trong một PR là cách chắc chắn nhất để PR đó không bao giờ được merge. Cách mình làm là biến API cũ thành một lớp mỏng bọc quanh compound component mới:

// components/ui/legacy-tabs.tsx
'use client'

import type { ReactNode } from 'react'
import { Badge } from './badge'
import { TabsList, TabsPanel, TabsRoot, TabsTrigger } from './tabs'

type LegacyTabItem = { key: string; label: string; content: ReactNode; badge?: number; disabled?: boolean }

type LegacyTabsProps = {
  tabs: LegacyTabItem[]
  ariaLabel: string
  activeKey?: string
  defaultActiveKey?: string
  onChange?: (key: string) => void
}

/** @deprecated Dùng <Tabs> dạng compound (components/ui/tabs). Component này chỉ giữ để tương thích. */
export function LegacyTabs({ tabs, ariaLabel, activeKey, defaultActiveKey, onChange }: LegacyTabsProps) {
  return (
    <TabsRoot value={activeKey} defaultValue={defaultActiveKey ?? tabs[0]?.key} onValueChange={onChange}>
      <TabsList aria-label={ariaLabel}>
        {tabs.map((tab) => (
          <TabsTrigger key={tab.key} value={tab.key} disabled={tab.disabled}>
            {tab.label}
            {tab.badge ? <Badge>{tab.badge}</Badge> : null}
          </TabsTrigger>
        ))}
      </TabsList>
      {tabs.map((tab) => (
        <TabsPanel key={tab.key} value={tab.key}>
          {tab.content}
        </TabsPanel>
      ))}
    </TabsRoot>
  )
}

Lợi ích đầu tiên: 40 chỗ đang dùng lập tức có ARIA và điều hướng bàn phím đúng mà không phải sửa dòng nào. Tag @deprecated làm editor gạch ngang tên component, nên người viết code mới tự chuyển sang API mới. Những prop chỉ phục vụ markup (renderTabLabel, tabClassName...) mình không mang sang lớp bọc. Chỗ nào còn dùng chúng là chỗ cần chuyển sang compound trước, và grep ra danh sách đó cho bạn backlog cụ thể. Khi số chỗ dùng về 0, xoá file.

Test viết cho Tabs ở phần trên vẫn giữ nguyên giá trị. Bạn chỉ cần thêm một test nhỏ cho LegacyTabs, kiểm tra nó render đúng số tab và gọi onChange với key.

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

1. Tiêm props bằng Children.map + cloneElement.

// Sai: vỡ khi Trigger bị bọc trong Tooltip, Fragment, hoặc component khác
{Children.map(children, (child, index) => cloneElement(child, { isActive: index === activeIndex }))}

// Đúng: state đi qua context, Trigger tự đọc
const { value } = useTabsContext('Tabs.Trigger')

2. Mỗi tab đều có tabIndex={0}. Người dùng bàn phím phải bấm Tab qua từng tab mới vào được panel. Dùng roving tabindex: chỉ tab đang chọn có 0.

3. Xử lý phím mũi tên mà quên preventDefault(). Trang cuộn theo mỗi lần chuyển tab, đặc biệt rõ với Tabs dọc và phím lên/xuống.

4. Ghi đè handler của người dùng.

// Sai: onClick của người dùng (analytics) bị mất, hoặc ngược lại onClick của hook bị mất
<button {...props} onClick={toggle} />

// Đúng: compose, tôn trọng preventDefault
<button {...props} onClick={composeHandlers(props.onClick, toggle)} />

5. Chuyển giữa controlled và uncontrolled. value={data?.tab}: lúc data chưa có thì undefined (uncontrolled), có rồi thì controlled. Truyền giá trị mặc định rõ ràng: value={data?.tab ?? 'orders'}.

6. Dùng Tabs.List trong Server Component. Lỗi "cannot dot into a client module". Import tên phẳng TabsList hoặc đặt phần JSX dùng Tabs trong một client component.

7. useClickOutside với sự kiện click và không đưa nút mở vào danh sách ref. Menu đóng rồi mở lại ngay. Dùng pointerdown và truyền [triggerRef, panelRef].

8. Test bằng class name hoặc data-testid.

// Sai: pass dù ARIA hỏng hoàn toàn
expect(container.querySelector('.tab--active')).toHaveTextContent('Hoá đơn')

// Đúng: test đúng thứ screen reader thấy
expect(screen.getByRole('tab', { name: 'Hoá đơn' })).toHaveAttribute('aria-selected', 'true')

Checklist

  • Liệt kê các component có từ 3 prop renderX/xClassName trở lên; đó là ứng viên chuyển sang compound components.
  • Compound component dùng context (không dùng cloneElement), throw lỗi rõ ràng khi con nằm ngoài cha, và context value được useMemo.
  • Export cả tên phẳng (TabsList) bên cạnh dot notation để dùng được từ Server Components.
  • Tabs, menu, listbox có roving tabindex, phím mũi tên, Home/End, bỏ qua phần tử disabled.
  • Component có state cho phép cả value/onValueChange lẫn defaultValue qua useControllableState.
  • Hook dùng chung trả về prop getters compose handler, không trả object để spread.
  • useClickOutside dùng pointerdown, nhận cả ref của trigger và của nội dung portal.
  • Test viết bằng getByRole + userEvent, có test bàn phím và test chế độ controlled.
  • Với Dialog, Combobox, Menu: đã cân nhắc Radix UI hoặc React Aria trước khi tự viết.

Đọc thêm

Last updated: August 20, 2026

Comments