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.
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. useControllableStatecho 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,renderExtraContentlà 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.
contentnằ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-selectedvàaria-controlstrỏ tới id của panel. Panel cóaria-labelledbytrỏ 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/Endvề đầ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ậtvalue, 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. undefinednghĩ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 khionChangeđổ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:
pointerdownthay vìclick.clickchỉ 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ìclickbắn trên phần tử bên ngoài và panel đóng oan.pointerdowncò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 ở
pointerdownrồi mở lại ởclick. - Portal không phải là DOM con. Một date picker render qua portal vào
document.bodynằm ngoàipanelReftrong 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àorefs. Đâ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ộtuseReflưu handler và cập nhật ref tronguseEffect.
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:
asprop (<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>):Buttonkhô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ơnfireEvent. Luônawaitcá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/xClassNametrở 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 đượcuseMemo. - 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/onValueChangelẫndefaultValuequauseControllableState. - Hook dùng chung trả về prop getters compose handler, không trả object để spread.
-
useClickOutsidedùngpointerdown, 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.