jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Tailwind, Radix & shadcn/ui · Part 8 — Radix Deep: Composition, asChild & State

Go pro with Radix: controlled vs uncontrolled state, the asChild slot pattern to merge behavior onto your own components, and Tabs/Switch/Tooltip/Popover composition — the toolkit before shadcn ties it together. With a live composition lab.

Phần 7 đã mổ xẻ giải phẫu một Radix primitive: Root/Trigger/Content, portal, và thuộc tính data-state. Đó là biết tên các bộ phận. Phần này là biết lắp ráp — ba kỹ năng tách “tôi copy nguyên ví dụ trong docs Radix” khỏi “tôi nhồi Radix vào design system của mình”:

  1. State — controlled vs uncontrolled, đến tận cơ chế: khi nào Radix tự giữ state, khi nào bạn cầm lái, và bảng quy ước prop của mọi primitive.
  2. asChild + Slot — cách Radix gộp hành vi (props, ref, event handler) vào component của chính bạn thay vì render thêm một thẻ DOM.
  3. Composition đa primitive — Tooltip bọc Dialog trigger, một item trong DropdownMenu mở ra Dialog, và chiến lược style để không lặp lại data-[state=…] ở mọi nơi.

Ba hợp đồng không được phá

Radix rất linh hoạt, nhưng composition tốt dựa trên ba hợp đồng:

Hợp đồngNghĩa làNếu phá thì lỗi gì
Controlled đi theo cặpopen luôn đi với onOpenChange, value đi với onValueChangepopup kẹt mở/đóng, tab không đổi
asChild cần một child nhận prop/refchild phải forward className, event handler, reftrigger không focus được, click không chạy, style mất
State style đọc từ DOMdùng data-state, aria-*, data-disabledUI lệch accessibility, test khó đọc

Khi một composition Radix “tự nhiên hỏng”, 90% thời gian là một trong ba hợp đồng này bị vi phạm. Đọc phần này như một checklist debug, không chỉ như API reference.

Thử Tabs, Switch, Tooltip, Popover trực tiếp khi ta đi:


1. Uncontrolled vs controlled — toàn cảnh

Đây là khái niệm React nền tảng mà Radix kế thừa nguyên xi, nên nắm chắc một lần là dùng được cho mọi primitive lẫn cho <input> thường.

Uncontrolled = component tự giữ state bên trong (qua useState của riêng nó). Bạn chỉ đưa giá trị khởi tạo qua prop defaultX, rồi để mặc kệ. Radix nhớ “đang mở hay đóng”, “đang checked hay không” — bạn không cần biến state nào cả.

Controlled = bạn giữ state (trong useState/store của bạn) và bơm ngược vào component qua prop x, đồng thời nghe sự kiện đổi qua onXChange. Component không còn trí nhớ riêng — nó hiển thị đúng cái bạn truyền vào.

UNCONTROLLED                          CONTROLLED
────────────                          ──────────
defaultOpen={false}                   open={open}  onOpenChange={setOpen}

  ┌─────────────┐                       ┌─────────────┐
  │   Radix     │  state sống           │   Radix     │  state sống
  │  (state nội)│  TRONG Radix          │ (không nhớ) │  TRONG component bạn
  └─────────────┘                       └──────┬──────┘
        ▲                                      │ onOpenChange(next)
        │ bạn chỉ set lần đầu                  ▼
   (không đọc lại được)                  const [open,setOpen]=useState()
                                               │ open=...
                                               └──► chảy ngược vào Radix

Vì sao có hai chế độ?

Chế độ uncontrolled tồn tại để code gọn: 90% trường hợp bạn không quan tâm dialog đang mở hay đóng, chỉ cần nó mở khi bấm trigger và đóng khi bấm ra ngoài. Tự quản state cho mấy ca đó là boilerplate thừa.

Chế độ controlled tồn tại khi state cần sống ở chỗ khác ngoài component: bạn muốn mở dialog từ một nút ở tận đẩu đâu, đồng bộ tab đang mở vào URL, đóng popover sau khi fetch xong, hay chặn không cho đóng khi form còn dở.

