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.

Part 7 covered the anatomy {Phần 7 đã nói về giải phẫu}. Now the patterns that separate “I copied a Radix example” from “I can compose Radix into my own design system” {Giờ là các mẫu phân biệt “tôi copy ví dụ Radix” với “tôi kết hợp Radix vào design system của mình”}: controlled state, the crucial asChild slot, and the composition vocabulary across more primitives {state controlled, slot asChild then chốt, và vốn từ kết hợp qua nhiều primitive}.

Try Tabs, Switch, Tooltip, and Popover live as we go {Thử Tabs, Switch, Tooltip, Popover trực tiếp khi ta đi}:


1. Uncontrolled vs controlled {Uncontrolled vs controlled}

By default Radix is uncontrolled — it owns the open/closed (or checked) state internally {Mặc định Radix là uncontrolled — nó tự giữ trạng thái mở/đóng (hay checked) bên trong}. You set an initial value and forget it {Bạn đặt giá trị ban đầu rồi quên đi}:

<Dialog.Root defaultOpen={false}>…</Dialog.Root>
<Switch.Root defaultChecked />

Switch to controlled when you need to drive or observe the state — to open a dialog from elsewhere, sync to a URL, or run side effects {Chuyển sang controlled khi bạn cần điều khiển hoặc theo dõi trạng thái — mở dialog từ nơi khác, đồng bộ URL, hay chạy side effect}:

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

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

</Dialog.Root>

// now you can do this from anywhere:
<button onClick={() => setOpen(true)}>Open from outside</button>

The rule {Quy tắc}: uncontrolled by default; reach for controlled only when you have a concrete reason {mặc định uncontrolled; chỉ dùng controlled khi có lý do cụ thể}. Every Radix primitive follows the same value/onValueChange, open/onOpenChange, checked/onCheckedChange convention {Mọi Radix primitive theo cùng quy ước value/onValueChange, open/onOpenChange, checked/onCheckedChange}.


2. asChild — the slot pattern {asChild — mẫu slot}

By default Dialog.Trigger renders a <button> {Mặc định Dialog.Trigger render một <button>}. But you already have a styled Button component (from Part 5) — you don’t want a button inside a button {Nhưng bạn đã có component Button đã style (từ Phần 5) — bạn không muốn một button trong button}. asChild tells Radix: don’t render your own element; merge your behavior and props onto my child instead {asChild bảo Radix: đừng render phần tử của bạn; hãy ghép hành vi và props vào con của tôi}:

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

<Dialog.Trigger asChild>
  <Button variant="outline">Edit profile</Button>
</Dialog.Trigger>

Now there’s no extra wrapper DOM — Radix’s onClick, aria-haspopup, aria-expanded, ref, and data-state all land on your Button {Giờ không có DOM bọc thừaonClick, aria-haspopup, aria-expanded, ref và data-state của Radix đều rơi vào Button của bạn}. This is how Radix, shadcn, and your design system interlock cleanly {Đây là cách Radix, shadcn và design system của bạn khớp vào nhau gọn gàng}.

The gotcha: an asChild child must accept and forward a ref and spread props {Điểm vướng: con của asChild phải nhận và forward ref cùng spread props}. With function components, that means React.forwardRef (or a ref-as-prop setup) and {...props} — otherwise the merged behavior silently drops {Với function component, nghĩa là React.forwardRef (hoặc ref-as-prop) và {...props} — nếu không, hành vi ghép vào sẽ âm thầm rơi mất}.

Under the hood asChild is powered by Radix’s Slot component, which you can use directly to build your own polymorphic, asChild-capable components {Bên dưới asChild chạy nhờ component Slot của Radix, thứ bạn dùng trực tiếp để dựng component của mình có khả năng đa hình, hỗ trợ asChild}:

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

function Button({ asChild, className, ...props }) {
  const Comp = asChild ? Slot : 'button';
  return <Comp className={cn(buttonVariants(), className)} {...props} />;
}

This single trick — Slot when asChild, else the tag — is exactly how shadcn’s Button supports asChild {Mẹo này — Slot khi asChild, ngược lại là thẻ — chính là cách Button của shadcn hỗ trợ asChild}.


3. Composition across primitives {Kết hợp qua các primitive}

All Radix primitives share the same mental model, so once you know one, you know them all {Mọi Radix primitive chung một mô hình tư duy, nên biết một là biết tất}.

Tabsvalue/onValueChange, items expose data-state="active" {Tabs}:

<Tabs.Root defaultValue="account">
  <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
                 data-[state=active]:bg-indigo-500 data-[state=active]:text-white">
      Account
    </Tabs.Trigger>
    <Tabs.Trigger value="password" className="…">Password</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="account">…</Tabs.Content>
  <Tabs.Content value="password">…</Tabs.Content>
</Tabs.Root>

Switchdata-state="checked"; style track and thumb {Switch}:

