DEV Community

Cover image for Type-Safe Compound Components in 2026 – The Generic Factory Pattern I Actually Use
Sambalicious
Sambalicious

Posted on

Type-Safe Compound Components in 2026 – The Generic Factory Pattern I Actually Use

How I finally got real type safety on children values without losing the beautiful dot API

react-typescript

Compound components are awesome for building flexible UI primitives — think <Tabs>, <Accordion>, <RadioGroup>, or <Popover> — where the consumer controls the layout and composition.

But flexibility often meant zero type safety on children or values.

I used to write stuff like this:

<RadioGroup value={selected} onChange={setSelected}>
  <RadioItem value="draft">Draft</RadioItem>
  <RadioItem value="published">Published</RadioItem>
  <RadioItem value={123}>Wait what? {/* ← TypeScript is silent 😭 */}
</RadioItem>
</RadioGroup>
Enter fullscreen mode Exit fullscreen mode

I wanted TypeScript to actually yell at me when the value type was wrong, while keeping the clean dot-notation DX everyone loves:

<StatusRadio.RadioGroup value={status} onValueChange={setStatus}>
  <StatusRadio.RadioItem value="draft">...</StatusRadio.RadioItem>
  {/* value={123} → red squiggle! */}
</StatusRadio.RadioGroup>
Enter fullscreen mode Exit fullscreen mode

After reading TkDodo’s excellent post “Building Type-Safe Compound Components” (Jan 2, 2026), I extended the ideas into a tiny generic factory. It’s now my go-to for typed item groups (radios, tabs, segmented controls, steppers…).

Two Flavors of Compound Components

  1. Max layout freedom (Card/Dialog style) Free-form children, arbitrary order:
<Card>
  <CardHeader>Title</CardHeader>
  <CardContent>Anything goes</CardContent>
  <CardFooter>Actions</CardFooter>
</Card>
Enter fullscreen mode Exit fullscreen mode

→ Use simple context + sub-components. Super common in shadcn/ui, Radix, etc. (Not the focus here).

  1. Strict typed children (Radio/Tabs/Steps style) Predictable items + strong value typing — factory time!
type Status = "draft" | "published" | "archived";

const StatusRadio = createTypedRadioGroup<Status>();

<StatusRadio.RadioGroup value={status} onValueChange={setStatus}>
  <StatusRadio.RadioItem value="draft">
    <StatusRadio.RadioIndicator />
    Draft
  </StatusRadio.RadioItem>
  {/* value={123} → compile error */}
</StatusRadio.RadioGroup>
Enter fullscreen mode Exit fullscreen mode

The Reusable Factory Pattern

import { createContext, useContext } from "react";
import {
  RadioGroup as BaseRadioGroup,
  Radio,
  mergeProps,
} from "@base-ui/react";
import { radioGroupRootVariants, radioItemVariants, RadioIndicator } from "../ui/radio";

type RadioGroupContextValue = {
  size?: "sm" | "md" | "lg" | null;
  variant?: "default" | "outline" | null;
};

export function createTypedRadioGroup<T>() {
  const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);

  function TypedRadioGroup({
    size,
    variant,
    orientation,
    className,
    children,
    ...props
  }: Omit<React.ComponentProps<typeof BaseRadioGroup>, "children" | "className"> & {
    size?: "sm" | "md" | "lg";
    variant?: "default" | "outline";
    orientation?: "horizontal" | "vertical";
    className?: string;
    children: React.ReactNode;
  }) {
    const ctxValue = { size: size ?? null, variant: variant ?? null };

    return (
      <RadioGroupContext.Provider value={ctxValue}>
        <BaseRadioGroup
          className={radioGroupRootVariants({ size, orientation })}
          {...props}
        >
          {children}
        </BaseRadioGroup>
      </RadioGroupContext.Provider>
    );
  }

  function TypedRadioItem({
    value,
    size: itemSize,
    className,
    children,
    ...props
  }: { value: T; size?: "sm" | "md" | "lg" } & Omit<
    React.ComponentProps<typeof Radio.Root>,
    "value" | "children"
  >) {
    const context = useContext(RadioGroupContext);
    if (!context) {
      throw new Error("RadioItem must be used inside RadioGroup");
    }

    const finalSize = itemSize ?? context.size ?? "md";

    const merged = mergeProps({ className }, {
      className: radioItemVariants({ size: finalSize }),
    });

    // Adapt value to string/number as needed by your base library
    return (
      <Radio.Root value={String(value)} className={merged.className} {...props}>
        {children}
      </Radio.Root>
    );
  }

  return {
    RadioGroup: TypedRadioGroup,
    RadioItem: TypedRadioItem,
    RadioIndicator,
  } as const;
}
Enter fullscreen mode Exit fullscreen mode