// UNCONTROLLED — Radix tự nhớ, bạn quên đi
<Dialog.Root defaultOpen={false}>…</Dialog.Root>
<Switch.Root defaultChecked />
<Tabs.Root defaultValue="account">…</Tabs.Root>

// CONTROLLED — bạn cầm state, mở/đọc từ bất cứ đâu
const [open, setOpen] = useState(false);

<Dialog.Root open={open} onOpenChange={setOpen}>…</Dialog.Root>

// giờ mở được từ một nút bất kỳ ngoài cây dialog:
<button onClick={() => setOpen(true)}>Open from outside</button>

Cái bẫy “controlled một nửa”

Lỗi kinh điển: truyền open (controlled) nhưng quên onOpenChange. Khi đó Radix gọi handler để báo “người dùng vừa bấm ra ngoài, hãy đóng đi” — nhưng không có ai nghe, nên state open của bạn không bao giờ đổi, và dialog không đóng được. Quy tắc: đã đưa open thì phải đưa onOpenChange; đã đưa value thì phải đưa onValueChange. Cặp đôi đi liền.

// ❌ controlled một nửa — dialog kẹt mở, đóng không được
<Dialog.Root open={open}>…</Dialog.Root>

// ✅ luôn đi theo cặp prop + handler
<Dialog.Root open={open} onOpenChange={setOpen}>…</Dialog.Root>

Đừng trộn defaultOpen với open. Một primitive hoặc uncontrolled (defaultOpen) hoặc controlled (open) — không phải cả hai. Truyền cùng lúc, React sẽ cảnh báo và hành vi thành khó đoán.

Bảng quy ước prop — học một, áp tất

Mọi Radix primitive đặt tên prop theo đúng một công thức: defaultX (uncontrolled) / x + onXChange (controlled). Chỉ tên X đổi theo primitive:

PrimitiveState là gìUncontrolledControlled (value)Controlled (handler)
Dialog / AlertDialogmở/đóngdefaultOpenopenonOpenChange
Popover / HoverCardmở/đóngdefaultOpenopenonOpenChange
DropdownMenu / ContextMenumở/đóngdefaultOpenopenonOpenChange
Tooltipmở/đóngdefaultOpenopenonOpenChange
Collapsible / Accordion*mở/đóngdefaultOpenopenonOpenChange
Tabstab đang chọndefaultValuevalueonValueChange
RadioGroup / ToggleGroupmục đang chọndefaultValuevalueonValueChange
Selectmục đang chọndefaultValuevalueonValueChange
Switch / Checkboxbật/tắtdefaultCheckedcheckedonCheckedChange
Togglebật/tắtdefaultPressedpressedonPressedChange
Slider / Progresssố (mảng)defaultValuevalueonValueChange

*Accordion dùng value/defaultValue (vì có thể nhiều panel mở). Nhìn chung: state nhị phân (mở, checked) dùng open/checked; state chọn-một-trong-nhiều dùng value. Biết quy tắc này thì bạn không cần tra docs từng primitive nữa.

onXChange luôn nhận giá trị tiếp theo làm tham số đầu — onOpenChange(nextOpen: boolean), onValueChange(nextValue: string) — nên bạn truyền thẳng setState vào được: onOpenChange={setOpen}.


2. asChild — vấn đề nó giải quyết

Mặc định Dialog.Trigger render ra một <button> thật. Nhưng bạn đã có component Button style sẵn (từ Phần 5). Nếu cứ đặt Button bên trong Trigger, bạn được… một <button> lồng trong <button> — HTML không hợp lệ, và hai lớp style đánh nhau:

// ❌ button trong button — HTML sai, style chồng chéo
<Dialog.Trigger>
  <Button variant="outline">Edit profile</Button>
</Dialog.Trigger>
// render ra: <button …><button class="…">Edit profile</button></button>

asChild nói với Radix: đừng render thẻ của bạn nữa; thay vào đó hãy lấy tất cả hành vi (props, ref, event handler) gắn thẳng vào đứa con tôi đưa cho bạn:

