Component System

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

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:

CategoryComponents
InputsInput, Textarea, Button, Label
Checkbox & SwitchCheckbox, Switch
RadioRadioGroup, RadioGroupItem
SelectSelect, SelectTrigger, SelectContent, SelectItem, SelectValue
Native SelectNativeSelect
SliderSlider
PopoverPopover, PopoverTrigger, PopoverContent
TooltipTooltip, TooltipTrigger, TooltipContent, TooltipProvider
SeparatorSeparator
DialogDialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription
CommandCommand, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem
Input OTPInputOTP, InputOTPGroup, InputOTPSlot
AccordionAccordion, AccordionItem, AccordionTrigger, AccordionContent
CalendarCalendar
FormFormField, FormControl
FieldField, 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