Note: If your headless base (Radix/Ark/Base UI) supports generic values natively, drop the String(value) cast.

Real-World Examples:

  1. Status Picker (string literals)
type Status = "draft" | "published" | "archived";

const StatusRadio = createTypedRadioGroup<Status>();

export function StatusPicker() {
  const [status, setStatus] = useState<Status>("draft");

  return (
    <StatusRadio.RadioGroup
      value={status}
      onValueChange={setStatus}
      size="md"
      variant="default"
    >
      <StatusRadio.RadioItem value="draft">
        <StatusRadio.RadioIndicator />
        Draft
      </StatusRadio.RadioItem>
      <StatusRadio.RadioItem value="published">
        <StatusRadio.RadioIndicator />
        Published
      </StatusRadio.RadioItem>
      <StatusRadio.RadioItem value="archived">
        <StatusRadio.RadioIndicator />
        Archived
      </StatusRadio.RadioItem>
    </StatusRadio.RadioGroup>
  );
}
Enter fullscreen mode Exit fullscreen mode
  1. Star Rating (numbers)
const RatingRadio = createTypedRadioGroup<number>();

<RatingRadio.RadioGroup value={rating} onValueChange={setRating} orientation="horizontal">
  {[1, 2, 3, 4, 5].map((n) => (
    <RatingRadio.RadioItem key={n} value={n}>
      <RatingRadio.RadioIndicator />
      {n} ★
    </RatingRadio.RadioItem>
  ))}
</RatingRadio.RadioGroup>
Enter fullscreen mode Exit fullscreen mode
  1. Object-based (real-world use case)
interface Option {
  id: string;
  label: string;
  description: string;
}

const OptionRadio = createTypedRadioGroup<Option>();

export function OptionPicker() {
  const options: Option[] = [
    { id: "1", label: "Option 1", description: "First option" },
    { id: "2", label: "Option 2", description: "Second option" },
    { id: "3", label: "Option 3", description: "Third option" },
  ];

  const [selected, setSelected] = useState<Option>(options[0]);

  return (
    <OptionRadio.RadioGroup value={selected} onValueChange={setSelected}>
      {options.map((option) => (
        <OptionRadio.RadioItem key={option.id} value={option}>
          <OptionRadio.RadioIndicator />
          <div>
            <div className="font-medium">{option.label}</div>
            <div className="text-sm text-gray-500">{option.description}</div>
          </div>
        </OptionRadio.RadioItem>
      ))}
    </OptionRadio.RadioGroup>
  );
}
Enter fullscreen mode Exit fullscreen mode

Trade-offs – When to Use (or Skip) This
Use the factory when:

  • Strong value type safety is non-negotiable
  • Children order is predictable (list/group items)
  • You want that sweet dot-notation DX

Skip it (use plain context + sub-components) when:

  • 1. You need a completely free-form layout/order
  • 2. Building low-level primitives (just use Radix/Ark/Base UI directly)

Final Thoughts
This little factory has been my default for typed groups since 2026 simple, type-safe, minimal boilerplate.
Ideas to level it up:

Propagate disabled from group → items

  • Add defaultValue support
  • Generalize for <Tabs>, <Accordion>, <RadioGroup> etc.
  • Package it as a tiny npm lib

Steal it, tweak it, ship with it.
What’s your go-to pattern for typed compound components in 2026? Drop a comment — or hit me up on X: @sambalicious_ 🚀

Top comments (0)