import { Button } from '@/components/ui/button';

// ✅ asChild — không thẻ thừa, Button của bạn TRỞ THÀNH trigger
<Dialog.Trigger asChild>
  <Button variant="outline">Edit profile</Button>
</Dialog.Trigger>
// render ra: <button class="…(style Button của bạn)…"
//              aria-haspopup="dialog" aria-expanded="false"
//              data-state="closed" onclick="(mở dialog)">Edit profile</button>

Kết quả: một phần tử DOM duy nhất, mang style Button của bạn nhưng cõng toàn bộ onClick, aria-haspopup, aria-expanded, refdata-state của Radix. Đây chính là khớp nối khiến Radix, shadcn và design system của bạn ăn vào nhau mượt mà.

Slot — cơ chế bên dưới

asChild chạy được nhờ một component nội bộ tên Slot (@radix-ui/react-slot). Khi asChild bật, Radix render <Slot {...props}>{children}</Slot> thay vì <button {...props}>. Slot không tự vẽ DOM — nó nhận props của nó, nhận đứa con duy nhất, rồi trộn props của hai bên rồi clone đứa con với props đã trộn:

<Dialog.Trigger asChild>          props Radix: { onClick, aria-*, data-state, ref }
  <Button onClick={myFn}>…        props của bạn: { onClick: myFn, className, … }
</Dialog.Trigger>

        ▼  Slot.merge(propsRadix, propsButton)


  <button                          ← thẻ THẬT do Button render
     className="(của bạn)"          ← className giữ nguyên
     aria-haspopup aria-expanded    ← props Radix gắn vào
     data-state="closed"
     ref={mergedRef}                ← ref của cả hai được hợp nhất
     onClick={chain(radixOnClick, myFn)}>  ← hai handler nối chuỗi (xem mục 4)

  </button>

Slot chính là thứ bạn dùng trực tiếp để khiến component của mình đa hình (polymorphic) — tự nó cũng hỗ trợ asChild. Mẫu “nếu asChild thì là Slot, ngược lại là thẻ thường” là xương sống của mọi component shadcn:

import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';

function Button({ asChild = false, className, ...props }) {
  const Comp = asChild ? Slot : 'button';      // ← chọn "vật chứa"
  return <Comp className={cn(buttonVariants(), className)} {...props} />;
}

// dùng như button thường:
<Button>Lưu</Button>
// HOẶC mượn style Button cho một thẻ khác (mục 8):
<Button asChild><a href="/docs">Tới docs</a></Button>

forwardRef — vì sao bắt buộc

Slot cần gắn ref của Radix vào node DOM thật. Nhưng ref không chảy xuyên qua function component theo cơ chế thường — nó dừng lại ở ranh giới component. Nên đứa con của asChild phải biết forward ref xuống thẻ DOM của nó:

// ✅ React 19 — ref là prop bình thường, forward thẳng
function Button({ className, ref, ...props }) {
  return <button ref={ref} className={cn(buttonVariants(), className)} {...props} />;
}