<Switch.Root className="h-6 w-11 rounded-full bg-input data-[state=checked]:bg-indigo-500">
  <Switch.Thumb className="block size-5 rounded-full bg-white transition-transform
                          data-[state=checked]:translate-x-5" />
</Switch.Root>

Tooltip — needs a single Tooltip.Provider near the app root; opens on hover and focus {Tooltip — cần một Tooltip.Provider gần gốc app; mở khi hover focus}:

<Tooltip.Provider delayDuration={200}>
  <Tooltip.Root>
    <Tooltip.Trigger asChild><Button>Save</Button></Tooltip.Trigger>
    <Tooltip.Portal>
      <Tooltip.Content className="rounded bg-surface px-2 py-1 text-xs
                                 data-[state=delayed-open]:animate-in">
        Saved 2 min ago
        <Tooltip.Arrow className="fill-surface" />
      </Tooltip.Content>
    </Tooltip.Portal>
  </Tooltip.Root>
</Tooltip.Provider>

Popover — like Dialog but non-modal; great for inline editors and pickers {Popover — như Dialog nhưng không modal; tốt cho editor và picker inline}. Same Root/Trigger/Portal/Content shape {Cùng hình Root/Trigger/Portal/Content}.


4. A reusable styling strategy {Chiến lược style tái sử dụng}

Writing the same data-[state=…] classes on every usage doesn’t scale {Viết lại cùng class data-[state=…] ở mỗi lần dùng không scale}. The pro move is to wrap each primitive once with your styles baked in, exposing a clean API {Nước đi pro là bọc mỗi primitive một lần với style đã nung sẵn, phơi ra API gọn}:

// components/ui/switch.tsx — wrap once, use everywhere
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 data-[state=checked]:bg-primary', className)}
      {...props}
    >
      <SwitchPrimitive.Thumb className="block size-5 rounded-full bg-white transition-transform data-[state=checked]:translate-x-5" />
    </SwitchPrimitive.Root>
  );
}

If this feels like a lot of boilerplate across dozens of components — that’s the exact problem shadcn/ui solves {Nếu thấy đây là nhiều boilerplate qua hàng chục component — đó chính xác là vấn đề shadcn/ui giải quyết}. shadcn is a curated set of these wrappers, pre-written and copied into your repo {shadcn là một bộ các wrapper này được tuyển chọn, viết sẵn và copy vào repo của bạn}. That’s Part 9 {Đó là Phần 9}.


5. Exercises {Bài tập}

1. When should a Radix component be controlled rather than uncontrolled? {Khi nào một component Radix nên controlled thay vì uncontrolled?}

Solution {Lời giải}

When you need to read or set its state from outside — open it programmatically, sync to a URL/store, or trigger side effects on change {Khi bạn cần đọc/đặt trạng thái từ bên ngoài — mở bằng code, đồng bộ URL/store, hay chạy side effect khi đổi}.

2. Why does asChild require the child to forward its ref? {Vì sao asChild yêu cầu con forward ref?}

Solution {Lời giải}

Radix needs a ref to the real DOM node to manage focus, positioning, and outside-click; without forwarding, it can’t reach the element and behavior breaks {Radix cần ref tới node DOM thật để quản lý focus, định vị, bấm-ngoài; không forward thì nó không chạm được phần tử và hành vi hỏng}.

3. Style a Tabs.Trigger so the active tab has an indigo background and white text {Style một Tabs.Trigger để tab active có nền indigo và chữ trắng}.

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

Stretch {Nâng cao}: in the lab, toggle the Switch and Popover and note both expose data-state — the same hook you’d target with data-[state=checked]: / data-[state=open]: {trong lab, bật Switch và Popover và để ý cả hai phơi data-state — cùng móc bạn nhắm bằng data-[state=checked]: / data-[state=open]:}.


Key takeaways {Điểm chính}

  • Uncontrolled by default; go controlled (open/onOpenChange…) only with a concrete need {Mặc định uncontrolled; dùng controlled chỉ khi có nhu cầu cụ thể}.
  • asChild (via Slot) merges Radix behavior onto your element — no wrapper DOM; the child must forward ref + spread props {asChild (qua Slot) ghép hành vi Radix vào phần tử của bạn — không DOM bọc; con phải forward ref + spread props}.
  • Every primitive shares the same value/onValueChange + data-state model — learn one, know all {Mọi primitive chung mô hình value/onValueChange + data-state — học một, biết tất}.
  • Wrapping each primitive once with baked-in styles is the scalable pattern — and exactly what shadcn productizes {Bọc mỗi primitive một lần với style nung sẵn là mẫu scale được — và đúng thứ shadcn sản phẩm hóa}.

Next up {Tiếp theo}

Part 9 — shadcn/ui: the philosophy (“not a component library”), the CLI, components.json, and adding your first components — where Tailwind + Radix + cva/cn finally click into one workflow {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}.