rilaykit
Core concepts

Component Registry

How to register your React components with Rilaykit.

Rilaykit is headless: you provide the components, Rilaykit provides the logic. The registry — a ril catalog — maps a type string to your renderer.

Registering a Component

Use .component(type, entry) on a ril instance. Each call returns a new instance (immutable, chainable). Registering an existing type throws DuplicateError unless the entry sets replace: true.

lib/rilay.ts
import { ril } from 'rilaykit';
import { TextInput } from '@/components/TextInput';
import { Select } from '@/components/Select';

export const r = ril
  .create()
  .component('text', {
    name: 'Text Input',
    renderer: TextInput,
  })
  .component('select', {
    name: 'Select',
    description: 'A dropdown for selecting options.',
    renderer: Select,
    defaultProps: { options: [] },
  });

Entry options

OptionDescription
renderer(ctx: ComponentRenderContext<TProps>) => React.ReactElement
nameHuman-readable name
descriptionPurpose of the component (also surfaced to agent manifests)
defaultPropsMerged under each field's props (field props win)
propsSchemaStandard Schema validating props — must validate synchronously
metaArbitrary metadata, passed through to the render context
replaceAllow overwriting an already-registered type

The same catalog also registers .tool() and .part() entries for agent UIs, and .renderers({ components: {...} }) attaches renderers to schema-only entries later — useful when the catalog is built server-side and renderers attach on the client.

The ComponentRenderContext

Your renderer receives a single context object. Form state lives under field (absent for static, non-field components):

interface ComponentRenderContext<TProps = Record<string, unknown>> {
  id: string; // unique field id
  props: TProps; // your custom props (defaultProps merged in)
  field?: FieldBinding; // form binding, when rendered as a field
  conditions?: FieldConditions; // required/disabled/readonly resolution
  children?: React.ReactNode;
  meta?: Record<string, unknown>;
}

interface FieldBinding {
  value: unknown;
  onChange: (value: unknown) => void;
  onBlur: () => void; // feeds validation timing (mode/reValidateMode)
  error?: FieldError[];
  disabled?: boolean;
  isValidating?: boolean;
  touched?: boolean;
}

Example: a TextInput renderer

components/TextInput.tsx
import type { ComponentRenderContext } from 'rilaykit';

interface TextInputProps {
  label: string;
  placeholder?: string;
  required?: boolean;
}

export function TextInput({ id, props, field }: ComponentRenderContext<TextInputProps>) {
  const showError = field?.touched && field.error?.length;

  return (
    <div className="form-control">
      <label htmlFor={id}>
        {props.label}
        {props.required && <span>*</span>}
      </label>
      <input
        id={id}
        type="text"
        value={(field?.value as string) ?? ''}
        onChange={(e) => field?.onChange(e.target.value)}
        onBlur={() => field?.onBlur()}
        placeholder={props.placeholder}
        disabled={field?.disabled}
        aria-invalid={!!showError}
      />
      {showError ? (
        <p role="alert" className="error-message">
          {field.error?.[0]?.message}
        </p>
      ) : null}
    </div>
  );
}

Gating on field.touched pairs with the default validation timing (mode: 'onTouched'): errors appear after first blur — or on submit, which marks errored fields touched — then clear live.

On this page