Component System

How @saastro/forms discovers and injects UI components — zero-config, providers, auto-discovery, and utilities.

View as Markdown

Component System

@saastro/forms is UI-agnostic. It doesn’t bundle a UI kit — you provide the primitives (inputs, buttons, selects, …), typically from shadcn/ui. This page explains the three ways to inject components, how auto-discovery works, and utilities for detecting missing components.


Three Modes

Pass components directly to the <Form> component. Only provide the components your form actually needs.

import { Form } from '@saastro/forms';
import { Input, Button, Label, Checkbox } from '@/components/ui';
import { Field, FieldLabel, FieldDescription, FieldError } from '@/components/ui/field';
import { FormField, FormControl } from '@/components/ui/form';

<Form
  config={config}
  components={{
    Input,
    Button,
    Label,
    Checkbox,
    Field,
    FieldLabel,
    FieldDescription,
    FieldError,
    FormField,
    FormControl,
  }}
/>;

This is the simplest approach. No setup, no context providers. If a component is missing, you’ll see a helpful warning with install instructions. (Submission is configured on the form config itself — see the Submit guide.)

2. Glob Auto-Discovery (Vite projects)

Use FormComponentsProvider with Vite’s import.meta.glob to automatically discover all your shadcn components. Set it up once in your root layout:

import { FormComponentsProvider } from '@saastro/forms';

export default function RootLayout({ children }) {
  return (
    <FormComponentsProvider components={import.meta.glob('@/components/ui/*.tsx', { eager: true })}>
      {children}
    </FormComponentsProvider>
  );
}

Then use <Form> anywhere without passing components:

<Form config={config} />

The provider extracts all named exports from your components/ui/ directory and builds a registry automatically. Named exports are registered under their export name; default exports are registered under the PascalCased file name (radio-group.tsx becomes RadioGroup).

3. Legacy Provider

For apps with many forms, wrap your tree with ComponentProvider and a full registry:

import { ComponentProvider, createComponentRegistry } from '@saastro/forms';
import * as ui from '@/lib/form-components';

const registry = createComponentRegistry(ui);

<ComponentProvider components={registry}>
  <App />
</ComponentProvider>;

createComponentRegistry() accepts an object with all component exports and returns a typed ComponentRegistry.

The legacy provider is marked @deprecated in favor of zero-config. It still works and will continue to work.


How Resolution Works

When a field renders, the system calls useComponents() to get the registry. Resolution order:

  1. Legacy mode — If ComponentProvider exists in the tree, use its full registry
  2. Zero-config mode — If <Form components={...}> or FormComponentsProvider exists, use that partial registry
  3. Error — If neither exists, throw with a helpful message showing both setup options

The zero-config and legacy providers can coexist. Legacy takes precedence for backward compatibility.


ComponentRegistry

The full registry has 49 component slots organized by category:

Category Components
Inputs Input, Textarea, Button, Label
Checkbox & Switch Checkbox, Switch
Radio RadioGroup, RadioGroupItem
Select Select, SelectTrigger, SelectContent, SelectItem, SelectValue
Native Select NativeSelect
Slider Slider
Popover Popover, PopoverTrigger, PopoverContent
Tooltip Tooltip, TooltipTrigger, TooltipContent, TooltipProvider
Separator Separator
Dialog Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription
Command Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem
Input OTP InputOTP, InputOTPGroup, InputOTPSlot
Accordion Accordion, AccordionItem, AccordionTrigger, AccordionContent
Calendar Calendar
Form FormField, FormControl
Field Field, FieldLabel, FieldDescription, FieldError

You don’t need all 49. Only provide the components your form’s field types require. Five of them are needed by every form — the package exports them as coreComponents:

import { coreComponents } from '@saastro/forms';
// ['Button', 'Field', 'FieldLabel', 'FieldDescription', 'FieldError']

Use getRequiredComponents() to find out exactly which components a given form config needs (it always includes the five core ones).


Missing Component Handling

When a field tries to render but a required component isn’t in the registry, it shows a MissingComponentFallback — a styled warning with:

  • The field name and type
  • The list of missing components
  • A npx shadcn@latest add ... command to install them
import { MissingComponentFallback } from '@saastro/forms';

// Or create placeholder components (component name + field type):
import { createMissingComponentPlaceholder } from '@saastro/forms';
const PlaceholderInput = createMissingComponentPlaceholder('Input', 'text');

Hooks

Four hooks expose the component registry. All are importable from @saastro/forms and follow the same resolution order described above (legacy registry first, then zero-config).

useComponents()

function useComponents(): ComponentRegistry;