// ✅ React ≤18 — phải bọc forwardRef
const Button = React.forwardRef(({ className, ...props }, ref) => (
  <button ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
Button.displayName = 'Button';

Quên forward ref thì Radix không nắm được node DOM → mất khả năng quản lý focus, định vị popover, phát hiện bấm-ngoài. Tệ hơn: nó hỏng âm thầm — không lỗi đỏ, chỉ là hành vi biến mất.


3. Đúng một đứa con — và vì sao

Slot clone một phần tử và trộn props vào đó. Nếu bạn đưa hai con, nó không biết gắn onClick vào đứa nào → Radix ném lỗi runtime ngay:

// ❌ asChild với HAI con — lỗi: "only expected to receive a single React element child"
<Dialog.Trigger asChild>
  <Icon />
  <span>Edit</span>
</Dialog.Trigger>

Cách sửa: gói lại thành một phần tử (đứa con đó tự chứa nhiều thứ bên trong):

// ✅ một phần tử bọc ngoài — hợp lệ
<Dialog.Trigger asChild>
  <Button>
    <Icon />
    <span>Edit</span>
  </Button>
</Dialog.Trigger>

Nếu thật sự cần render nhiều node ngang hàng mà vẫn dùng asChild, Radix có Slottable để đánh dấu đâu là node nhận props — nhưng 99% trường hợp, “bọc thành một phần tử” là câu trả lời đúng.


4. Nối chuỗi event handler

Câu hỏi tinh tế: nếu cả Radix bạn đều muốn onClick thì sao? Ví dụ Radix cần onClick để mở dialog, còn bạn muốn onClick để log analytics. Slot không vứt cái nào — nó nối chuỗi: chạy handler của Radix trước, rồi handler của bạn (trừ khi handler trước gọi preventDefault).

<Dialog.Trigger asChild>
  <Button onClick={() => track('open_profile')}>
    Edit profile
  </Button>
</Dialog.Trigger>
// bấm → Radix mở dialog → SAU ĐÓ track('open_profile') chạy

Hệ quả thực dụng: muốn chặn hành vi Radix, gọi event.preventDefault() trong handler của bạn. Đây là cách giữ một menu mở sau khi bấm item, hay xác nhận trước khi đóng:

<AlertDialog.Cancel asChild>
  <Button
    onClick={(e) => {
      if (hasUnsavedChanges) e.preventDefault(); // chặn đóng nếu form dở
    }}
  >
    Cancel
  </Button>
</AlertDialog.Cancel>

Thứ tự: với hầu hết primitive Radix, handler nội bộ chạy trước rồi mới tới handler của bạn. Nên muốn phủ quyết hành vi mặc định, dùng preventDefault() — chứ đừng trông vào việc handler của bạn chạy trước.


5. Bảng gotcha asChild

Triệu chứngNguyên nhânCách xử lý
Error: only expected … single React element childĐưa nhiều con cho asChildBọc lại thành một phần tử bao ngoài (mục 3)
Focus/định vị/bấm-ngoài không chạyCon không forward refforwardRef (React ≤18) hoặc nhận ref làm prop (React 19) rồi gắn vào thẻ DOM
Style biến mất khi gắn asChildclassName của Radix đè/không gộp className của conComponent con phải spread {...props} dùng cn() để gộp className
onClick của bạn không chạyHandler Radix gọi preventDefault chặn chuỗi, hoặc con không spread {...props}Spread {...props}; nếu cần phủ quyết, tự gọi preventDefault trong handler của bạn
data-state không xuất hiện trên phần tửCon nuốt props, không spread xuống thẻ DOMĐảm bảo {...props} rơi tới đúng thẻ DOM gốc
Render hai thẻ lồng nhau dù đã asChildCon cũng là một wrapper render thẻ riêngCon phải render đúng một thẻ DOM gốc và forward props/ref vào đó
Click xuyên hai lớp / kích hoạt hai lầnVừa để asChild vừa lồng thêm <button> bên trong conBỏ thẻ tương tác lồng; chỉ giữ một phần tử tương tác

6. Composition đa primitive

Radix được thiết kế để lồng vào nhau. Hai mẫu hay gặp nhất:

Tooltip bọc một Dialog trigger

Bạn muốn một nút icon: hover hiện tooltip “Chỉnh sửa”, bấm thì mở dialog. Cả Tooltip.Trigger lẫn Dialog.Trigger đều muốn là cùng một nút — asChild cho phép chồng chúng lên nhau:

<Tooltip.Provider delayDuration={200}>
  <Dialog.Root>
    <Tooltip.Root>
      {/* Dialog.Trigger asChild → Tooltip.Trigger asChild → Button thật */}
      <Tooltip.Trigger asChild>
        <Dialog.Trigger asChild>
          <Button variant="ghost" size="icon" aria-label="Edit">
            <PencilIcon className="size-4" />
          </Button>
        </Dialog.Trigger>
      </Tooltip.Trigger>
      <Tooltip.Portal>
        <Tooltip.Content className="rounded bg-surface px-2 py-1 text-xs">
          Chỉnh sửa
          <Tooltip.Arrow className="fill-surface" />
        </Tooltip.Content>
      </Tooltip.Portal>
    </Tooltip.Root>

    <Dialog.Portal>
      <Dialog.Overlay className="fixed inset-0 bg-black/50" />
      <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
                                 rounded-xl bg-surface p-6 shadow-lg">
        <Dialog.Title>Chỉnh sửa hồ sơ</Dialog.Title>

      </Dialog.Content>
    </Dialog.Portal>
  </Dialog.Root>
</Tooltip.Provider>

Vì cả hai trigger asChild xuống cùng Button, Slot gộp props của cả hai primitive vào một thẻ: nó vừa mang aria của tooltip, vừa mang aria-haspopup="dialog"onClick mở dialog. Một nút, hai hành vi.

Item trong DropdownMenu mở ra một Dialog

Đây là ca khó kinh điển, vì hai primitive cãi nhau về focus: khi DropdownMenu.Item được bấm, menu đóng và trả focus về trigger — nhưng bạn lại muốn nó mở một dialog. Nếu để menu tự đóng theo cách thường, dialog vừa mở đã bị giật focus, hỏng. Mẫu đúng: nâng state dialog lên controlled, và chặn auto-focus của menu khi đóng.

function RowActions() {
  const [open, setOpen] = useState(false);

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <DropdownMenu.Root>
        <DropdownMenu.Trigger asChild>
          <Button variant="ghost" size="icon"><DotsIcon /></Button>
        </DropdownMenu.Trigger>

        <DropdownMenu.Content
          // đừng giật focus về trigger khi menu đóng để mở dialog
          onCloseAutoFocus={(e) => e.preventDefault()}
          className="min-w-40 rounded-md border bg-surface p-1 shadow-md"
        >
          <DropdownMenu.Item className="rounded px-2 py-1.5 text-sm
                                        data-[highlighted]:bg-muted">
            Sao chép
          </DropdownMenu.Item>

          {/* item này KHÔNG render dialog trực tiếp — chỉ set state mở */}
          <DropdownMenu.Item
            onSelect={(e) => {
              e.preventDefault();      // chặn menu đóng theo kiểu mặc định
              setOpen(true);           // tự tay mở dialog
            }}
            className="rounded px-2 py-1.5 text-sm text-red-600
                       data-[highlighted]:bg-red-50"
          >
            Xoá…
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Root>

      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
                                   rounded-xl bg-surface p-6 shadow-lg">
          <Dialog.Title>Xoá mục này?</Dialog.Title>

        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Chìa khoá: Dialog nằm ngoài DropdownMenu (anh em, không phải con), state mở của nó là controlled, và Item dùng onSelect + preventDefault để menu không tự “nuốt” hành động. Đây là khác biệt giữa demo và code production.


7. Chiến lược style tái sử dụng

Viết lại nguyên chuỗi data-[state=…] ở mỗi lần dùng không scale. Nước đi pro: bọc mỗi primitive đúng một lần với style nung sẵn, phơi ra API gọn — phần còn lại của app chỉ import wrapper:

// components/ui/switch.tsx — bọc một lần, dùng khắp nơi
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';

export function Switch({
  className,
  ...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
  return (
    <SwitchPrimitive.Root
      className={cn(
        'h-6 w-11 rounded-full bg-input transition-colors',
        'data-[state=checked]:bg-primary',
        'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
        'disabled:cursor-not-allowed disabled:opacity-50',
        className,
      )}
      {...props}
    >
      <SwitchPrimitive.Thumb
        className="block size-5 rounded-full bg-white shadow transition-transform
                   data-[state=checked]:translate-x-5"
      />
    </SwitchPrimitive.Root>
  );
}

Ba điểm khiến đây là wrapper “đúng chuẩn”, không chỉ là copy:

  1. React.ComponentProps<typeof …Root> — kế thừa nguyên kiểu props của Radix, nên checked, onCheckedChange, disabled… vẫn truyền xuyên qua mà không cần khai báo lại.
  2. cn(... , className) — style mặc định đứng trước, className của người dùng đứng sau để override được (Phần 5 giải thích vì sao thứ tự này, cùng tailwind-merge).
  3. Spread {...props} — mọi prop chưa nắm (kể cả data-*, aria-*, event handler) chảy thẳng xuống Radix.

Khi thấy bạn lặp đúng mẫu wrapper này qua hàng chục primitive — đó chính xác là vấn đề shadcn/ui giải quyết: nó là bộ wrapper này, tuyển chọn sẵn, copy thẳng vào repo bạn (Phần 9).


8. Animation lái bằng data-state

Vì Radix dán data-state lên phần tử ở mỗi trạng thái, bạn animate hoàn toàn bằng CSS/Tailwind — không cần thư viện animation, không cần useState. Mấu chốt: phần tử vẫn ở trong DOM trong lúc đóng (Radix giữ nó lại tới khi animation xong nhờ cơ chế “presence”), nên data-[state=closed] có thời gian chạy.

<Dialog.Content
  className="
    fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
    rounded-xl bg-surface p-6 shadow-lg
    data-[state=open]:animate-in  data-[state=open]:fade-in-0  data-[state=open]:zoom-in-95
    data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95
  "
>

</Dialog.Content>

Các data-state bạn sẽ nhắm thường xuyên:

Selector TailwindKhi nào activeDùng cho
data-[state=open]:overlay/menu/popover đang mởfade-in, zoom-in, slide-in
data-[state=closed]:đang đóng (còn trong DOM lúc thoát)fade-out, zoom-out, slide-out
data-[state=checked]:Switch/Checkbox bậtđổi nền, trượt thumb
data-[state=active]:Tabs.Trigger đang chọnnền/chữ tab active
data-[state=delayed-open]:Tooltip mở sau delayfade-in dịu cho tooltip
data-[highlighted]item menu đang được hover/phímnền highlight item
data-[disabled]phần tử bị disablegiảm opacity, đổi con trỏ

Nếu dùng plugin tailwindcss-animate (shadcn cài sẵn), bạn có ngay animate-in/animate-out cùng các tiện ích fade-*, zoom-*, slide-in-from-*. Không có plugin thì tự viết @keyframes rồi nhắm bằng cùng selector data-[state=…] — cơ chế y hệt.

/* tự cuốn, không cần plugin */
@keyframes overlayIn { from { opacity: 0 } to { opacity: 1 } }
[data-state='open'] > .overlay { animation: overlayIn 150ms ease-out; }

Vì sao animate đóng được: nếu Radix gỡ node ngay khi đóng, data-[state=closed] không kịp chạy. Radix giữ node lại đến khi animation kết thúc (theo dõi animationend). Đó là lý do bạn không cần AnimatePresence của framer — Radix đã tự lo “presence”.


9. Tổng kết hai primitive style sẵn

Gộp các mảnh trên, đây là Tabs và Tooltip ở dạng “production”, đủ state + style + a11y:

Tabsdata-[state=active] cho trigger:

<Tabs.Root defaultValue="account" className="w-80">
  <Tabs.List className="flex gap-1 rounded-lg bg-muted p-1">
    <Tabs.Trigger
      value="account"
      className="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors
                 data-[state=active]:bg-indigo-500 data-[state=active]:text-white
                 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
    >
      Account
    </Tabs.Trigger>
    <Tabs.Trigger value="password" className="flex-1 rounded-md px-3 py-1.5 text-sm font-medium
                 data-[state=active]:bg-indigo-500 data-[state=active]:text-white">
      Password
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="account" className="pt-4 text-sm">Thông tin tài khoản…</Tabs.Content>
  <Tabs.Content value="password" className="pt-4 text-sm">Đổi mật khẩu…</Tabs.Content>
</Tabs.Root>

Tooltip — cần một Tooltip.Provider gần gốc app; mở khi hover focus bàn phím:

<Tooltip.Provider delayDuration={200}>
  <Tooltip.Root>
    <Tooltip.Trigger asChild>
      <Button variant="ghost" size="icon" aria-label="Lưu"><SaveIcon /></Button>
    </Tooltip.Trigger>
    <Tooltip.Portal>
      <Tooltip.Content
        sideOffset={6}
        className="rounded bg-surface px-2 py-1 text-xs shadow
                   data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0"
      >
        Đã lưu 2 phút trước
        <Tooltip.Arrow className="fill-surface" />
      </Tooltip.Content>
    </Tooltip.Portal>
  </Tooltip.Root>
</Tooltip.Provider>

Popover giống Dialog nhưng không modal (không khoá nền) — lý tưởng cho editor, color picker, filter inline. Cùng bộ khung Root/Trigger/Portal/Content, cùng cặp prop open/onOpenChange.


10. Recipe thực tế

Bạn muốn một thẻ <a> trông y hệt một nút primary nhưng vẫn là link thật (điều hướng, mở tab mới, được crawler hiểu). asChild mượn style của Button cho <a>:

<Button asChild>
  <a href="/pricing">Xem bảng giá</a>
</Button>
// render: <a href="/pricing" class="(toàn bộ style Button)…">Xem bảng giá</a>

Với router (Next.js / React Router), bọc đúng component link của router:

<Button asChild variant="outline">
  <Link href="/dashboard">Tới dashboard</Link>
</Button>

Nhờ vậy bạn không bao giờ phải dán lại buttonVariants() lên một <a> thủ công — một nguồn style duy nhất cho cả nút lẫn link.

Recipe 2 — menu → dialog xác nhận xoá

Gói lại mẫu ở mục 6 thành một component tái dùng được, có cả nút xác nhận:

function DeleteRowMenu({ onConfirm }: { onConfirm: () => void }) {
  const [open, setOpen] = useState(false);

  return (
    <AlertDialog.Root open={open} onOpenChange={setOpen}>
      <DropdownMenu.Root>
        <DropdownMenu.Trigger asChild>
          <Button variant="ghost" size="icon" aria-label="Hành động"><DotsIcon /></Button>
        </DropdownMenu.Trigger>
        <DropdownMenu.Content
          onCloseAutoFocus={(e) => e.preventDefault()}
          className="min-w-40 rounded-md border bg-surface p-1 shadow-md"
        >
          <DropdownMenu.Item
            onSelect={(e) => { e.preventDefault(); setOpen(true); }}
            className="rounded px-2 py-1.5 text-sm text-red-600 data-[highlighted]:bg-red-50"
          >
            Xoá…
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Root>

      <AlertDialog.Portal>
        <AlertDialog.Overlay className="fixed inset-0 bg-black/50
                                        data-[state=open]:animate-in data-[state=open]:fade-in-0" />
        <AlertDialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
                                        rounded-xl bg-surface p-6 shadow-lg">
          <AlertDialog.Title className="text-lg font-semibold">Xoá mục này?</AlertDialog.Title>
          <AlertDialog.Description className="mt-1 text-sm text-muted-foreground">
            Hành động không thể hoàn tác.
          </AlertDialog.Description>
          <div className="mt-4 flex justify-end gap-2">
            <AlertDialog.Cancel asChild>
              <Button variant="outline">Huỷ</Button>
            </AlertDialog.Cancel>
            <AlertDialog.Action asChild>
              <Button variant="destructive" onClick={onConfirm}>Xoá</Button>
            </AlertDialog.Action>
          </div>
        </AlertDialog.Content>
      </AlertDialog.Portal>
    </AlertDialog.Root>
  );
}

Để ý mọi mảnh ghép của bài này hội tụ: controlled state (open/onOpenChange), asChild để mượn Button cho mọi nút của AlertDialog, onSelect + preventDefault để menu không nuốt hành động, và data-[state=open] cho animation vào.


11. Bài tập

1. Khi nào một component Radix nên controlled thay vì uncontrolled?

Lời giải

Khi state cần sống ngoài component: mở/đọc bằng code, đồng bộ URL hay store, chạy side effect khi đổi, hoặc chặn việc đóng theo điều kiện. Còn lại, để uncontrolled cho gọn.

2. Vì sao đứa con của asChild phải forward ref?

Lời giải

Slot cần gắn ref của Radix vào node DOM thật để Radix quản lý focus, định vị, và phát hiện bấm-ngoài. ref không tự chảy qua function component, nên con phải forwardRef (React ≤18) hoặc nhận ref làm prop (React 19) rồi gắn vào thẻ. Không forward thì hành vi hỏng âm thầm, không báo lỗi.

3. Style một Tabs.Trigger để tab active có nền indigo và chữ trắng.

Lời giải
<Tabs.Trigger value="x"
  className="data-[state=active]:bg-indigo-500 data-[state=active]:text-white">X</Tabs.Trigger>

Radix dán data-state="active" lên trigger đang chọn; selector data-[state=active]: chỉ ăn vào lúc đó.

4. Đoạn này render ra gì, và sai ở đâu?

<Dialog.Trigger asChild>
  <Icon />
  <span>Edit</span>
</Dialog.Trigger>
Lời giải

Lỗi runtime: asChild/Slot chỉ nhận đúng một phần tử con (nó clone một node để trộn props). Có hai con thì nó không biết gắn onClick/ref vào đâu. Sửa: bọc cả hai vào một phần tử, ví dụ <Button><Icon /><span>Edit</span></Button>.

5. Bạn truyền open={open} cho Dialog.Root nhưng dialog bấm ra ngoài không đóng. Thiếu gì?

Lời giải

Thiếu onOpenChange={setOpen}. Khi controlled, Radix không tự đổi state — nó gọi onOpenChange(false) để báo bạn nên đóng. Không nghe sự kiện đó thì open không bao giờ về false. Controlled là một cặp: open onOpenChange luôn đi cùng nhau.

6. Trong mẫu “menu item mở dialog”, vì sao phải gọi e.preventDefault() trong onSelect của DropdownMenu.Item?

Lời giải

Mặc định khi chọn một Item, menu tự đóng và giật focus về trigger. Nếu để vậy, dialog vừa mở đã bị cướp focus → vỡ. e.preventDefault() chặn hành vi đóng-mặc-định để bạn tự kiểm soát: set open của dialog, và (kèm onCloseAutoFocus preventDefault trên Content) giữ focus cho dialog.

Nâng cao: trong lab, bật Switch rồi mở Popover và để ý cả hai phơi data-state — cùng một loại móc bạn nhắm bằng data-[state=checked]:data-[state=open]:. Thử thêm data-[state=open]:zoom-in-95 cho Popover content xem animation vào ra sao.


Điểm chính

  • Uncontrolled (defaultX) cho Radix tự giữ state — mặc định, gọn nhất. Controlled (x + onXChange) khi state cần sống ngoài component. Đã đưa x thì bắt buộc đưa onXChange, nếu không primitive kẹt.
  • Mọi primitive theo một quy ước prop: state nhị phân → open/checked, chọn-một → value; handler luôn nhận giá trị tiếp theo. Học một bảng, áp cho tất.
  • asChild (qua Slot) gộp props + ref + event handler của Radix vào phần tử của bạn — không thẻ DOM thừa. Yêu cầu: đúng một con, con phải forward refspread {...props}.
  • Slot nối chuỗi event handler (Radix trước, bạn sau); gọi preventDefault() để phủ quyết hành vi Radix — nền tảng cho mẫu menu→dialog.
  • Compose đa primitive: chồng nhiều asChild lên cùng một nút (Tooltip + Dialog trigger); với menu→dialog, để Dialog controlled, đặt ngoài menu, dùng onSelect+preventDefault.
  • data-[state=…] cho phép animate vào/ra thuần CSS — Radix giữ node tới khi animation xong nên data-[state=closed] kịp chạy.
  • Bọc mỗi primitive một lần (ComponentProps + cn + spread) là mẫu scale được — và đúng thứ shadcn/ui sản phẩm hoá.

Tiếp theo

Phần 9 — shadcn/ui: triết lý (“không phải thư viện component”), CLI, components.json, và thêm component đầu tiên — nơi Tailwind + Radix + cva/cn cuối cùng khớp vào một workflow.