Returns the active registry. Throws when no provider is found — the error message explains both setup options (zero-config components prop vs. ComponentProvider). Use it in components rendered inside a <Form> (or under a provider), such as custom field renderers:

import { useComponents } from '@saastro/forms';

function MyCustomField() {
  const { Input, Button } = useComponents();
  return <Input placeholder="Custom field" />;
}

Note that the return type is the full ComponentRegistry even in zero-config mode, where only a partial registry was provided — components your form doesn’t use may still be missing at runtime.

usePartialComponents()

function usePartialComponents(): PartialComponentRegistry;

Same resolution as useComponents(), but never throws — it returns an empty object {} when no provider exists. Every slot is optional, so check before rendering:

import { usePartialComponents } from '@saastro/forms';

function OptionalTooltip({ children }) {
  const { Tooltip } = usePartialComponents();
  if (!Tooltip) return children;
  return <Tooltip>{children}</Tooltip>;
}

useHasComponentProvider()

function useHasComponentProvider(): boolean;

Returns true if any component provider (legacy or zero-config) is present in the tree. Useful for rendering a setup hint instead of crashing.

useComponentMode()

function useComponentMode(): 'legacy' | 'zero-config' | 'none';

Reports which injection mode is active: 'legacy' when a ComponentProvider registry is present, 'zero-config' when components come from <Form components={...}> or FormComponentsProvider, and 'none' when there is no provider at all.


Utility Functions

mergeComponentRegistries()

Merge multiple partial registries. Later registries override earlier ones.

import { mergeComponentRegistries } from '@saastro/forms';

const merged = mergeComponentRegistries(baseRegistry, overrides);

parseGlobModules()

Convert Vite’s import.meta.glob result into a component registry. Used internally by FormComponentsProvider.

import { parseGlobModules } from '@saastro/forms';

const modules = import.meta.glob('@/components/ui/*.tsx', { eager: true });
const registry = parseGlobModules(modules);

withComponents()

Higher-order component that injects a components prop (the full registry from useComponents()) into the wrapped component. Like useComponents(), it throws when no provider exists.

import { withComponents } from '@saastro/forms';

const FieldWithComponents = withComponents(MyField);
// MyField receives `components: ComponentRegistry` as a prop

ComponentResolver

Singleton for async component resolution with caching. Useful for advanced setups with code-split components. It only resolves components you pre-cache or register an importer for — resolve() returns null for anything else.

import { getComponentResolver, configureComponents } from '@saastro/forms';

// Pre-cache components you've already imported:
configureComponents({ Button, Input, Label });

// Or register lazy importers for code splitting:
const resolver = getComponentResolver();
resolver.registerImporter('Calendar', () =>
  import('@/components/ui/calendar').then((m) => m.Calendar),
);

const button = await resolver.resolve('Button'); // ComponentType | null

Base UI: two things that bite

The primitives you inject are yours, so most of this page is provider-agnostic. Two details are specific to Base UI and cost real debugging time, so they’re written down.

Select must forward items

Base UI resolves the trigger’s text from the items prop on Select.Root — it does not inherit the chosen SelectItem’s content the way Radix did. @saastro/forms passes items for you (since 0.20.1), but your wrapper has to forward it. The shadcn default does, because it spreads:

// ✅ items reaches Select.Root
const Select = SelectPrimitive.Root;
// ✅ also fine — props are spread through
function Select(props: SelectPrimitive.Root.Props) {
  return <SelectPrimitive.Root data-slot="select" {...props} />;
}

// ❌ items is silently dropped → the trigger shows the raw value ("otro")
function Select({ value, onValueChange, disabled, children }) {
  return <SelectPrimitive.Root value={value} onValueChange={onValueChange} disabled={disabled}>{children}</SelectPrimitive.Root>;
}

The symptom is unmistakable: pick “Otro seguro o consulta” and the trigger reads otro. If you see that, your Select wrapper is swallowing props.

render={<a />} does not work inside .astro files

Base UI’s render prop takes a React element. Inside a .astro file the braces are not React JSX — Astro compiles its own element and React receives undefined, so the component renders nothing useful.

---
import { buttonVariants } from '@/components/ui/button';
---
<!-- ❌ en .astro: Astro compila su propio elemento, React recibe undefined -->
<Button render={<a href="/precios" />}>Ver precios</Button>

<!-- ✅ en .astro: clases directas sobre el elemento nativo -->
<a href="/precios" class={buttonVariants({ variant: 'default' })}>Ver precios</a>

Inside .tsx islands render={<a />} works normally. This only matters in .astro templates — which is exactly where sites built on the SAASTRO theme put their buttons, so it comes up often.