# AI & Skills import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; AI & Skills [#ai--skills] RilayKit is designed to be AI-friendly. Use the documentation directly with AI agents, or install the RilayKit skill for context-aware assistance. LLM-Ready Documentation [#llm-ready-documentation] The full RilayKit documentation is available in machine-readable formats for AI agents: | Endpoint | Description | | ---------------------------------- | ----------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | Lightweight index of all pages with titles and descriptions | | [`/llms-full.txt`](/llms-full.txt) | Complete documentation content in plain markdown | Use these endpoints to feed RilayKit knowledge into any AI tool that supports `llms.txt`. Install the RilayKit Skill [#install-the-rilaykit-skill] The RilayKit skill gives AI coding agents deep knowledge of the framework: builder patterns, validation, conditions, hooks, and real-world patterns. ```bash npx skills add andyoucreate/rilaykit -a claude-code ``` ```bash npx skills add andyoucreate/rilaykit --all ``` ```bash npx skills add andyoucreate/rilaykit -g --all ``` The skill uses **progressive disclosure** — only the relevant reference is loaded into context when needed, keeping the AI's context window efficient. What's Included [#whats-included] The skill covers the entire RilayKit API across three reference files:

Core

ril instance, component registry, Standard Schema validation, conditions with `when()` builder

Forms

Form builder API, row layouts, components, field hooks, form hooks, condition hooks

Workflow

Flow builder, navigation, step data helpers, persistence, analytics, real-world patterns

Supported Agents [#supported-agents] The skill works with any agent compatible with the [skills.sh](https://skills.sh) ecosystem, including: * **Claude Code** — Anthropic's CLI agent * **Cursor** — AI-powered code editor * **Windsurf** — Codeium's AI IDE * **OpenCode** — Open-source coding agent * And [40+ more agents](https://skills.sh) Verify Installation [#verify-installation] After installing, ask your AI agent: ``` How do I create a multi-step workflow with conditional steps using RilayKit? ``` The agent should reference the `@rilaykit/workflow` builder API with `when()` conditions — not generic form library patterns. # API Reference import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; API Reference [#api-reference] Complete API documentation for all RilayKit packages. *** @rilaykit/core [#rilaykitcore] The core package provides the component registry, validation system, conditional logic, and error classes shared across all RilayKit packages. ril [#ril] The central configuration class. All RilayKit functionality starts with a `ril` instance. Every mutation method returns a **new instance** (immutable API). ril.create() [#rilcreate] Creates a new RilayKit configuration instance. ```ts import { ril } from '@rilaykit/core'; const rilay = ril.create(); ``` **Returns**: `ril>` .addComponent(type, config) [#addcomponenttype-config] Registers a component renderer. Returns a new instance with the component added. ```ts const rilay = ril.create() .addComponent('input', { name: 'Text Input', renderer: InputRenderer, defaultProps: { placeholder: 'Enter text...' }, }) .addComponent('email', { name: 'Email Input', renderer: EmailRenderer, validation: { validate: email(), validateOnBlur: true, }, }); ``` **Parameters**: * `type: string` -- Unique component type identifier * `config: Omit, 'id' | 'type'>` -- Component configuration **Returns**: `ril` (new instance) .configure(config) [#configureconfig] Configures form and workflow renderers via a single method. Returns a new instance. ```ts const rilay = ril.create() .addComponent('input', { name: 'Input', renderer: InputRenderer }) .configure({ rowRenderer: CustomRowRenderer, bodyRenderer: CustomBodyRenderer, submitButtonRenderer: CustomSubmitButton, fieldRenderer: CustomFieldRenderer, stepperRenderer: CustomStepper, nextButtonRenderer: CustomNextButton, previousButtonRenderer: CustomPreviousButton, skipButtonRenderer: CustomSkipButton, }); ``` **Accepted keys**: | Form renderers | Workflow renderers | | ---------------------- | ------------------------ | | `rowRenderer` | `stepperRenderer` | | `bodyRenderer` | `nextButtonRenderer` | | `submitButtonRenderer` | `previousButtonRenderer` | | `fieldRenderer` | `skipButtonRenderer` | **Returns**: `ril` (new instance) .getComponent(id) [#getcomponentid] Retrieves a registered component configuration. ```ts const config = rilay.getComponent('input'); ``` **Returns**: `ComponentConfig | undefined` .getAllComponents() [#getallcomponents] Returns all registered component configurations. **Returns**: `ComponentConfig[]` .hasComponent(id) [#hascomponentid] Checks whether a component is registered. **Returns**: `boolean` .removeComponent(id) [#removecomponentid] Returns a new instance without the specified component. **Returns**: `ril` (new instance) .clear() [#clear] Returns a new instance with all components removed. Render configurations are preserved. **Returns**: `ril` (new instance) .clone() [#clone] Creates a deep copy of the current instance. **Returns**: `ril` (new instance) .getFormRenderConfig() [#getformrenderconfig] Returns the current form render configuration. **Returns**: `FormRenderConfig` .getWorkflowRenderConfig() [#getworkflowrenderconfig] Returns the current workflow render configuration. **Returns**: `WorkflowRenderConfig` .validate() [#validate] Synchronously validates the configuration (duplicate IDs, missing renderers, invalid keys). **Returns**: `string[]` -- Array of error messages (empty if valid) .validateAsync() [#validateasync] Asynchronous validation with structured error handling. Throws `ValidationError` if invalid. **Returns**: `Promise` ```ts interface AsyncValidationResult { isValid: boolean; errors: string[]; warnings?: string[]; } ``` .getStats() [#getstats] Returns statistics about the current configuration. ```ts const stats = rilay.getStats(); // { // total: number; // byType: Record; // hasCustomRenderers: { // row: boolean; // body: boolean; // submitButton: boolean; // field: boolean; // stepper: boolean; // workflowNextButton: boolean; // workflowPreviousButton: boolean; // workflowSkipButton: boolean; // }; // } ``` *** ComponentRenderProps [#componentrenderprops] Props passed to every component renderer function. ```ts interface ComponentRenderProps { id: string; props: T; value?: any; onChange?: (value: any) => void; onBlur?: () => void; disabled?: boolean; error?: ValidationError[]; isValidating?: boolean; [key: string]: any; } type ComponentRenderer = ( props: ComponentRenderProps ) => React.ReactElement; ``` *** Validators [#validators] All built-in validators implement the [Standard Schema](https://standardschema.dev) interface (`StandardSchemaV1`). They can be used alongside Zod, Yup, or any other Standard Schema-compatible library. ```ts import { required, email, url, minLength, maxLength, pattern, number, min, max, custom, async, combine, } from '@rilaykit/core'; ``` | Validator | Signature | Description | | ----------- | ------------------------ | --------------------------------------------------------------------- | | `required` | `required(msg?)` | Field must have a value | | `email` | `email(msg?)` | Valid email format | | `url` | `url(msg?)` | Valid URL | | `minLength` | `minLength(min, msg?)` | Minimum string length | | `maxLength` | `maxLength(max, msg?)` | Maximum string length | | `pattern` | `pattern(regex, msg?)` | Matches regular expression | | `number` | `number(msg?)` | Must be a valid number | | `min` | `min(val, msg?)` | Minimum numeric value | | `max` | `max(val, msg?)` | Maximum numeric value | | `custom` | `custom(fn, msg?)` | Synchronous custom validation (`fn: (value: T) => boolean`) | | `async` | `async(fn, msg?)` | Asynchronous custom validation (`fn: (value: T) => Promise`) | | `combine` | `combine(...schemas)` | Combines multiple `StandardSchemaV1` schemas into one | All validators return `StandardSchemaV1`, so you can mix them freely with Zod, Yup, or any compatible library. *** Conditional Logic [#conditional-logic] when(field) [#whenfield] Creates a `ConditionBuilder` for declaring conditional behaviors on fields or steps. ```ts import { when } from '@rilaykit/core'; const condition = when('accountType').equals('business'); ``` **Returns**: `ConditionBuilder` ConditionBuilder Methods [#conditionbuilder-methods] **Comparison operators**: | Method | Signature | Description | | ----------------------- | ------------------------------------- | ----------------------------- | | `.equals()` | `.equals(value: ConditionValue)` | Strict equality | | `.notEquals()` | `.notEquals(value: ConditionValue)` | Strict inequality | | `.greaterThan()` | `.greaterThan(value: number)` | Numeric greater than | | `.lessThan()` | `.lessThan(value: number)` | Numeric less than | | `.greaterThanOrEqual()` | `.greaterThanOrEqual(value: number)` | Numeric greater than or equal | | `.lessThanOrEqual()` | `.lessThanOrEqual(value: number)` | Numeric less than or equal | | `.contains()` | `.contains(value: string)` | String/array contains | | `.notContains()` | `.notContains(value: string)` | String/array does not contain | | `.in()` | `.in(values: Array)` | Value is in array | | `.notIn()` | `.notIn(values: Array)` | Value is not in array | | `.matches()` | `.matches(pattern: string \| RegExp)` | Matches regex pattern | | `.exists()` | `.exists()` | Value is not null/undefined | | `.notExists()` | `.notExists()` | Value is null/undefined | **Logical combinators**: | Method | Signature | Description | | -------- | ------------------------------------------------------ | ----------- | | `.and()` | `.and(condition: ConditionBuilder \| ConditionConfig)` | Logical AND | | `.or()` | `.or(condition: ConditionBuilder \| ConditionConfig)` | Logical OR | **Terminal methods**: | Method | Signature | Description | | ------------- | -------------------------------------- | ------------------------------------------ | | `.build()` | `.build()` | Returns the serializable `ConditionConfig` | | `.evaluate()` | `.evaluate(data: Record)` | Evaluates the condition against data | ```ts // Boolean check when('isVerified').equals(true) // Combining conditions when('role').equals('admin') .or(when('permissions').contains('write')) // Nested field paths when('address.country').equals('FR') ``` *** Error Classes [#error-classes] ```ts import { RilayError, ValidationError, DuplicateIdError } from '@rilaykit/core'; ``` | Class | Code | Description | | ------------------ | -------------------- | -------------------------------------------------------------------------- | | `RilayError` | Custom `code` | Base error class. Properties: `code: string`, `meta?: Record` | | `ValidationError` | `VALIDATION_ERROR` | Thrown on validation failures | | `DuplicateIdError` | `DUPLICATE_ID_ERROR` | Thrown on duplicate ID registration | *** @rilaykit/forms [#rilaykitforms] The forms package provides components, a builder, and granular Zustand-backed hooks for building type-safe forms. Components [#components] Form [#form] Top-level form wrapper. Accepts either a built `FormConfiguration` or a `form` builder (auto-builds internally). ```tsx import { Form } from '@rilaykit/forms';
{}} className="my-form" > {children}
``` | Prop | Type | Description | | ---------------- | ---------------------------------------------------------------------- | -------------------------------------- | | `formConfig` | `FormConfiguration \| form` | Form configuration or builder instance | | `defaultValues?` | `Record` | Initial form values | | `onSubmit?` | `(data: Record) => void \| Promise` | Submission handler | | `onFieldChange?` | `(fieldId: string, value: any, formData: Record) => void` | Field change callback | | `className?` | `string` | CSS class for the `
` element | | `children` | `React.ReactNode` | Form content | FormProvider [#formprovider] Lower-level provider that wraps the form context. `Form` delegates to this internally. | Prop | Type | Description | | ---------------- | ------------------------------------------------------------------------------ | ------------------------ | | `formConfig` | `FormConfiguration` | Built form configuration | | `defaultValues?` | `Record` | Initial form values | | `onSubmit?` | `(data: Record) => void \| Promise` | Submission handler | | `onFieldChange?` | `(fieldId: string, value: unknown, formData: Record) => void` | Field change callback | | `className?` | `string` | CSS class | | `children` | `React.ReactNode` | Children | FormBody [#formbody] Renders all form rows automatically from the form configuration. ```tsx ``` | Prop | Type | Description | | ------------ | -------- | ----------- | | `className?` | `string` | CSS class | FormField [#formfield] Renders a single form field by ID. ```tsx ``` | Prop | Type | Description | | --------------- | ------------------------- | ------------------------------------------------------- | | `fieldId` | `string` | Field ID (must match a field in the form configuration) | | `disabled?` | `boolean` | Force disabled state | | `customProps?` | `Record` | Additional props merged into the component | | `className?` | `string` | CSS class for the field wrapper | | `forceVisible?` | `boolean` | Override condition-based visibility | FormRow [#formrow] Renders a horizontal row of form fields. | Prop | Type | Description | | ------------ | -------------- | ------------------------ | | `row` | `FormFieldRow` | Row configuration object | | `className?` | `string` | CSS class | FormSubmitButton [#formsubmitbutton] Submit button with automatic loading state from the form store. | Prop | Type | Description | | --------------- | --------- | -------------------------------------- | | `isSubmitting?` | `boolean` | Override the computed submitting state | | `className?` | `string` | CSS class | *** form (FormBuilder) [#form-formbuilder] Builder class for creating type-safe form configurations. Creating a form builder [#creating-a-form-builder] ```ts import { form } from '@rilaykit/forms'; const myForm = form.create(rilConfig, 'contact-form'); ``` ```ts import { form } from '@rilaykit/forms'; const myForm = new form(rilConfig, 'contact-form'); ``` **Parameters**: * `config: ril` -- The ril configuration containing component definitions * `formId?: string` -- Optional unique form identifier (auto-generated if omitted) .add(...fields) / .add([fields]) [#addfields--addfields] Adds fields to the form. Supports multiple calling patterns: ```ts // Single field -- own row builder.add({ id: 'name', type: 'input', props: { label: 'Name' } }); // Multiple fields (max 3) -- same row builder.add( { id: 'firstName', type: 'input', props: { label: 'First' } }, { id: 'lastName', type: 'input', props: { label: 'Last' } }, ); // Array syntax -- explicit single row builder.add([ { id: 'email', type: 'email', props: { label: 'Email' } }, { id: 'phone', type: 'phone', props: { label: 'Phone' } }, ]); // More than 3 fields (variadic) -- auto-split into separate rows builder.add(field1, field2, field3, field4); ``` **Returns**: `this` (chainable) .addSeparateRows(fields) [#addseparaterowsfields] Adds an array of fields, each on its own row. ```ts builder.addSeparateRows([ { type: 'input', props: { label: 'Field 1' } }, { type: 'input', props: { label: 'Field 2' } }, ]); ``` **Returns**: `this` (chainable) .setId(id) [#setidid] Sets or overrides the form identifier. **Returns**: `this` (chainable) .setValidation(config) [#setvalidationconfig] Sets form-level validation configuration. ```ts builder.setValidation({ validate: z.object({ password: z.string().min(8), confirmPassword: z.string(), }).refine(data => data.password === data.confirmPassword, { message: "Passwords don't match", path: ['confirmPassword'], }), validateOnSubmit: true, }); ``` **Parameters**: * `config: FormValidationConfig` -- Form-level validation settings **Returns**: `this` (chainable) .addFieldConditions(fieldId, conditions) [#addfieldconditionsfieldid-conditions] Adds conditional behavior to a field after creation. ```ts builder.addFieldConditions('phone', { visible: when('contactMethod').equals('phone').build(), required: when('contactMethod').equals('phone').build(), }); ``` **Parameters**: * `fieldId: string` -- Field to add conditions to * `conditions: ConditionalBehavior` -- Condition configuration (`visible?`, `disabled?`, `required?`, `readonly?`) **Returns**: `this` (chainable) .updateField(fieldId, updates) [#updatefieldfieldid-updates] Updates an existing field's configuration. ```ts builder.updateField('email', { props: { placeholder: 'Enter your email' }, }); ``` **Returns**: `this` (chainable) .removeField(fieldId) [#removefieldfieldid] Removes a field and cleans up empty rows. **Returns**: `this` (chainable) .getField(fieldId) [#getfieldfieldid] Retrieves a field configuration by ID. **Returns**: `FormFieldConfig | undefined` .getFields() [#getfields] Returns all fields as a flat array. **Returns**: `FormFieldConfig[]` .getRows() [#getrows] Returns a copy of all form rows. **Returns**: `FormFieldRow[]` .clear() [#clear-1] Removes all fields and rows, resets the ID generator. **Returns**: `this` (chainable) .clone(newId?) [#clonenewid] Creates a deep copy of the form builder. **Returns**: `form` (new instance) .validate() [#validate-1] Checks for structural issues (duplicate IDs, missing components, row constraints). **Returns**: `string[]` .build() [#build] Builds the final `FormConfiguration`. Throws if validation fails. **Returns**: `FormConfiguration` .toJSON() / .fromJSON(json) [#tojson--fromjsonjson] Serialization and deserialization of the form structure. .getStats() [#getstats-1] Returns form statistics. ```ts const stats = builder.getStats(); // { totalFields, totalRows, averageFieldsPerRow, maxFieldsInRow, minFieldsInRow } ``` *** Hooks [#hooks] All form hooks are backed by a Zustand store for granular re-renders. Field-level hooks re-render only when their specific field's data changes, not on every form update. Field Hooks [#field-hooks] | Hook | Signature | Returns | | ------------------------- | ------------------------------------------ | ----------------------- | | `useFieldValue` | `useFieldValue(fieldId: string)` | `T` | | `useFieldErrors` | `useFieldErrors(fieldId: string)` | `ValidationError[]` | | `useFieldTouched` | `useFieldTouched(fieldId: string)` | `boolean` | | `useFieldValidationState` | `useFieldValidationState(fieldId: string)` | `ValidationState` | | `useFieldConditions` | `useFieldConditions(fieldId: string)` | `FieldConditions` | | `useFieldState` | `useFieldState(fieldId: string)` | `FieldState` | | `useFieldActions` | `useFieldActions(fieldId: string)` | `UseFieldActionsResult` | ```ts interface FieldState { value: unknown; errors: ValidationError[]; validationState: ValidationState; // 'idle' | 'validating' | 'valid' | 'invalid' touched: boolean; dirty: boolean; } interface FieldConditions { visible: boolean; disabled: boolean; required: boolean; readonly: boolean; } interface UseFieldActionsResult { setValue: (value: unknown) => void; setTouched: () => void; setErrors: (errors: ValidationError[]) => void; clearErrors: () => void; setValidationState: (state: ValidationState) => void; } ``` Form Hooks [#form-hooks] | Hook | Signature | Returns | | -------------------- | ---------------------- | ------------------------------------ | | `useFormSubmitting` | `useFormSubmitting()` | `boolean` | | `useFormValid` | `useFormValid()` | `boolean` | | `useFormDirty` | `useFormDirty()` | `boolean` | | `useFormValues` | `useFormValues()` | `Record` | | `useFormSubmitState` | `useFormSubmitState()` | `{ isSubmitting, isValid, isDirty }` | | `useFormActions` | `useFormActions()` | `UseFormActionsResult` | ```ts interface UseFormActionsResult { setValue: (fieldId: string, value: unknown) => void; setTouched: (fieldId: string) => void; setErrors: (fieldId: string, errors: ValidationError[]) => void; setSubmitting: (isSubmitting: boolean) => void; reset: (values?: Record) => void; setFieldConditions: (fieldId: string, conditions: FieldConditions) => void; } ``` Context Hook [#context-hook] | Hook | Signature | Returns | | ---------------------- | ------------------------ | ------------------------ | | `useFormConfigContext` | `useFormConfigContext()` | `FormConfigContextValue` | ```ts interface FormConfigContextValue { formConfig: FormConfiguration; conditionsHelpers: { hasConditionalFields, getFieldCondition, isFieldVisible, isFieldDisabled, isFieldRequired, isFieldReadonly }; validateField: (fieldId: string, value?: unknown) => Promise; validateForm: () => Promise; submit: (event?: React.FormEvent) => Promise; } ``` *** @rilaykit/workflow [#rilaykitworkflow] The workflow package provides components, a builder, and hooks for building multi-step workflows. Components [#components-1] Workflow [#workflow] Top-level workflow wrapper. Accepts either a built `WorkflowConfig` or a `flow` builder (auto-builds internally). ```tsx import { Workflow, WorkflowBody, WorkflowStepper, WorkflowNextButton } from '@rilaykit/workflow'; {}} onWorkflowComplete={(data) => {}} className="my-workflow" > ``` | Prop | Type | Description | | --------------------- | -------------------------------------------------------------- | ------------------------------------------ | | `workflowConfig` | `WorkflowConfig \| flow` | Workflow configuration or builder instance | | `children` | `React.ReactNode` | Workflow content | | `defaultValues?` | `Record` | Initial data | | `defaultStep?` | `string` | Step ID to start on | | `onStepChange?` | `(from: number, to: number, context: WorkflowContext) => void` | Step change callback | | `onWorkflowComplete?` | `(data: Record) => void \| Promise` | Completion callback | | `className?` | `string` | CSS class | WorkflowBody [#workflowbody] Renders the current step's form content. | Prop | Type | Description | | ----------- | ----------------- | ----------------------------------------- | | `stepId?` | `string` | Only render if this is the current step | | `children?` | `React.ReactNode` | Custom content (falls back to `FormBody`) | WorkflowStepper [#workflowstepper] Renders the step progress indicator. | Prop | Type | Description | | -------------- | ----------------------------- | ------------------------- | | `onStepClick?` | `(stepIndex: number) => void` | Custom step click handler | | `className?` | `string` | CSS class | WorkflowNextButton [#workflownextbutton] Next/submit button with automatic state management. | Prop | Type | Description | | --------------- | --------- | ------------------------- | | `isSubmitting?` | `boolean` | Override submitting state | | `className?` | `string` | CSS class | WorkflowPreviousButton [#workflowpreviousbutton] Previous button with automatic state management. | Prop | Type | Description | | --------------- | --------- | ------------------------- | | `isSubmitting?` | `boolean` | Override submitting state | | `className?` | `string` | CSS class | WorkflowSkipButton [#workflowskipbutton] Skip button, only active when the current step allows skipping. | Prop | Type | Description | | --------------- | --------- | ------------------------- | | `isSubmitting?` | `boolean` | Override submitting state | | `className?` | `string` | CSS class | *** flow (FlowBuilder) [#flow-flowbuilder] Builder class for creating multi-step workflow configurations. Creating a flow builder [#creating-a-flow-builder] ```ts import { flow } from '@rilaykit/workflow'; const myWorkflow = flow.create(rilConfig, 'onboarding', 'User Onboarding', 'Optional description'); ``` ```ts const myWorkflow = new flow(rilConfig, 'onboarding', 'User Onboarding'); ``` **Parameters**: * `config: ril` -- The ril configuration instance * `workflowId: string` -- Unique workflow identifier * `workflowName: string` -- Display name * `description?: string` -- Optional description .step(stepDef) / .step([stepDefs]) [#stepstepdef--stepstepdefs] Adds one or multiple steps to the workflow. ```ts workflow .step({ id: 'personal-info', title: 'Personal Information', formConfig: personalForm, allowSkip: false, metadata: { icon: 'user' }, onAfterValidation: async (stepData, helper, context) => { const company = await fetchCompany(stepData.siren); helper.setNextStepFields({ companyName: company.name }); }, }) .step([ { title: 'Step 2', formConfig: form2 }, { title: 'Step 3', formConfig: form3, allowSkip: true }, ]); ``` **StepDefinition**: ```ts interface StepDefinition { id?: string; // Auto-generated if omitted title: string; description?: string; formConfig: FormConfiguration | form; // Builder is auto-built allowSkip?: boolean; conditions?: StepConditionalBehavior; metadata?: Record; onAfterValidation?: ( stepData: Record, helper: StepDataHelper, context: WorkflowContext, ) => void | Promise; } ``` The step lifecycle callback is `onAfterValidation`, not `onEnter` or `onExit`. It fires after successful validation and before navigating to the next step. **Returns**: `this` (chainable) .configure(options) [#configureoptions] Configures workflow-level options. ```ts workflow.configure({ analytics: { onWorkflowStart: (id, context) => {}, onStepComplete: (id, duration, data, context) => {}, }, persistence: { adapter: localStorageAdapter, options: { autoPersist: true }, userId: 'user-123', }, }); ``` **Parameters**: * `options: { analytics?: WorkflowAnalytics, persistence?: { adapter, options?, userId? } }` **Returns**: `this` (chainable) .use(plugin) [#useplugin] Installs a workflow plugin. Validates plugin dependencies before installation. ```ts workflow.use(myPlugin); ``` **Returns**: `this` (chainable) .removePlugin(name) [#removepluginname] Removes a plugin by name. **Returns**: `this` (chainable) .updateStep(id, updates) [#updatestepid-updates] Updates an existing step configuration. **Returns**: `this` (chainable) .stepConditions(id, conditions) [#stepconditionsid-conditions] Adds conditional behavior to a step after creation. ```ts workflow.stepConditions('payment', { visible: when('hasPayment').equals(true).build(), skippable: when('balance').equals(0).build(), }); ``` **Returns**: `this` (chainable) .removeStep(id) [#removestepid] Removes a step from the workflow. **Returns**: `this` (chainable) .getStep(id) [#getstepid] Retrieves a step configuration by ID. **Returns**: `StepConfig | undefined` .getSteps() [#getsteps] Returns a copy of all step configurations. **Returns**: `StepConfig[]` .clearSteps() [#clearsteps] Removes all steps and resets the ID generator. **Returns**: `this` (chainable) .clone(newId?, newName?) [#clonenewid-newname] Creates a deep copy of the workflow builder. **Returns**: `flow` (new instance) .validate() [#validate-2] Checks for structural issues (empty workflow, duplicate IDs, missing plugin dependencies). **Returns**: `string[]` .build() [#build-1] Builds the final `WorkflowConfig`. Throws if validation fails. **Returns**: `WorkflowConfig` .toJSON() / .fromJSON(json) [#tojson--fromjsonjson-1] Serialization and deserialization of the workflow structure. .getStats() [#getstats-2] Returns workflow statistics. ```ts const stats = workflow.getStats(); // { // totalSteps, totalFields, averageFieldsPerStep, // maxFieldsInStep, minFieldsInStep, hasAnalytics // } ``` *** Hooks [#hooks-1] Context Hook [#context-hook-1] | Hook | Signature | Returns | | -------------------- | ---------------------- | ---------------------- | | `useWorkflowContext` | `useWorkflowContext()` | `WorkflowContextValue` | `useWorkflowContext()` provides the full workflow context including state, navigation, data, and submission methods. See the `WorkflowContextValue` type below for details. Store Hooks (Zustand) [#store-hooks-zustand] | Hook | Signature | Returns | | ---------------------------- | ---------------------------------- | ----------------------------------------------------- | | `useCurrentStepIndex` | `useCurrentStepIndex()` | `number` | | `useWorkflowTransitioning` | `useWorkflowTransitioning()` | `boolean` | | `useWorkflowInitializing` | `useWorkflowInitializing()` | `boolean` | | `useWorkflowSubmitting` | `useWorkflowSubmitting()` | `boolean` | | `useWorkflowAllData` | `useWorkflowAllData()` | `Record` | | `useWorkflowStepData` | `useWorkflowStepData()` | `Record` | | `useStepDataById` | `useStepDataById(stepId: string)` | `Record \| undefined` | | `useVisitedSteps` | `useVisitedSteps()` | `Set` | | `usePassedSteps` | `usePassedSteps()` | `Set` | | `useIsStepVisited` | `useIsStepVisited(stepId: string)` | `boolean` | | `useIsStepPassed` | `useIsStepPassed(stepId: string)` | `boolean` | | `useWorkflowNavigationState` | `useWorkflowNavigationState()` | `{ currentStepIndex, isTransitioning, isSubmitting }` | | `useWorkflowSubmitState` | `useWorkflowSubmitState()` | `{ isSubmitting, isTransitioning, isInitializing }` | | `useWorkflowActions` | `useWorkflowActions()` | `UseWorkflowActionsResult` | ```ts interface UseWorkflowActionsResult { setCurrentStep: (stepIndex: number) => void; setStepData: (data: Record, stepId: string) => void; setAllData: (data: Record) => void; setFieldValue: (fieldId: string, value: unknown, stepId: string) => void; setSubmitting: (isSubmitting: boolean) => void; setTransitioning: (isTransitioning: boolean) => void; setInitializing: (isInitializing: boolean) => void; markStepVisited: (stepId: string) => void; markStepPassed: (stepId: string) => void; reset: () => void; loadPersistedState: (state: Partial) => void; } ``` Metadata Hook [#metadata-hook] | Hook | Signature | Returns | | ----------------- | ------------------- | ----------------------- | | `useStepMetadata` | `useStepMetadata()` | `UseStepMetadataReturn` | ```ts interface UseStepMetadataReturn { current: Record | undefined; getByStepId: (stepId: string) => Record | undefined; getByStepIndex: (stepIndex: number) => Record | undefined; hasCurrentKey: (key: string) => boolean; getCurrentValue: (key: string, defaultValue?: T) => T; getAllStepsMetadata: () => Array<{ id, title, index, metadata }>; findStepsByMetadata: (predicate) => string[]; } ``` *** Types [#types] Validation [#validation] ```ts interface ValidationResult { readonly isValid: boolean; readonly errors: ValidationError[]; readonly value?: any; } interface ValidationError { readonly message: string; readonly code?: string; readonly path?: string; } type ValidationState = 'idle' | 'validating' | 'valid' | 'invalid'; interface FieldValidationConfig { readonly validate?: StandardSchemaV1 | StandardSchemaV1[]; readonly validateOnChange?: boolean; readonly validateOnBlur?: boolean; readonly debounceMs?: number; } interface FormValidationConfig = Record> { readonly validate?: StandardSchemaV1 | StandardSchemaV1[]; readonly validateOnSubmit?: boolean; readonly validateOnStepChange?: boolean; } ``` Conditions [#conditions] ```ts interface ConditionConfig { field: string; operator: ConditionOperator; value?: ConditionValue; conditions?: ConditionConfig[]; logicalOperator?: 'and' | 'or'; } type ConditionOperator = | 'equals' | 'notEquals' | 'greaterThan' | 'lessThan' | 'greaterThanOrEqual' | 'lessThanOrEqual' | 'contains' | 'notContains' | 'in' | 'notIn' | 'matches' | 'exists' | 'notExists'; type ConditionValue = string | number | boolean | null | undefined | Array; interface ConditionalBehavior { readonly visible?: ConditionConfig; readonly disabled?: ConditionConfig; readonly required?: ConditionConfig; readonly readonly?: ConditionConfig; } interface StepConditionalBehavior { readonly visible?: ConditionConfig; readonly skippable?: ConditionConfig; } ``` Field and Form State [#field-and-form-state] ```ts interface FieldConditions { readonly visible: boolean; readonly disabled: boolean; readonly required: boolean; readonly readonly: boolean; } interface FieldState { readonly value: unknown; readonly errors: ValidationError[]; readonly validationState: ValidationState; readonly touched: boolean; readonly dirty: boolean; } ``` Workflow [#workflow-1] ```ts interface WorkflowAnalytics { readonly onWorkflowStart?: (workflowId: string, context: WorkflowContext) => void; readonly onWorkflowComplete?: (workflowId: string, duration: number, data: any) => void; readonly onWorkflowAbandon?: (workflowId: string, currentStep: string, data: any) => void; readonly onStepStart?: (stepId: string, timestamp: number, context: WorkflowContext) => void; readonly onStepComplete?: (stepId: string, duration: number, data: any, context: WorkflowContext) => void; readonly onStepSkip?: (stepId: string, reason: string, context: WorkflowContext) => void; readonly onError?: (error: Error, context: WorkflowContext) => void; } interface WorkflowPlugin { readonly name: string; readonly version?: string; readonly install: (workflow: any) => void; readonly dependencies?: string[]; } interface MonitoringConfig { readonly enabled: boolean; readonly enablePerformanceTracking?: boolean; readonly enableErrorTracking?: boolean; readonly enableMemoryTracking?: boolean; readonly performanceThresholds?: PerformanceThresholds; readonly sampleRate?: number; readonly bufferSize?: number; readonly flushInterval?: number; readonly onEvent?: (event: MonitoringEvent) => void; readonly onBatch?: (events: MonitoringEvent[]) => void; readonly onError?: (error: Error) => void; } ``` # Examples Gallery import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; Examples Gallery [#examples-gallery] A collection of production-ready examples demonstrating RilayKit's flexibility across UI libraries, frameworks, and use cases. Each example is complete and ready to copy-paste into your project. For complete production scenarios (SaaS onboarding, KYC verification, dynamic pricing), see the [Real-World Examples](/guides/real-world-examples) guide. Simple Contact Form [#simple-contact-form] A basic contact form using built-in validators with the `validation: { validate: [...] }` format. ```tsx title="ContactForm.tsx" import { ril, required, email, minLength } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; import { Form, FormField } from '@rilaykit/forms'; import type { ComponentRenderer } from '@rilaykit/core'; // Define a simple input component interface InputProps { label: string; type?: string; placeholder?: string; required?: boolean; } const Input: ComponentRenderer = ({ id, value, onChange, onBlur, error, props, disabled, }) => (
onChange?.(e.target.value)} onBlur={onBlur} disabled={disabled} placeholder={props.placeholder} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" /> {error &&

{error[0].message}

}
); // Configure RilayKit const rilay = ril.create() .addComponent('input', { renderer: Input }); // Build the form const contactForm = form.create(rilay, 'contact') .add({ id: 'name', type: 'input', props: { label: 'Full Name', required: true }, validation: { validate: [required(), minLength(2)] }, }) .add({ id: 'email', type: 'input', props: { label: 'Email', type: 'email', required: true }, validation: { validate: [required(), email()] }, }) .add({ id: 'message', type: 'input', props: { label: 'Message', required: true }, validation: { validate: [required(), minLength(10)] }, }); export function ContactForm() { const handleSubmit = (data: { name: string; email: string; message: string }) => { console.log('Contact form submitted:', data); }; return (
); } ``` Registration with Conditional Fields [#registration-with-conditional-fields] Using `when()` for conditional visibility and Zod as a direct Standard Schema validator -- no adapter needed. ```tsx title="RegistrationForm.tsx" import { ril, required, email, when } from '@rilaykit/core'; import { Form, FormField } from '@rilaykit/forms'; import { z } from 'zod'; const rilay = ril.create() .addComponent('input', { renderer: Input }) .addComponent('select', { renderer: Select }); const registrationForm = form.create(rilay, 'registration') .add({ id: 'email', type: 'input', props: { label: 'Email', type: 'email' }, validation: { validate: [required(), email()], validateOnBlur: true, }, }) .add({ id: 'password', type: 'input', props: { label: 'Password', type: 'password' }, validation: { validate: [z.string().min(8, 'Password must be at least 8 characters')], validateOnChange: true, }, }) .add({ id: 'accountType', type: 'select', props: { label: 'Account Type', options: [ { value: 'personal', label: 'Personal' }, { value: 'business', label: 'Business' }, ], }, validation: { validate: [required()] }, }) .add({ id: 'companyName', type: 'input', props: { label: 'Company Name' }, validation: { validate: [required('Company name is required')] }, conditions: { visible: when('accountType').equals('business'), }, }); export function RegistrationForm() { const handleSubmit = (data: Record) => { console.log('Registration submitted:', data); }; return (
); } ``` When a field is hidden via `conditions.visible`, its validation is automatically skipped. The `companyName` field above is only validated when `accountType` is `"business"`. Material-UI Integration [#material-ui-integration] Integrate RilayKit with Material-UI by creating typed component renderers. ```tsx title="MaterialInput.tsx" import { TextField } from '@mui/material'; import type { ComponentRenderer } from '@rilaykit/core'; interface MaterialInputProps { label: string; variant?: 'outlined' | 'filled' | 'standard'; multiline?: boolean; rows?: number; } const MaterialInput: ComponentRenderer = ({ id, value, onChange, onBlur, error, props, disabled, }) => ( onChange?.(e.target.value)} onBlur={onBlur} error={!!error} helperText={error?.[0]?.message} disabled={disabled} fullWidth margin="normal" /> ); export { MaterialInput, type MaterialInputProps }; ``` ```tsx title="lib/mui-rilay.ts" import { ril } from '@rilaykit/core'; import { MaterialInput } from '@/components/MaterialInput'; import { MaterialSelect } from '@/components/MaterialSelect'; export const muiRilay = ril.create() .addComponent('input', { renderer: MaterialInput, defaultProps: { variant: 'outlined' }, }) .addComponent('select', { renderer: MaterialSelect, defaultProps: { variant: 'outlined' }, }) .addComponent('textarea', { renderer: MaterialInput, defaultProps: { multiline: true, rows: 4 }, }); ``` ```tsx title="MaterialForm.tsx" import { Form, FormField } from '@rilaykit/forms'; import { required } from '@rilaykit/core'; import { muiRilay } from '@/lib/mui-rilay'; const profileForm = form.create(muiRilay, 'user-profile') .add({ id: 'name', type: 'input', props: { label: 'Full Name' }, validation: { validate: [required()] }, }) .add({ id: 'bio', type: 'textarea', props: { label: 'Biography', rows: 6 }, }); export function MaterialForm() { return (
); } ```
Shadcn/UI Integration [#shadcnui-integration] Integrate RilayKit with shadcn/ui components for a clean, accessible design system. ```tsx title="ShadcnInput.tsx" import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import type { ComponentRenderer } from '@rilaykit/core'; interface ShadcnInputProps { label: string; type?: string; placeholder?: string; } const ShadcnInput: ComponentRenderer = ({ id, value, onChange, onBlur, error, props, disabled, }) => (
onChange?.(e.target.value)} onBlur={onBlur} placeholder={props.placeholder} disabled={disabled} className={error ? 'border-destructive' : ''} /> {error && (

{error[0].message}

)}
); export { ShadcnInput, type ShadcnInputProps }; ```
```tsx title="lib/shadcn-rilay.ts" import { ril } from '@rilaykit/core'; import { ShadcnInput } from '@/components/ShadcnInput'; import { ShadcnSelect } from '@/components/ShadcnSelect'; export const shadcnRilay = ril.create() .addComponent('input', { renderer: ShadcnInput }) .addComponent('select', { renderer: ShadcnSelect }); ``` ```tsx title="ShadcnForm.tsx" import { Form, FormField } from '@rilaykit/forms'; import { required, email } from '@rilaykit/core'; import { shadcnRilay } from '@/lib/shadcn-rilay'; const contactForm = form.create(shadcnRilay, 'contact') .add({ id: 'name', type: 'input', props: { label: 'Name', placeholder: 'Your name' }, validation: { validate: [required()] }, }) .add({ id: 'email', type: 'input', props: { label: 'Email', type: 'email', placeholder: 'you@example.com' }, validation: { validate: [required(), email()] }, }); export function ShadcnForm() { return (
); } ```
Multi-Step Workflow [#multi-step-workflow] Build a multi-step user onboarding workflow using `flow.create(rilay, )`. ```tsx title="OnboardingWorkflow.tsx" import { ril, required, email, minLength } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; import { flow } from '@rilaykit/workflow'; import { Workflow } from '@rilaykit/workflow'; const rilay = ril.create() .addComponent('input', { renderer: Input }) .addComponent('checkbox', { renderer: Checkbox }); // Step 1: Account creation const accountForm = form.create(rilay, 'account') .add({ id: 'email', type: 'input', props: { label: 'Email', type: 'email' }, validation: { validate: [required(), email()] }, }) .add({ id: 'password', type: 'input', props: { label: 'Password', type: 'password' }, validation: { validate: [required(), minLength(8)] }, }); // Step 2: Profile details const profileForm = form.create(rilay, 'profile') .add( { id: 'firstName', type: 'input', props: { label: 'First Name' } }, { id: 'lastName', type: 'input', props: { label: 'Last Name' } }, ); // Step 3: Confirmation const confirmForm = form.create(rilay, 'confirm') .add({ id: 'terms', type: 'checkbox', props: { label: 'I agree to the terms and conditions' }, validation: { validate: [required('You must accept the terms')], }, }); // Build the workflow const onboardingWorkflow = flow.create(rilay, 'onboarding', 'User Onboarding') .step({ id: 'account', title: 'Create Account', description: 'Set up your credentials', formConfig: accountForm, }) .step({ id: 'profile', title: 'Your Profile', description: 'Tell us about yourself', formConfig: profileForm, allowSkip: true, }) .step({ id: 'confirm', title: 'Confirmation', description: 'Review and accept terms', formConfig: confirmForm, }) .configure({ analytics: { onWorkflowStart: (id) => console.log(`Started: ${id}`), onWorkflowComplete: (id, totalTime) => console.log(`Completed: ${id} in ${totalTime}ms`), }, }); export function OnboardingWorkflow() { const handleComplete = (data: Record) => { console.log('Workflow completed:', data); }; return ; } ``` Async Validation [#async-validation] Use the `async()` validator from `@rilaykit/core` for server-side checks like email uniqueness. ```tsx title="AsyncValidationForm.tsx" import { ril, required, email, async as asyncValidator } from '@rilaykit/core'; import { Form, FormField } from '@rilaykit/forms'; // Custom async validator using the built-in async() helper const checkEmailAvailability = asyncValidator( async (value: string) => { const response = await fetch(`/api/check-email?email=${value}`); const { available } = await response.json(); return available; }, 'This email is already taken', ); const rilay = ril.create() .addComponent('input', { renderer: Input }); const signupForm = form.create(rilay, 'signup') .add({ id: 'email', type: 'input', props: { label: 'Email', type: 'email' }, validation: { validate: [required(), email(), checkEmailAvailability], validateOnBlur: true, debounceMs: 500, }, }) .add({ id: 'password', type: 'input', props: { label: 'Password', type: 'password' }, validation: { validate: [required(), minLength(8)], }, }); export function AsyncValidationForm() { return (
); } ``` You can also achieve async validation using Zod's `.refine()` with an async callback, which works as a Standard Schema validator. See the [Validation](/docs/forms/validation) guide for details. Next.js App Router [#nextjs-app-router] RilayKit works seamlessly with Next.js App Router. Since forms are interactive, mark your component file with `'use client'`. ```tsx title="app/contact/page.tsx" 'use client'; import { ril, required, email } from '@rilaykit/core'; import { Form, FormField } from '@rilaykit/forms'; import type { ComponentRenderer } from '@rilaykit/core'; // Component definition const Input: ComponentRenderer<{ label: string; type?: string }> = ({ id, value, onChange, onBlur, error, props, }) => (
onChange?.(e.target.value)} onBlur={onBlur} className="mt-1 w-full rounded-md border p-2" /> {error &&

{error[0].message}

}
); // RilayKit setup const rilay = ril.create() .addComponent('input', { renderer: Input }); const contactForm = form.create(rilay, 'contact') .add({ id: 'name', type: 'input', props: { label: 'Name' }, validation: { validate: [required()] }, }) .add({ id: 'email', type: 'input', props: { label: 'Email', type: 'email' }, validation: { validate: [required(), email()] }, }); export default function ContactPage() { async function handleSubmit(data: { name: string; email: string }) { const response = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!response.ok) { throw new Error('Failed to submit'); } } return (

Contact Us

); } ``` In a real application, extract your `ril` instance and form configurations into separate files (e.g., `lib/rilay.ts` and `config/forms.ts`) to keep them reusable across pages. Testing with Vitest [#testing-with-vitest] RilayKit forms can be tested with Vitest and Testing Library using standard patterns. ```tsx title="ContactForm.test.tsx" import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ContactForm } from './ContactForm'; describe('ContactForm', () => { it('should display validation errors for empty required fields', async () => { render(); const submitButton = screen.getByRole('button', { name: /send message/i }); await userEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(/required/i)).toBeInTheDocument(); }); }); it('should validate email format on blur', async () => { const user = userEvent.setup(); render(); const emailInput = screen.getByLabelText(/email/i); await user.type(emailInput, 'not-an-email'); await user.tab(); // triggers onBlur await waitFor(() => { expect(screen.getByText(/valid email/i)).toBeInTheDocument(); }); }); it('should call onSubmit with valid data', async () => { const user = userEvent.setup(); const handleSubmit = vi.fn(); render(); await user.type(screen.getByLabelText(/name/i), 'Jane Doe'); await user.type(screen.getByLabelText(/email/i), 'jane@example.com'); await user.type(screen.getByLabelText(/message/i), 'Hello, this is a test message.'); await user.click(screen.getByRole('button', { name: /send message/i })); await waitFor(() => { expect(handleSubmit).toHaveBeenCalledWith({ name: 'Jane Doe', email: 'jane@example.com', message: 'Hello, this is a test message.', }); }); }); }); ``` More examples and starter templates are available in the [GitHub repository](https://github.com/andyoucreate/rilay/tree/main/examples). # Introduction import { Card, Cards } from 'fumadocs-ui/components/card'; import { FileCode, BotMessageSquare, Settings2, Sparkles, Check, X } from 'lucide-react'; import { Callout } from 'fumadocs-ui/components/callout'; Is RilayKit for you? [#is-rilaykit-for-you] RilayKit is **not** a React Hook Form replacement for simple forms. It's a specialized tool for specific use cases. **Building a login, contact, or settings form?** → Use [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/). It's simpler, battle-tested, and perfect for standard forms. **RilayKit is for you if** you're building one of these: ✅ You need RilayKit when: [#-you-need-rilaykit-when] * **Forms are generated dynamically** from server config or database schemas * **Multi-tenant SaaS** where each client customizes form fields and workflows * **Visual form builder** for non-technical users to create forms * **Complex multi-step workflows** with cross-step conditional logic (onboarding, KYC, checkout) * **Same logic, multiple UIs** — one form schema across web, mobile, or different design systems * **Form versioning & A/B testing** — store forms as data, diff them, roll back changes ❌ You don't need RilayKit when: [#-you-dont-need-rilaykit-when] * Building standard CRUD forms with static field sets * Forms are defined once and never change dynamically * Simple wizard flows with no cross-step dependencies * No need for serialization or form-as-data architecture *** The tipping point [#the-tipping-point] Here's when React Hook Form hits its limits and RilayKit shines: **Scenario:** Multi-step onboarding where Step 3 shows different fields based on selections from Step 1 and Step 2, and Step 4 is skipped entirely if revenue \< $50k. With React Hook Form [#with-react-hook-form] ```tsx // State machine sprawl const [step, setStep] = useState(1); const [userType, setUserType] = useState(''); const [revenue, setRevenue] = useState(0); // Manual cross-step dependencies useEffect(() => { if (userType === 'freelance' && revenue < 50000) { setStep(5); // skip step 4 } }, [userType, revenue]); // 200+ lines of conditional rendering and navigation logic {step === 3 && userType === 'business' && } {step === 3 && userType === 'freelance' && } ``` With RilayKit [#with-rilaykit] ```tsx import { ril } from 'rilaykit'; // Create step forms const basicsForm = rilay.form('basics') .add({ id: 'userType', type: 'select', props: { options: ['freelance', 'business'] } }) .add({ id: 'revenue', type: 'number', props: { label: 'Annual Revenue' } }) .build(); const businessForm = rilay.form('business-details') .add({ id: 'company', type: 'input', props: { label: 'Company Name' } }) .build(); const freelanceForm = rilay.form('freelance-details') .add({ id: 'skills', type: 'multi-select', props: { label: 'Skills' } }) .build(); // Build workflow with conditional steps // Note: ID and name are optional (auto-generated if omitted) const onboarding = rilay.flow('onboarding', 'User Onboarding') .step({ id: 'basics', title: 'Basic Information', formConfig: basicsForm }) .step({ id: 'business', title: 'Business Details', formConfig: businessForm, conditions: { visible: when('basics.userType').equals('business'), skippable: when('basics.revenue').lessThan(50000) } }) .step({ id: 'freelance', title: 'Freelance Details', formConfig: freelanceForm, conditions: { visible: when('basics.userType').equals('freelance') } }) .build(); // Serialize to DB, version control, A/B test const json = onboarding.toJSON(); ``` **The key difference:** RilayKit moves business logic out of your components and into declarative configuration. This configuration is data, so you can store it, diff it, version it, and generate it dynamically. *** Quick example [#quick-example] ```tsx import { ril, Form, FormField } from 'rilaykit'; // 1. Register your components once (shadcn, MUI, custom — your choice) const rilay = ril.create() .addComponent('input', { renderer: YourInput }) .addComponent('select', { renderer: YourSelect }); // 2. Define forms as data (not JSX) const signupForm = rilay.form('signup') .add({ id: 'email', type: 'input', validation: { validate: [required(), email()] } }) .add({ id: 'plan', type: 'select', props: { options: ['free', 'pro', 'enterprise'] } }) .add({ id: 'company', type: 'input', conditions: { visible: when('plan').in(['pro', 'enterprise']) } }) .build(); // 3. Render anywhere with full type safety
{/* auto-hidden unless plan is pro/enterprise */} ``` RilayKit is fully **headless** — it manages state, validation, and logic. You own the components, the markup, and the styling. *** React Hook Form vs RilayKit [#react-hook-form-vs-rilaykit] An honest comparison to help you choose: | Criteria | React Hook Form | RilayKit | | ------------------------------ | -------------------------------- | ---------------------------------- | | **Learning curve** | ✅ Simple, familiar patterns | ⚠️ Steeper (new concepts) | | **Simple forms** | ✅ Perfect fit | ❌ Overkill | | **Multi-step with conditions** | ⚠️ Requires custom state machine | ✅ Built-in workflow engine | | **Forms as data** | ❌ Not possible | ✅ Core feature (`.toJSON()`) | | **Dynamic form generation** | ❌ Challenging | ✅ Designed for it | | **Multiple design systems** | ⚠️ Requires refactoring | ✅ Swap renderers, logic stays | | **Ecosystem maturity** | ✅ Huge, battle-tested | ⚠️ Young but growing | | **Type safety** | ✅ Good (with Zod/TS) | ✅ Excellent (built-in propagation) | **Rule of thumb:** If you're not sure you need RilayKit, you probably don't. Start with React Hook Form. Migrate to RilayKit when you hit the limitations above. [Read the detailed comparison](/why-rilaykit) *** What makes RilayKit different [#what-makes-rilaykit-different] 1. Forms are data, not JSX [#1-forms-are-data-not-jsx] This is the fundamental shift. Your form definition is a serializable data structure: ```tsx const pricingForm = rilay.form('pricing') .add({ id: 'plan', type: 'select', props: { options: plans } }) .add({ id: 'seats', type: 'number', conditions: { visible: when('plan').equals('enterprise') } }) .build(); // It's data — you can serialize it const json = pricingForm.toJSON(); await db.forms.save(json); // Load it later, even from a different server const loaded = rilay.form().fromJSON(json).build(); // Diff it for version control const diff = deepDiff(v1.toJSON(), v2.toJSON()); ``` **Why this matters:** * **Multi-tenant SaaS:** Each tenant customizes their forms, stored in your DB * **Visual builders:** Non-technical users create forms through a UI, you render them * **A/B testing:** Switch form versions without deploying code * **Form versioning:** Track changes, roll back, audit who changed what 2. Type propagation end-to-end [#2-type-propagation-end-to-end] Register a component once and TypeScript propagates its types everywhere: ```tsx import { ril } from 'rilaykit'; // Register with typed props const rilay = ril.create() .addComponent('input', { renderer: Input }); // Input has InputProps // Now TypeScript knows rilay.form('test') .add({ type: 'input', // ✅ Autocompletes from registry props: { label: '' } // ✅ Typed as InputProps }) .add({ type: 'unknown', // ❌ Compile error — not registered props: { foo: 'bar' } // ❌ Won't even get here }); ``` **No `any` escape hatches.** Your registry becomes your single source of truth for types. [Learn more](/core-concepts/typescript-support) 3. Universal validation (Standard Schema) [#3-universal-validation-standard-schema] Any validation library, no adapters: ```tsx // Built-in validators validation: { validate: [required(), email(), minLength(8)] } // Zod — directly validation: { validate: z.string().email() } // Yup — directly validation: { validate: yup.string().email() } // Mix them validation: { validate: [required(), z.string().min(8)] } ``` RilayKit implements the [Standard Schema spec](https://github.com/standard-schema/standard-schema), so any compliant library works out of the box. [Learn more](/core-concepts/validation) 4. Declarative conditions (no useEffect) [#4-declarative-conditions-no-useeffect] Control field behavior based on other fields without manual state management: ```tsx .add({ id: 'company', type: 'input', conditions: { visible: when('accountType').equals('business'), required: when('plan').in(['pro', 'enterprise']), disabled: when('revenue').lessThan(10000), } }) ``` **Cross-step conditions** work the same way in workflows: ```tsx .step('payment', (s) => s .conditions({ skippable: when('plan.tier', 'eq', 'free'), // from step "plan" visible: when('details.country', 'in', ['US', 'CA']) // from step "details" }) ) ``` [Learn more](/core-concepts/conditions) 5. Production-ready workflow engine [#5-production-ready-workflow-engine] Multi-step flows with navigation, persistence, analytics, and plugins: ```tsx import { localStorageAdapter } from 'rilaykit'; const onboarding = rilay.flow('onboarding', 'User Onboarding') .step({ id: 'account', title: 'Account', formConfig: accountForm }) .step({ id: 'profile', title: 'Profile', formConfig: profileForm }) .configure({ persistence: { adapter: localStorageAdapter({ key: 'onboarding-v2' }) }, analytics: { onStepComplete: (id) => analytics.track('step_done', { step: id }) } }) .build(); // Render the workflow ``` **Not just a wizard.** Real features: * Step skipping based on conditions * Progress persistence (localStorage, sessionStorage, custom adapter) * Navigation guards (prevent forward if validation fails) * Analytics hooks for tracking * Plugin system for custom behavior [Learn more](/workflow/building-workflows) *** Architecture [#architecture] Headless & renderer-agnostic [#headless--renderer-agnostic] RilayKit generates **zero HTML and zero CSS**. It's pure logic. You bring your own components: * shadcn/ui * Material UI * Chakra UI * Mantine * Your custom design system ```tsx // Works with any component library const rilay = ril.create() .addComponent('input', { renderer: ShadcnInput }) .addComponent('select', { renderer: ShadcnSelect }); // Or switch to MUI without changing form logic const rilay = ril.create() .addComponent('input', { renderer: MuiTextField }) .addComponent('select', { renderer: MuiSelect }); ``` Same form config, different UI. This is why **multi-tenant SaaS** and **white-label products** love RilayKit — one form schema, N brands. One package, everything included [#one-package-everything-included] Install `rilaykit` and you're ready to go — forms, workflows, validation, and conditions all in one: ```bash pnpm add rilaykit ``` For more granular control, individual packages are also available (`@rilaykit/core`, `@rilaykit/forms`, `@rilaykit/workflow`). See [Installation](/getting-started/installation) for details. *** Real-world use cases [#real-world-use-cases] 1. Multi-tenant SaaS onboarding [#1-multi-tenant-saas-onboarding] **Problem:** Each customer wants custom onboarding flows. Hardcoding every variation is unmaintainable. **Solution:** Store form configs per tenant in your database, render them dynamically. ```tsx // Load tenant-specific config from DB const tenantConfigJson = await db.workflows.findOne({ tenantId, workflowId: 'onboarding' }); // Recreate workflow from JSON const onboarding = rilay.flow('onboarding', 'Onboarding') .fromJSON(tenantConfigJson) .build(); // Render the same way for all tenants ``` 2. Visual form builder [#2-visual-form-builder] **Problem:** Non-technical users need to create/edit forms without touching code. **Solution:** Build a drag-and-drop form builder that outputs RilayKit JSON. ```tsx // Builder UI generates this const formJson = { id: 'lead-capture', rows: [ { kind: 'fields', fields: [{ id: 'email', type: 'input', componentId: 'input', validation: { ... } }] }, { kind: 'fields', fields: [{ id: 'source', type: 'select', componentId: 'select', conditions: { ... } }] } ] }; // Your app consumes it const leadForm = rilay.form() .fromJSON(formJson) .build(); ``` 3. Complex KYC workflows [#3-complex-kyc-workflows] **Problem:** Know Your Customer flows have dozens of conditional branches based on country, business type, revenue, etc. **Solution:** Declarative conditions keep it manageable: ```tsx .step('tax-info', (s) => s .conditions({ visible: when('basics.country', 'in', ['US', 'CA']), skippable: when('basics.revenue', 'lt', 10000) }) .add({ id: 'ein', type: 'input', conditions: { visible: when('basics.entityType', 'eq', 'corporation'), required: when('basics.country', 'eq', 'US') } }) ) ``` *** Get Started [#get-started] *** # Quick Start import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; Quick Start [#quick-start] Get a working form in 5 minutes. This guide skips explanations and focuses on getting you productive fast. 1. Install [#1-install] ```bash pnpm add rilaykit ``` ```bash npm install rilaykit ``` ```bash yarn add rilaykit ``` 2. Create Components [#2-create-components] ```tsx title="components/Input.tsx" import { ComponentRenderer } from 'rilaykit'; interface InputProps { label: string; type?: string; placeholder?: string; } export const Input: ComponentRenderer = ({ id, value, onChange, onBlur, error, props }) => (
onChange?.(e.target.value)} onBlur={onBlur} placeholder={props.placeholder} className="w-full p-2 border rounded focus:ring-2 focus:ring-blue-500" /> {error &&

{error[0].message}

}
); ``` 3. Configure RilayKit [#3-configure-rilaykit] ```tsx title="lib/rilay.ts" import { ril } from 'rilaykit'; import { Input } from '../components/Input'; export const rilay = ril.create() .addComponent('input', { renderer: Input }); ``` 4. Build Form [#4-build-form] ```tsx title="forms/login.ts" import { required, email } from 'rilaykit'; import { rilay } from '../lib/rilay'; export const loginForm = rilay.form('login') .add({ id: 'email', type: 'input', props: { label: 'Email', type: 'email' }, validation: { validate: [required(), email()] }, }) .add({ id: 'password', type: 'input', props: { label: 'Password', type: 'password' }, validation: { validate: [required()] }, }) .build(); ``` 5. Render Form [#5-render-form] ```tsx title="components/LoginForm.tsx" import { Form, FormField } from 'rilaykit'; import { loginForm } from '../forms/login'; export function LoginForm() { const handleSubmit = (data: { email: string; password: string }) => { console.log('Login:', data); // Handle login logic }; return (
); } ``` 6. Use Anywhere [#6-use-anywhere] ```tsx title="pages/login.tsx" import { LoginForm } from '../components/LoginForm'; export default function LoginPage() { return (

Sign In

); } ``` Done [#done] You now have a fully functional, validated login form. What's Next? [#whats-next]

Style It

Integrate with your UI library

View Examples →

Add Logic

Conditional fields, async validation

Learn More →

Multi-Step

Complex workflows and wizards

Explore Workflows →

Validate

Zod, Yup, Joi integration

See Validation →
Common Patterns [#common-patterns] Multiple Input Types [#multiple-input-types] ```tsx // Support different input types with one component export const rilay = ril.create() .addComponent('input', { renderer: Input }) .addComponent('textarea', { renderer: Input, defaultProps: { multiline: true } }) .addComponent('select', { renderer: SelectInput }); ``` Validation with Zod [#validation-with-zod] ```tsx import { z } from 'zod'; // Zod 3.24+ supports Standard Schema natively const userForm = rilay.form('user') .add({ id: 'email', type: 'input', validation: { validate: z.string().email(), // Use Zod directly — no adapter needed }, }); ``` Conditional Fields [#conditional-fields] ```tsx import { when } from 'rilaykit'; const accountForm = rilay.form('account') .add({ id: 'accountType', type: 'select', props: { options: [{ value: 'business', label: 'Business' }] } }) .add({ id: 'companyName', type: 'input', conditions: { visible: when('accountType').equals('business') } }); ``` Custom Validator [#custom-validator] ```tsx import { custom } from 'rilaykit'; const passwordMatch = custom( (value, context) => value === context?.formData?.password, 'Passwords do not match' ); // Usage .add({ id: 'confirmPassword', type: 'input', validation: { validate: [required(), passwordMatch] }, }) ``` **Pro tip**: Save the examples above as code snippets in your editor for faster development. Templates [#templates] Clone ready-to-use templates: ```bash npx create-next-app@latest my-app --typescript cd my-app pnpm add rilaykit ``` ```bash npm create vite@latest my-app -- --template react-ts cd my-app pnpm add rilaykit ``` ```bash npx create-remix@latest my-app --typescript cd my-app pnpm add rilaykit ``` For complete project templates, visit our [GitHub templates](https://github.com/andyoucreate/rilay-templates). Need Help? [#need-help] * [Full Tutorial](/getting-started/your-first-form) — Step-by-step with explanations * [Examples Gallery](/examples) — Real-world examples * [API Reference](/api) — Complete documentation * [Discord Community](https://discord.gg/rilay) — Get help from the community # Roadmap import { Callout } from 'fumadocs-ui/components/callout'; Roadmap [#roadmap] This is the public roadmap for RilayKit. It reflects our current priorities and may evolve based on community feedback. Features are organized by phase, roughly in the order we plan to ship them. Want to influence the roadmap? [Open a discussion](https://github.com/andyoucreate/rilaykit/discussions) or upvote existing proposals. *** Phase 1 — Field Reactivity & Side Effects [#phase-1--field-reactivity--side-effects] **Status:** Next up The most requested capability: declarative reactions to field value changes. Today, the condition system handles `visible` / `disabled` / `required` / `readonly`, but there's no first-class way to express **side effects** like cascading field values, dynamic option loading, or cross-field prefilling. onFieldChange — Declarative Field Effects [#onfieldchange--declarative-field-effects] A new `effects` property on field configuration, enabling reactive field-to-field logic without `useEffect`: ```tsx .add({ id: 'country', type: 'select', props: { label: 'Country' }, effects: [ onChange('country', async (value, { setValue, setProps }) => { setValue('city', ''); setProps('city', { options: await fetchCities(value) }); }) ] }) ``` **What this unlocks:** * Cascading dropdowns (country → city → district) * Dynamic option loading based on sibling field values * Cross-field value resets and prefilling * Calculated fields (e.g., total = price × quantity) *** Phase 2 — Data Transform Pipeline [#phase-2--data-transform-pipeline] **Status:** Planned A declarative transform layer that processes form data before and after submission. No more boilerplate in `onSubmit` handlers. transform() — Pre/Post Submission Hooks [#transform--prepost-submission-hooks] ```tsx form.create(r, 'register') .add(/* fields */) .transform({ before: (data) => ({ ...data, email: data.email.trim().toLowerCase(), }), after: (data) => omit(data, ['confirmPassword', 'acceptTerms']), }) .build(); ``` **What this unlocks:** * Data sanitization (trim, lowercase, format) * Field exclusion (remove internal-only fields before API call) * Shape transformation (flatten nested objects, rename keys) * Serialization pipeline (dates, files, enums) *** Phase 3 — Cross-Step Workflow Validation [#phase-3--cross-step-workflow-validation] **Status:** Planned Validation rules that span across multiple workflow steps. Today, each step validates independently — but real-world workflows need constraints that reference data from previous steps. crossValidate() — Multi-Step Validation Rules [#crossvalidate--multi-step-validation-rules] ```tsx flow.create(r, 'checkout', 'Checkout') .step({ id: 'shipping', formConfig: shippingForm }) .step({ id: 'billing', formConfig: billingForm }) .crossValidate({ validate: (allData) => { const errors: Record = {}; if (allData.billing.sameAsShipping && !allData.shipping.address) { errors['shipping.address'] = 'Required when "same as shipping" is checked'; } return errors; }, trigger: 'before-complete' // or 'on-step-leave' }) .build(); ``` **What this unlocks:** * Cross-step dependency validation (billing depends on shipping) * Final review step that validates the entire workflow * Business rules that span multiple data sources * Pre-completion integrity checks *** Phase 4 — Server-Driven Forms [#phase-4--server-driven-forms] **Status:** Planned Generate fully functional forms from a JSON schema sent by the backend. RilayKit is already schema-first and headless — this is the natural next step. fromSchema() — JSON to FormConfig [#fromschema--json-to-formconfig] ```tsx // Backend sends the form definition const schema = await fetch('/api/forms/onboarding'); // Client hydrates it into a fully typed FormConfig const formConfig = ril.fromSchema(schema); // Render it like any other form
``` JSON Schema Format [#json-schema-format] ```json { "id": "onboarding", "fields": [ { "id": "email", "type": "input", "props": { "label": "Email", "type": "email" }, "validation": [{ "rule": "required" }, { "rule": "email" }], "conditions": { "visible": { "when": "accountType", "equals": "personal" } } } ], "transform": { "before": ["trim", "lowercase:email"] } } ``` **What this unlocks:** * Forms deployed without frontend redeployment * Multi-tenant SaaS with per-client form customization * CMS-driven form generation * Visual form builder backends * A/B testing of form variants from the server *** Phase 5 — DevTools [#phase-5--devtools] **Status:** Planned A visual inspector panel (similar to React Query DevTools) that surfaces the entire form and workflow state in real time. — Visual Inspector [#rilaydevtools---visual-inspector] ```tsx {process.env.NODE_ENV === 'development' && } ``` **Planned capabilities:** * **Field Inspector** — live view of every field's value, errors, touched state, and active conditions * **Condition Graph** — visual dependency graph showing which fields affect which conditions * **Validation Timeline** — trace of all validation runs with timing and results * **Workflow Navigator** — step state, visited/passed steps, accumulated data across steps * **Performance Panel** — render counts, validation durations, re-render hotspots (powered by the existing monitoring system) * **State Diff** — highlight what changed between renders The monitoring system in `@rilaykit/core` already tracks events, performance metrics, and errors. DevTools will be a visual layer on top of this existing infrastructure. *** Phase 6 — Plugin System [#phase-6--plugin-system] **Status:** Exploring A first-class plugin architecture for extending form and workflow behavior without modifying core code. The `WorkflowPlugin` type already exists internally — this phase promotes it to a public, documented API. createPlugin() — Lifecycle Hooks [#createplugin--lifecycle-hooks] ```tsx import { createPlugin } from 'rilaykit'; const autosavePlugin = createPlugin({ id: 'autosave', onFieldChange: debounce(async (fieldId, value, { allValues }) => { await saveDraft(allValues); }, 1000), onStepComplete: (stepId, stepData) => { analytics.track('step_completed', { stepId }); }, onWorkflowComplete: (workflowId, allData) => { analytics.track('workflow_completed', { workflowId }); }, }); flow.create(r, 'onboarding', 'Onboarding') .configure({ plugins: [autosavePlugin] }) .build(); ``` **Plugin ideas for the community:** * `@rilaykit/plugin-autosave` — auto-save drafts to localStorage or server * `@rilaykit/plugin-analytics` — track field interactions, drop-off rates * `@rilaykit/plugin-ab-testing` — serve different form variants * `@rilaykit/plugin-feature-flags` — toggle fields/steps based on feature flags * `@rilaykit/plugin-undo` — undo/redo for form state *** Phase 7 — AI-Assisted Form Filling [#phase-7--ai-assisted-form-filling] **Status:** Exploring Leverage the schema-first architecture to let LLMs intelligently pre-fill forms from unstructured text. Since RilayKit forms are data, the AI has full knowledge of every field's type, constraints, and validation rules. useAIFormFill() — Text-to-Form Mapping [#useaiformfill--text-to-form-mapping] ```tsx const { fillFromText, isProcessing } = useAIFormFill(formConfig, { provider: 'openai', // or 'anthropic', 'custom' model: 'gpt-4o-mini', }); // User pastes unstructured text await fillFromText( "My name is Karl, I live in Paris, my email is karl@example.com" ); // → setValue('name', 'Karl') // → setValue('city', 'Paris') // → setValue('email', 'karl@example.com') ``` **What this unlocks:** * Paste-to-fill from emails, documents, or chat transcripts * Voice-to-form via speech-to-text + AI mapping * Bulk data import from unstructured sources * Intelligent defaults based on context * Accessibility improvement for complex forms This feature will be opt-in and provider-agnostic. RilayKit will not bundle any AI SDK — you bring your own provider. *** Completed [#completed] Features already shipped in the current release (v0.1.x): | Feature | Package | Status | | -------------------------------------------------------- | -------------------- | ------- | | Immutable component registry | `@rilaykit/core` | Shipped | | Standard Schema validation (Zod, Valibot, Yup, ArkType) | `@rilaykit/core` | Shipped | | Declarative conditions with `when()` builder | `@rilaykit/core` | Shipped | | Performance monitoring & adapters | `@rilaykit/core` | Shipped | | Form builder with type-safe field config | `@rilaykit/forms` | Shipped | | Granular Zustand store selectors | `@rilaykit/forms` | Shipped | | Repeatable fields with min/max | `@rilaykit/forms` | Shipped | | Async field validation | `@rilaykit/forms` | Shipped | | Multi-step workflow builder | `@rilaykit/workflow` | Shipped | | Step navigation with validation guards | `@rilaykit/workflow` | Shipped | | Workflow persistence (LocalStorage) | `@rilaykit/workflow` | Shipped | | Workflow analytics hooks | `@rilaykit/workflow` | Shipped | | Step conditions (visible, skipable) | `@rilaykit/workflow` | Shipped | | All-in-one `rilaykit` package with `.form()` / `.flow()` | `rilaykit` | Shipped | # Why RilayKit import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; The Problem [#the-problem] Forms in React are typically built imperatively. JSX is mixed with state management, validation logic is scattered across components, there is no serialization, and no unified approach for multi-step workflows. In practice, this leads to a set of recurring problems: * **Tight coupling between UI and business logic** -- component code handles rendering, validation, conditional logic, and state management all at once. * **No way to serialize, store, or share form definitions** -- each form lives in JSX and cannot be extracted as data. * **Every form is a one-off implementation** -- there is no shared structure, so teams rebuild the same patterns from scratch. * **Multi-step workflows require custom state machines** -- step navigation, persistence, and analytics are reinvented for each flow. * **Type safety is bolted on, not built in** -- prop types, field IDs, and validation are loosely connected at best. RilayKit takes a different approach. Instead of treating forms as UI trees, it treats them as data structures. Schema-First: Forms as Data [#schema-first-forms-as-data] RilayKit treats form configurations as plain, declarative, serializable objects -- not JSX trees. You describe what a form contains, and the library handles how it renders and behaves. ```tsx import { ril, required, email } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; const rilay = ril.create() .addComponent('input', { renderer: YourInputComponent }); const onboardingForm = form.create(rilay, 'onboarding') .add({ id: 'name', type: 'input', props: { label: 'Full Name' }, validation: { validate: [required()] }, }) .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [required(), email()] }, }); // The form config is just data -- serialize it, store it, version it const json = onboardingForm.toJSON(); ``` Because a form is data, it is: * **Introspectable** -- iterate over fields, read their types, check their validation rules. * **Serializable** -- convert to JSON, store in a database, send over the network. * **Clonable** -- create variants with `form.clone()` for A/B testing or branching logic. * **Generatable** -- build form definitions from a server, a CMS, or a visual editor. Component Registry: Define Once, Use Everywhere [#component-registry-define-once-use-everywhere] The component registry separates component definitions from form configurations. You register your components once, and every form in your application uses them. This means the same form schema can render with completely different design systems. ```tsx import { ril } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; // Design system A const rilayMaterial = ril.create() .addComponent('input', { renderer: MaterialInput }); // Design system B const rilayShadcn = ril.create() .addComponent('input', { renderer: ShadcnInput }); // Same form definition works with both const loginForm = form.create(rilayMaterial, 'login') .add({ id: 'email', type: 'input', props: { label: 'Email' } }); ``` Your UI components stay in your design system. RilayKit handles state, validation, conditions, and rendering orchestration. No CSS conflicts, no imposed styling, no vendor lock-in on the visual layer. Type Propagation: Safety Without Boilerplate [#type-propagation-safety-without-boilerplate] When you register a component with `.addComponent('input', { renderer: MyInput })`, TypeScript captures the exact props interface of `MyInput`. From that point on, every `.add({ type: 'input', props: ... })` call provides full autocompletion and compile-time validation for the `props` object. ```tsx interface TextInputProps { label: string; placeholder?: string; } interface SelectProps { label: string; options: Array<{ value: string; label: string }>; } const rilay = ril.create() .addComponent('text', { renderer: TextInput }) .addComponent('select', { renderer: SelectInput }); // TypeScript knows 'type' can only be 'text' | 'select' // and narrows 'props' accordingly form.create(rilay, 'example') .add({ id: 'name', type: 'text', props: { label: 'Name', // autocompletes from TextInputProps placeholder: 'Enter', // valid }, }) .add({ id: 'country', type: 'select', props: { label: 'Country', options: [{ value: 'us', label: 'US' }], // required by SelectProps }, }); ``` This happens automatically through generic type accumulation. You never write type annotations for forms -- the types flow from your component definitions through the builder chain. Universal Validation [#universal-validation] RilayKit's validation system accepts multiple formats through a single `validation` field. There are no adapters to install and no wrappers to write. ```tsx import { required, email, minLength } from '@rilaykit/core'; .add({ id: 'password', type: 'input', props: { label: 'Password' }, validation: { validate: [required(), minLength(8)] }, }) ``` ```tsx import { z } from 'zod'; .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: z.string().email('Invalid email format'), validateOnBlur: true, }, }) ``` ```tsx import { custom } from '@rilaykit/core'; const strongPassword = custom( (value) => /(?=.*[A-Z])(?=.*\d)/.test(value), 'Must contain uppercase and number' ); .add({ id: 'password', type: 'input', props: { label: 'Password' }, validation: { validate: [required(), strongPassword] }, }) ``` ```tsx import { z } from 'zod'; import { required } from '@rilaykit/core'; // Combine RilayKit built-ins with Zod schemas in the same array .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [ required('Email is required'), z.string().email('Invalid format'), ], }, }) ``` Any library that implements the [Standard Schema](https://standardschema.dev) interface works out of the box: Zod, Valibot, ArkType, Yup, and others. One interface, any validation library. Declarative Conditions [#declarative-conditions] Conditional logic is expressed declaratively with the `when()` function. No `useEffect`, no imperative state management, no manual subscription to field changes. ```tsx import { when } from '@rilaykit/core'; form.create(rilay, 'account') .add({ id: 'accountType', type: 'select', props: { label: 'Account Type', options: [ { value: 'personal', label: 'Personal' }, { value: 'business', label: 'Business' }, ], }, }) .add({ id: 'companyName', type: 'input', props: { label: 'Company Name' }, conditions: { visible: when('accountType').equals('business') }, }) .add({ id: 'taxId', type: 'input', props: { label: 'Tax ID' }, conditions: { visible: when('accountType').equals('business'), required: when('accountType').equals('business'), }, }); ``` Conditions can control visibility, required state, disabled state, and readonly state. They compose with `.and()` and `.or()`, support nested field paths via dot notation, and evaluate with short-circuit logic for performance. Available operators include `equals`, `notEquals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `contains`, `notContains`, `in`, `notIn`, `matches`, `exists`, and `notExists`. When a field is hidden by a condition, its validation is automatically skipped. Real Workflow Engine [#real-workflow-engine] The `@rilaykit/workflow` package is not a wizard with hidden divs. It is a workflow engine built on top of the same schema-first foundation, with real infrastructure for production multi-step flows. ```tsx import { rilay } from '@/lib/rilay'; import { required, email, minLength, custom } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; import { flow, LocalStorageAdapter } from '@rilaykit/workflow'; const accountForm = form.create(rilay, 'account') .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [required(), email()] }, }) .add({ id: 'password', type: 'input', props: { label: 'Password' }, validation: { validate: [required(), minLength(8)] }, }); const profileForm = form.create(rilay, 'profile') .add( { id: 'firstName', type: 'input', props: { label: 'First Name' } }, { id: 'lastName', type: 'input', props: { label: 'Last Name' } }, ); const onboarding = flow.create(rilay, 'onboarding', 'User Onboarding') .step({ id: 'account', title: 'Create Account', formConfig: accountForm, }) .step({ id: 'profile', title: 'Your Profile', formConfig: profileForm, allowSkip: true, }) .configure({ persistence: { adapter: new LocalStorageAdapter({ maxAge: 7 * 24 * 60 * 60 * 1000 }), options: { autoPersist: true, debounceMs: 500 }, }, analytics: { onStepComplete: (stepId, duration) => { trackEvent('step_complete', { stepId, duration }); }, onWorkflowComplete: (id, totalTime) => { trackEvent('workflow_complete', { id, totalTime }); }, }, }); ``` What the workflow engine provides: * **Step navigation** with guards, conditions, and step-level validation before navigation. * **Persistence** that auto-saves to localStorage or any custom backend (Supabase, your own API) through an adapter interface. * **Analytics** callbacks for tracking completions, drop-offs, time per step, and errors. * **Plugin system** for encapsulating reusable behavior -- install with `.use(plugin)`, declare dependencies between plugins. * **Cross-step conditions** using `when('stepId.fieldId')` syntax. Built for Products That Evolve [#built-for-products-that-evolve] RilayKit is designed for applications where forms are not static one-offs but living parts of the product that change over time. * **Serializable** -- store form configurations in a database, version them with your data, diff changes between releases. * **Clonable** -- `form.clone('variant-b')` creates an independent copy for A/B testing or per-tenant customization. * **Pluggable** -- workflow plugins encapsulate cross-cutting concerns (analytics, logging, conditional steps) and can be shared across workflows. * **Schema-first** -- because form definitions are data, not code, non-engineers can contribute to form structures through visual builders or configuration interfaces without touching React components. Who Is RilayKit For? [#who-is-rilaykit-for] RilayKit is built for teams that need more than a simple form helper. * **Product teams building complex user-facing flows** -- SaaS onboarding, KYC verification, insurance claims, multi-step checkout, patient intake forms. * **Teams that need type-safe, maintainable form systems** -- where forms are central to the product and broken forms mean broken revenue. * **Organizations with multiple design systems or multi-brand products** -- where the same form logic needs to render differently depending on the context. * **Developers who want workflow automation without building custom state machines** -- step navigation, persistence, and analytics handled out of the box. RilayKit is MIT licensed, free, and open source. Ready to get started? Head to the [installation guide](/getting-started/installation) to set up RilayKit, or jump straight to the [quickstart](/quickstart) to build your first form in 5 minutes. # Component Registry import { Callout } from 'fumadocs-ui/components/callout'; Rilaykit is headless and component-agnostic. It doesn't know how to render a text input or a select dropdown. You provide the components, and Rilaykit provides the logic. The **Component Registry** is the bridge between your UI components and the Rilaykit engine. Registering a Component [#registering-a-component] You register components using the `.addComponent()` method on your `ril` instance. This method takes two arguments: 1. A unique `type` string (e.g., `'text'`, `'email'`, `'custom-select'`). 2. A configuration object for the component. ```tsx title="lib/rilay.ts" import { ril } from '@rilaykit/core'; import { TextInput } from '@/components/TextInput'; import { Select } from '@/components/Select'; export const rilay = ril .create() .addComponent('text', { name: 'Text Input', renderer: TextInput, defaultProps: { placeholder: 'Enter your name...', }, }) .addComponent('select', { name: 'Select Input', description: 'A dropdown for selecting options.', renderer: Select, defaultProps: { options: [], }, }); ``` Component Configuration [#component-configuration] * `name`: A human-readable name for the component type. * `renderer`: The actual React component function or class. This is the most important part. * `defaultProps` (optional): An object containing default props that will be passed to every instance of this component type. This is useful for setting sensible defaults like an empty `options` array for a select input. * `description` (optional): A string to describe the component's purpose. The ComponentRenderProps [#the-componentrenderprops] When Rilaykit renders your component, it injects a standardized set of props. Your component needs to be able to handle these props to integrate with the engine. ```ts title="types.ts" export interface ComponentRenderProps { id: string; // Unique ID for the field value: TValue | undefined; // The current value of the field onChange: (value: TValue) => void; // Function to update the value onBlur: () => void; // Function to trigger validation props: TProps; // Your custom props defined in the form/workflow config error?: FieldError[]; // An array of validation errors disabled?: boolean; // Whether the field is disabled context: FormContext; // The full form context } ``` Example: A TextInput Component [#example-a-textinput-component] Here is an example of a simple `TextInput` component compatible with Rilaykit. ```tsx title="components/TextInput.tsx" import type { ComponentRenderProps } from '@rilaykit/core'; interface TextInputProps { label: string; placeholder?: string; required?: boolean; } export const TextInput: React.FC> = ({ id, value, onChange, onBlur, props, error, disabled, }) => { return (
onChange(e.target.value)} onBlur={onBlur} placeholder={props.placeholder} disabled={disabled} aria-invalid={!!error} /> {error &&

{error[0].message}

}
); }; ``` By adhering to the `ComponentRenderProps` interface, you can make any of your existing components compatible with the Rilaykit engine. # Conditions System import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; The Rilay conditions system provides a powerful, type-safe way to create dynamic forms and workflows that adapt based on user input. Conditions are evaluated in real-time and can control field visibility, requirement status, disabled state, and step navigation in workflows. The when() Function [#the-when-function] The system centers around the `when()` function which returns a `ConditionBuilder` instance: ```typescript import { when } from '@rilaykit/core'; // Basic condition const condition = when('fieldName').equals('value'); // Evaluate condition const result = condition.evaluate({ fieldName: 'value' }); // true ``` Available Operators [#available-operators] Equality Operators [#equality-operators] ```typescript // Exact equality when('status').equals('active') when('age').equals(25) when('isVip').equals(true) // Not equal when('status').notEquals('inactive') when('role').notEquals('admin') ``` Numeric Comparisons [#numeric-comparisons] ```typescript // Age must be greater than 18 when('age').greaterThan(18) // Price must be less than 100 when('price').lessThan(100) // Score must be at least 70 when('score').greaterThanOrEqual(70) // Discount cannot exceed 50% when('discount').lessThanOrEqual(50) ``` String Operations [#string-operations] ```typescript // Name contains "John" when('name').contains('John') // Email doesn't contain "test" when('email').notContains('test') // Regex pattern matching when('phone').matches(/^\d{3}-\d{3}-\d{4}$/) ``` Array Operations [#array-operations] ```typescript // Check if array contains a specific value when('selectedProducts').contains('provident') when('userRoles').contains('admin') // Check if array doesn't contain a specific value when('excludedItems').notContains('premium') // Check if a single value is in a list of options when('status').in(['active', 'pending', 'approved']) // Check if a single value is not in a list of options when('role').notIn(['admin', 'super-admin']) ``` Existence Checks [#existence-checks] ```typescript // Field has a value (not null/undefined) when('companyName').exists() // Field is null or undefined when('optionalField').notExists() ``` Logical Operators [#logical-operators] AND Operations [#and-operations] Combine multiple conditions where **all** must be true: ```typescript // Multiple conditions must be true when('age').greaterThan(18) .and(when('status').equals('active')) .and(when('country').equals('US')) // Complex nested example when('userType').equals('premium') .and( when('subscription.plan').equals('pro') .or(when('subscription.legacy').equals(true)) ) ``` OR Operations [#or-operations] Combine conditions where **any** can be true: ```typescript // Any condition can be true when('type').equals('premium') .or(when('age').greaterThan(65)) .or(when('vipStatus').equals(true)) ``` Field Path Resolution [#field-path-resolution] The condition system supports dot notation for nested object access: ```typescript // Access nested properties when('user.profile.age').greaterThan(18) when('company.address.country').equals('US') when('settings.notifications.email').equals(true) // Example data structure const data = { user: { profile: { age: 25, name: 'John Doe' } }, company: { address: { country: 'US', city: 'New York' } } }; // This condition evaluates to true when('user.profile.age').greaterThan(18).evaluate(data); // true ``` Field-Level Conditions [#field-level-conditions] Control individual form field behavior with four types of conditions: ```typescript // Show/hide fields dynamically form.create(rilay, 'my-form') .add({ id: 'phoneNumber', type: 'text', props: { label: 'Phone Number' }, conditions: { visible: when('contactMethod').equals('phone') } }) .add({ id: 'companyDetails', type: 'text', props: { label: 'Company Details' }, conditions: { visible: when('userType').equals('business') .and(when('hasCompany').equals(true)) } }); ``` ```typescript // Make fields required conditionally form.create(rilay, 'my-form') .add({ id: 'businessLicense', type: 'text', props: { label: 'Business License' }, conditions: { required: when('businessType').equals('corporation') .and(when('state').in(['NY', 'CA', 'TX'])) } }) .add({ id: 'taxId', type: 'text', props: { label: 'Tax ID' }, conditions: { required: when('orderTotal').greaterThan(1000) } }); ``` ```typescript // Disable fields based on conditions form.create(rilay, 'my-form') .add({ id: 'email', type: 'email', props: { label: 'Email Address' }, conditions: { disabled: when('emailVerified').equals(true) } }) .add({ id: 'discount', type: 'number', props: { label: 'Discount %' }, conditions: { disabled: when('userRole').notEquals('admin') } }); ``` ```typescript // Make fields read-only conditionally form.create(rilay, 'my-form') .add({ id: 'accountNumber', type: 'text', props: { label: 'Account Number' }, conditions: { readonly: when('accountLocked').equals(true) } }) .add({ id: 'finalScore', type: 'number', props: { label: 'Final Score' }, conditions: { readonly: when('testSubmitted').equals(true) } }); ``` Step-Level Conditions (Workflows) [#step-level-conditions-workflows] Control workflow step navigation with visibility and skip conditions: ```typescript const workflow = flow.create(rilay, 'user-onboarding') .step({ id: 'personal-info', title: 'Personal Information', formConfig: personalInfoForm }) .step({ id: 'company-info', title: 'Company Information', conditions: { // Only show for business users visible: when('personal-info.userType').equals('business'), // Allow skipping if company data already exists skippable: when('personal-info.hasCompanyData').equals(true) }, formConfig: companyInfoForm }) .step({ id: 'payment-info', title: 'Payment Information', conditions: { // Show for premium users or large companies visible: when('personal-info.planType').equals('premium') .or(when('company-info.employees').greaterThan(50)), // Cannot be skipped for premium users skippable: when('personal-info.planType').notEquals('premium') }, formConfig: paymentForm }); ``` Cross-Step References [#cross-step-references] Reference data from previous workflow steps using `"stepId.fieldId"` format: ```typescript // Reference specific step data when('personal-info.age').greaterThan(18) when('contact-details.email').contains('@company.com') when('preferences.notifications').equals(true) // Real-world example const workflow = flow.create(rilay, 'loan-application') .step({ id: 'applicant-info', formConfig: applicantForm }) .step({ id: 'employment-details', conditions: { visible: when('applicant-info.age').greaterThan(18) .and(when('applicant-info.citizenship').equals('US')) } }) .step({ id: 'co-signer', conditions: { visible: when('applicant-info.creditScore').lessThan(650) .or(when('employment-details.income').lessThan(50000)) } }); ``` Real-World Examples [#real-world-examples] E-commerce Checkout Form [#e-commerce-checkout-form] ```typescript const checkoutForm = form.create(rilay, 'checkout') .add({ id: 'billingAddress', type: 'address', props: { label: 'Billing Address' }, conditions: { visible: when('sameAsShipping').equals(false) } }) .add({ id: 'creditCardNumber', type: 'text', props: { label: 'Credit Card Number' }, conditions: { visible: when('paymentMethod').equals('credit_card'), required: when('paymentMethod').equals('credit_card') } }) .add({ id: 'paypalEmail', type: 'email', props: { label: 'PayPal Email' }, conditions: { visible: when('paymentMethod').equals('paypal'), required: when('paymentMethod').equals('paypal') } }) .add({ id: 'companyTaxId', type: 'text', props: { label: 'Company Tax ID' }, conditions: { visible: when('customerType').equals('business') .and(when('country').in(['US', 'CA', 'UK'])), required: when('customerType').equals('business') .and(when('orderTotal').greaterThan(1000)) } }); ``` Survey with Skip Logic [#survey-with-skip-logic] ```typescript const surveyForm = form.create(rilay, 'customer-survey') .add({ id: 'satisfaction', type: 'select', props: { label: 'How satisfied are you?', options: [ { value: 'very-satisfied', label: 'Very Satisfied' }, { value: 'satisfied', label: 'Satisfied' }, { value: 'neutral', label: 'Neutral' }, { value: 'dissatisfied', label: 'Dissatisfied' }, { value: 'very-dissatisfied', label: 'Very Dissatisfied' } ] } }) .add({ id: 'positiveReason', type: 'textarea', props: { label: 'What did you like most?' }, conditions: { visible: when('satisfaction').in(['very-satisfied', 'satisfied']) } }) .add({ id: 'improvementSuggestions', type: 'textarea', props: { label: 'How can we improve?' }, conditions: { visible: when('satisfaction').in(['neutral', 'dissatisfied', 'very-dissatisfied']), required: when('satisfaction').in(['dissatisfied', 'very-dissatisfied']) } }) .add({ id: 'recommendToFriend', type: 'select', props: { label: 'Would you recommend us to a friend?', options: [ { value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }, { value: 'maybe', label: 'Maybe' } ] }, conditions: { visible: when('satisfaction').in(['very-satisfied', 'satisfied', 'neutral']) } }); ``` Job Application Workflow [#job-application-workflow] ```typescript const jobApplicationFlow = flow.create(rilay, 'job-application') .step({ id: 'basic-info', title: 'Basic Information', formConfig: basicInfoForm }) .step({ id: 'experience', title: 'Work Experience', conditions: { skippable: when('basic-info.isRecentGraduate').equals(true) .and(when('basic-info.degree').in(['bachelor', 'master', 'phd'])) }, formConfig: experienceForm }) .step({ id: 'portfolio', title: 'Portfolio', conditions: { visible: when('basic-info.position').in(['designer', 'developer', 'architect']) .or(when('experience.yearsExperience').greaterThan(3)) }, formConfig: portfolioForm }) .step({ id: 'references', title: 'References', conditions: { visible: when('basic-info.position').in(['manager', 'director', 'vp']) .or(when('experience.yearsExperience').greaterThan(5)), skippable: when('basic-info.isInternalCandidate').equals(true) }, formConfig: referencesForm }); ``` Advanced Patterns [#advanced-patterns] Progressive Disclosure [#progressive-disclosure] Reveal fields progressively based on user choices: ```typescript form.create(rilay, 'progressive-form') .add({ id: 'experience', type: 'select', props: { label: 'Experience Level', options: [ { value: 'beginner', label: 'Beginner' }, { value: 'intermediate', label: 'Intermediate' }, { value: 'advanced', label: 'Advanced' } ] } }) .add({ id: 'yearsExperience', type: 'number', props: { label: 'Years of Experience' }, conditions: { visible: when('experience').in(['intermediate', 'advanced']) } }) .add({ id: 'specializations', type: 'multiselect', props: { label: 'Specializations' }, conditions: { visible: when('experience').equals('advanced') .and(when('yearsExperience').greaterThan(5)) } }) .add({ id: 'certifications', type: 'textarea', props: { label: 'Professional Certifications' }, conditions: { visible: when('experience').equals('advanced') .and(when('yearsExperience').greaterThan(3)), required: when('specializations').contains('security') .or(when('specializations').contains('architecture')) } }); ``` Dynamic Validation [#dynamic-validation] Combine conditions with validation for smart form behavior: ```typescript form.create(rilay, 'smart-validation') .add({ id: 'email', type: 'email', props: { label: 'Email Address' }, validation: { validate: [ required('Email is required'), email('Please enter a valid email') ] }, conditions: { required: when('contactMethod').equals('email'), readonly: when('emailVerified').equals(true) } }) .add({ id: 'phone', type: 'text', props: { label: 'Phone Number' }, validation: { validate: [ required('Phone is required'), pattern(/^\d{3}-\d{3}-\d{4}$/, 'Please enter a valid phone number') ] }, conditions: { visible: when('contactMethod').in(['phone', 'both']), required: when('contactMethod').equals('phone') } }); ``` **Performance Note**: Conditions are evaluated efficiently with short-circuit logic. OR conditions stop evaluating once a true condition is found, and field paths are resolved using optimized object traversal. Integration with Validation [#integration-with-validation] When a field is not visible due to conditions, its validation is automatically skipped, ensuring the form can be submitted without errors from hidden fields. ```typescript // This field's validation only runs when visible form.create(rilay, 'my-form') .add({ id: 'businessTaxId', type: 'text', validation: { validate: [ required('Tax ID is required for business accounts'), pattern(/^\d{2}-\d{7}$/, 'Tax ID must be in format XX-XXXXXXX') ] }, conditions: { visible: when('accountType').equals('business'), required: when('accountType').equals('business') } }); ``` Debugging Conditions [#debugging-conditions] Test conditions directly for debugging: ```typescript // Test conditions with sample data const condition = when('age').greaterThan(18); console.log(condition.evaluate({ age: 25 })); // true console.log(condition.evaluate({ age: 16 })); // false // Complex condition testing const complexCondition = when('type').equals('premium') .or(when('age').greaterThan(65)); console.log(complexCondition.evaluate({ type: 'basic', age: 70 })); // true (age > 65) // Debug helper function const debugCondition = (condition, data, label) => { const result = condition.evaluate(data); console.log(`${label}:`, result, { condition: condition.build(), data }); return result; }; ``` Best Practices [#best-practices] **Keep conditions simple**: Prefer multiple simple conditions over complex nested ones for better maintainability. **Use meaningful field names**: Clear field names make conditions easier to read and debug. **Test edge cases**: Always test conditions with empty, null, and undefined values. **Avoid circular dependencies**: Don't create conditions where fields depend on each other in a circular manner. The conditions system makes Rilay forms and workflows incredibly flexible, allowing you to create sophisticated user experiences that adapt dynamically to user input while maintaining type safety and performance. # Philosophy import { Callout } from 'fumadocs-ui/components/callout'; RilayKit is built on a set of deliberate architectural decisions. Understanding them will help you use the library effectively and make the most of its design. Schema-First Design [#schema-first-design] Forms are data, not imperative code. In traditional React form libraries, form logic is scattered across JSX trees: state declarations, change handlers, validation effects, conditional rendering. The form's structure is implicit, buried inside component hierarchies. This makes forms hard to inspect, hard to compose, and impossible to serialize. RilayKit takes a different approach. A form is a **declarative data structure** that describes fields, validation rules, and conditions. Think of it as "JSON Schema for forms, but type-safe." A form configuration is: * A **plain data structure** describing fields, validation, and conditions * **Serializable** to JSON * **Introspectable** -- you can iterate fields, check conditions, and analyze structure programmatically * **Composable** -- clone, extend, and merge configurations at runtime ```tsx // Traditional: form logic scattered in JSX function TraditionalForm() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [errors, setErrors] = useState({}); // 50+ lines of handlers, validation, effects... } // RilayKit: form is a data structure import { form } from '@rilaykit/forms'; const contactForm = form.create(rilay, 'contact') .add({ id: 'name', type: 'input', props: { label: 'Name' }, validation: { validate: [required()] } }) .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [required(), email()] } }); ``` The RilayKit approach separates the **what** (field definitions, rules) from the **how** (rendering, event handling). The builder produces a configuration object that can be passed to a React provider, stored in a database, or inspected by developer tools -- all without touching any rendering code. Headless by Design [#headless-by-design] RilayKit generates **zero HTML** and **zero CSS**. It is a pure logic layer that provides: * **State management** -- field values, errors, touched states, dirty tracking * **Validation orchestration** -- field-level and form-level, sync and async, with debouncing * **Condition evaluation** -- show/hide fields, enable/disable, set values based on other fields * **Workflow navigation** -- step progression, skip logic, persistence You provide everything visual: * All HTML markup * All styling (CSS, Tailwind, CSS-in-JS, anything) * All ARIA attributes and accessibility patterns * All animations and transitions This separation means RilayKit works with any design system, any component library, and any styling approach. Your renderers are plain React components that receive standardized props from the engine. Because RilayKit is fully headless, you have complete control over markup and accessibility. This means you are responsible for implementing ARIA attributes, focus management, and keyboard navigation in your renderers. See the [Renderers](/docs/core-concepts/renderers) guide for patterns. Immutability and Type Accumulation [#immutability-and-type-accumulation] Each builder method returns a **new typed instance**. When you call `.addComponent('input', { renderer: Input })`, TypeScript creates a new type that includes `'input'` in the valid component types. When you add `'select'`, the type grows to `'input' | 'select'`. This pattern: * Enables **full autocompletion** on `.add({ type: '...' })` -- your IDE knows exactly which types are valid * **Prevents typos at compile time** -- using an unregistered type is a type error, not a runtime crash * **Propagates component prop types** through the entire chain -- once you pick a type, props are typed to match that component ```tsx const rilay = ril.create() .addComponent('input', { renderer: Input }) // Type now knows about 'input' .addComponent('select', { renderer: Select }); // Type now knows about 'input' | 'select' // TypeScript enforces valid types form.create(rilay, 'test') .add({ id: 'field', type: 'input', props: { /* Input props autocompleted */ } }) .add({ id: 'field2', type: 'unknown' }); // Compile error: 'unknown' is not assignable ``` Because the API is immutable, each `.addComponent()` call returns a new instance with an extended type. Calling `.addComponent()` without capturing the return value has no effect. This is by design: it prevents accidental mutations and ensures every variable has a precise, narrow type. Separation of Layers [#separation-of-layers] RilayKit has four distinct layers, each with a single responsibility: 1. **Registry** (`ril.create().addComponent(...)`) -- Maps type names to renderers and default props. Configured once per application. 2. **Builder** (`form.create(rilay).add(...)`) -- Constructs form and workflow configurations as data structures. Pure configuration, no React dependency. 3. **Provider** (``) -- React context that manages state, validation, and rendering orchestration. 4. **Renderer** (your components) -- Receives props from the provider, renders UI. You own this layer completely. ``` Registry (ril.create) | component definitions Builder (form.create / flow.create) | form/workflow config Provider ( / ) | state + props Renderer (your components) | HTML + CSS User ``` This separation has practical consequences. The Registry and Builder layers have no React dependency -- they can run in Node, in tests, in build scripts. The Provider is the only layer that touches React. The Renderer is entirely your code. This means you can: * Test form configurations without rendering anything * Generate configs on the server and send them to the client * Share registry definitions across multiple applications * Replace renderers without changing any configuration Serialization as a First-Class Concern [#serialization-as-a-first-class-concern] Form and workflow configurations can be serialized with `.toJSON()` and restored with `.fromJSON()`. This is not an afterthought -- the schema-first design makes serialization natural because configurations are already plain data structures. This enables: * **Visual form builders** -- Generate configs from drag-and-drop interfaces, store them as JSON * **Server-driven forms** -- Store configs in databases, serve them via API, render them on the client * **Version control** -- Diff form definitions like any other data, track changes over time * **A/B testing** -- Clone configs with `.clone()` and modify them at runtime to test variations ```tsx // Serialize a form to JSON const json = myForm.toJSON(); const jsonString = JSON.stringify(json); // Later, rehydrate it const restored = form.create(rilay).fromJSON(JSON.parse(jsonString)); ``` Serialization is structural -- it preserves field definitions, validation rules, and conditions, but not renderer functions. After deserializing with `fromJSON()`, you must use the same `ril` instance to provide the component renderers. # Renderers import { Callout } from 'fumadocs-ui/components/callout'; While the Component Registry defines *what* is rendered for each field, **Renderers** control *how* structural elements are laid out. They give you complete control over the HTML wrappers around your fields, rows, and buttons. Rilaykit does **not** provide default renderers for structural elements like rows or submit buttons. You **must** define your own renderers for these components. If a renderer is not configured for an element that needs to be rendered, Rilaykit will throw an error. This approach ensures that Rilaykit integrates seamlessly and predictably into your existing design system or CSS framework without injecting any unwanted markup or styles. Available Renderers [#available-renderers] You provide custom renderers by setting them on the `ril` instance. Here are the most common renderers you **must** configure for `@rilaykit/forms`: * `setBodyRenderer`: Wraps the entire body of the form. * `setRowRenderer`: Wraps a row of one or more fields. * `setSubmitButtonRenderer`: Renders the main submission button. The `@rilaykit/workflow` package adds more mandatory renderers: * `setStepperRenderer`: Renders the progress stepper. * `setWorkflowNextButtonRenderer`: Renders the "Next" button. * `setWorkflowPreviousButtonRenderer`: Renders the "Previous" button. * `setWorkflowSkipButtonRenderer` (if used): Renders the "Skip" button. Example: Configuring Mandatory Renderers [#example-configuring-mandatory-renderers] Here’s how you would set up the basic renderers required for a form. 1. Define Your Renderer Components [#1-define-your-renderer-components] A renderer is just a React component that receives specific props and must render `{props.children}`. ```tsx title="components/renderers.tsx" import type { FormRowRendererProps, FormSubmitButtonRendererProps } from '@rilaykit/core'; export const MyRowRenderer: React.FC = ({ children, row }) => { // Simple grid layout based on number of fields const gridCols = `grid-cols-${row.fields.length}`; return
{children}
; }; export const MySubmitButtonRenderer: React.FC = (props) => { return ( ); }; ``` 2. Set the Renderers on the ril Instance [#2-set-the-renderers-on-the-ril-instance] Now, register these components with your shared `ril` instance. ```tsx title="lib/rilay.ts" import { rilay } from './rilay'; // Your existing instance import { MyRowRenderer, MySubmitButtonRenderer } from '@/components/renderers'; // Let's assume you have a BodyRenderer as well // import { MyBodyRenderer } from '@/components/renderers'; rilay // .setBodyRenderer(MyBodyRenderer) .setRowRenderer(MyRowRenderer) .setSubmitButtonRenderer(MySubmitButtonRenderer); ``` Now, any form or workflow component built with this `rilay` instance knows exactly how to render its structural parts. Overriding Renderers with renderAs [#overriding-renderers-with-renderas] Even with global renderers configured, you can always override the rendering for a specific component instance by using the `renderAs="children"` prop. This is perfect for one-off customizations. ```tsx import { FormSubmitButton } from '@rilaykit/forms'; {(props) => ( )} ``` This powerful pattern allows for both global consistency and local flexibility. # The `ril` Instance import { Callout } from 'fumadocs-ui/components/callout'; The `ril` instance is the **heart of every RilayKit application**. It serves as a central configuration hub for all your form and workflow configurations, providing type safety and component reusability across your entire application. **Best Practice**: Create a single, shared `ril` instance for your application and export it from a central file (e.g., `lib/rilay.ts`). Core Responsibilities [#core-responsibilities] The `ril` instance has two primary roles: 1. **Component Registry**: Stores your component configurations and custom renderers 2. **Builder Factory**: Creates type-safe `form` and `workflow` builders that inherit its configuration Creating and Configuring [#creating-and-configuring] You create an instance by calling `ril.create()` and then chain configuration methods to it. ```tsx title="lib/rilay.ts" import { ril } from 'rilaykit'; import { MyTextInput } from '@/components/MyTextInput'; import { myRowRenderer, mySubmitButtonRenderer, myBodyRenderer } from '@/renderers'; export const rilay = ril .create() // Register a component type named 'text' .addComponent('text', { name: 'Text Input', renderer: MyTextInput, }) // Configure all renderers at once .configure({ bodyRenderer: myBodyRenderer, rowRenderer: myRowRenderer, submitButtonRenderer: mySubmitButtonRenderer, }); ``` This configured `rilay` instance is now ready to be used to create builders. Management API [#management-api] The `ril` instance is not just a write-only configuration object. It also provides a rich API for managing and inspecting its state, which can be useful for debugging, testing, or building developer tools. Component Management [#component-management] You can dynamically manage the component registry. * **`getComponent(id: string)`**: Retrieves a component's configuration. * **`getAllComponents()`**: Returns an array of all registered components. * **`hasComponent(id: string)`**: Checks for the existence of a component type. * **`removeComponent(id: string)`**: Removes a component from the registry. * **`clear()`**: Removes all registered components. ```tsx // Check if a 'text' component is registered if (rilay.hasComponent('text')) { console.log('Text component is ready.'); } // Get all component names const componentNames = rilay.getAllComponents().map(c => c.name); // -> ['Text Input'] ``` Builder Methods [#builder-methods] When using the `rilaykit` package, the `ril` instance has convenience methods to create builders directly: ```tsx import { ril } from 'rilaykit'; const rilay = ril.create() .addComponent('text', { name: 'Text', renderer: MyTextInput }); // Create a form builder directly from the ril instance const myForm = rilay.form('my-form') .add({ type: 'text', props: { label: 'Name' } }); // Create a workflow builder directly from the ril instance const myWorkflow = rilay.flow('my-workflow', 'My Workflow') .step({ title: 'Step 1', formConfig: myForm }); ``` .form(formId?) [#formformid] Creates a new form builder bound to this `ril` instance. Equivalent to `form.create(rilay, formId)`. .flow(workflowId?, name?, description?) [#flowworkflowid-name-description] Creates a new workflow builder bound to this `ril` instance. Equivalent to `flow.create(rilay, workflowId, name, description)`. When using the modular packages (`@rilaykit/core`, `@rilaykit/forms`, `@rilaykit/workflow`), use `form.create(rilay)` and `flow.create(rilay)` instead. Introspection & Debugging [#introspection--debugging] Two methods are particularly useful for understanding the state of your configuration. getStats() [#getstats] The `.getStats()` method returns a snapshot of the current configuration, including the number of components and which custom renderers have been set. ```ts const stats = rilay.getStats(); console.log(stats); /* Output: { total: 1, byType: { text: 1 }, hasCustomRenderers: { row: true, body: false, submitButton: false, field: false, stepper: false, workflowNextButton: false, workflowPreviousButton: false, workflowSkipButton: false } } */ ``` validate() [#validate] The `.validate()` method performs an internal check of your configuration to find common errors, like duplicate component IDs or components without a renderer. ```ts const errors = rilay.validate(); if (errors.length > 0) { console.error('Rilay configuration errors:', errors); } ``` These management and introspection APIs make the `ril` instance a powerful and transparent tool, not just a black box. # TypeScript Support import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; Type safety is not an afterthought in RilayKit — it is the core design principle. The entire API is built around **type propagation**: types flow from your component definitions through form configurations to rendered fields, catching errors before your code ever runs. Before and After [#before-and-after] To understand the difference, compare building a form with manual typing versus RilayKit's type propagation. ```tsx // Types are disconnected from usage interface FormData { email: string; password: string; } function LoginForm() { const [values, setValues] = useState({ email: '', password: '' }); const [errors, setErrors] = useState>>({}); const validate = () => { const newErrors: typeof errors = {}; if (!values.email) newErrors.email = 'Required'; if (!values.email.includes('@')) newErrors.email = 'Invalid email'; if (!values.password) newErrors.password = 'Required'; if (values.password.length < 8) newErrors.password = 'Too short'; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; return (
validate() && handleSubmit(values)}> {/* No connection between field IDs and types */} setValues(v => ({ ...v, email: e.target.value }))} /> {errors.email && {errors.email}} setValues(v => ({ ...v, password: e.target.value }))} /> {errors.password && {errors.password}}
); } ``` Issues: types are manually defined and disconnected from validation, no autocompletion for field IDs, component props are unchecked, validation logic is duplicated.
```tsx import { ril, required, email, minLength } from '@rilaykit/core'; import { Form, FormField } from '@rilaykit/forms'; // Types flow automatically from component registration const rilay = ril.create() .addComponent('input', { renderer: TextInput }); const loginForm = form.create(rilay, 'login') .add({ id: 'email', type: 'input', // Autocompleted from registry props: { label: 'Email' }, // Typed as TextInputProps validation: { validate: [required(), email()] }, }) .add({ id: 'password', type: 'input', props: { label: 'Password', type: 'password' }, validation: { validate: [required(), minLength(8)] }, }); // Render — fieldId autocompletes to 'email' | 'password'
``` Every piece is connected: component types, props, field IDs, and validation are all verified at compile time.
Type Propagation [#type-propagation] Type propagation is the mechanism by which TypeScript tracks your registered components and enforces their types throughout the API. It works through **generic type accumulation** on the `ril` instance. Step 1: Component Registration [#step-1-component-registration] Each call to `.addComponent()` extends the generic type parameter of the `ril` instance: ```tsx title="lib/rilay.ts" import { ril } from '@rilaykit/core'; import { TextInput, SelectInput } from '@/components'; interface TextInputProps { label: string; placeholder?: string; } interface SelectInputProps { label: string; options: Array<{ value: string; label: string }>; multiple?: boolean; } // Each .addComponent() call extends the type export const rilay = ril .create() .addComponent('text', { name: 'Text Input', renderer: TextInput, }) .addComponent('select', { name: 'Select Input', renderer: SelectInput, }); // rilay is now typed as ril<{ text: TextInputProps; select: SelectInputProps }> ``` Step 2: Form Building [#step-2-form-building] When building forms, the accumulated types flow into `.add()`: ```tsx const userForm = form.create(rilay, 'user-form') .add({ id: 'username', type: 'text', // Autocompletes: 'text' | 'select' props: { label: 'Username', placeholder: 'Enter your username', // Valid TextInputProps }, }); ``` Step 3: Props Inference [#step-3-props-inference] Once you select a component type, the `props` object is automatically typed to match that component's interface: ```tsx .add({ id: 'country', type: 'select', props: { label: 'Country', options: [{ value: 'us', label: 'United States' }], // Required for SelectInputProps multiple: true, // Available on SelectInputProps }, }) ``` Error Prevention at Compile Time [#error-prevention-at-compile-time] RilayKit catches entire categories of bugs before your code runs. Invalid Component Type [#invalid-component-type] ```tsx form.create(rilay, 'test').add({ type: 'checkbox', // Error: Type '"checkbox"' is not assignable to type '"text" | "select"' props: { label: 'Accept' }, }); ``` **Fix:** Register the component first with `.addComponent('checkbox', { renderer: ... })`. Invalid Props for Component Type [#invalid-props-for-component-type] ```tsx form.create(rilay, 'test').add({ id: 'email', type: 'text', props: { label: 'Email', options: [], // Error: 'options' does not exist on TextInputProps multiple: false, // Error: 'multiple' does not exist on TextInputProps }, }); ``` **Fix:** Use props that match the component's interface, or use `type: 'select'` which accepts `options`. Missing Required Props [#missing-required-props] ```tsx form.create(rilay, 'test').add({ id: 'category', type: 'select', props: { label: 'Category', // Error: Property 'options' is missing in type }, }); ``` **Fix:** Provide all required props defined in the component's interface. Unused Return Value [#unused-return-value] ```tsx const base = ril.create(); base.addComponent('text', { renderer: TextInput }); // 'base' still has no components — addComponent returns a NEW instance form.create(base, 'test').add({ type: 'text', // Error: no component 'text' registered }); ``` **Fix:** Always chain calls or assign the return value: ```tsx const rilay = ril.create().addComponent('text', { renderer: TextInput }); ``` Immutable API [#immutable-api] The `ril` instance is immutable — each `.addComponent()` returns a **new** instance with an extended type. This ensures type safety across your application: ```tsx const base = ril.create(); // base is ril> — no components const withText = base.addComponent('text', { renderer: TextInput }); // withText is ril<{ text: TextInputProps }> const withBoth = withText.addComponent('select', { renderer: SelectInput }); // withBoth is ril<{ text: TextInputProps; select: SelectInputProps }> ``` Because the API is immutable, always chain your `.addComponent()` calls or assign the final result. Calling `.addComponent()` without using the return value has no effect. Multi-Field Rows [#multi-field-rows] Type safety is maintained when adding multiple fields to the same row: ```tsx const registrationForm = form.create(rilay, 'registration') .add( { id: 'firstName', type: 'text', // Typed as TextInputProps props: { label: 'First Name' }, }, { id: 'lastName', type: 'text', // Typed as TextInputProps props: { label: 'Last Name' }, } ); ``` Workflows [#workflows] Type safety extends to workflows. When building steps, the `formConfig` accepts both a `FormConfiguration` and a `form` builder instance: ```tsx const step1Form = form.create(rilay, 'personal-info') .add({ id: 'name', type: 'text', props: { label: 'Name' } }); const workflow = flow.create(rilay, 'onboarding', 'Onboarding') .step({ id: 'personal', title: 'Personal Information', formConfig: step1Form, // Type-checked }); ``` IDE Experience [#ide-experience] With RilayKit's type propagation system, your IDE provides: * **Autocompletion** for component types, props, and field IDs * **Inline error highlighting** for invalid types or props before running code * **Go to definition** navigation from `type: 'text'` to the `TextInputProps` interface * **Rename refactoring** — rename component types safely across the entire codebase Best Practices [#best-practices] * Define explicit prop interfaces for each component — this gives the best autocompletion and documentation * Use a single shared `rilay` instance exported from a central file (e.g., `lib/rilay.ts`) * Let TypeScript infer the generic types — avoid manually specifying them * Use TypeScript 5.0+ for the best type inference performance * Enable `strict` mode in your `tsconfig.json` for complete type safety # Validation import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; RilayKit ships with a built-in validation engine that implements the [Standard Schema](https://standardschema.dev) specification. This means you can use RilayKit's own validators **or** any Standard Schema-compatible library (Zod 3.24+, Valibot 1.0+, ArkType 2.0+) interchangeably -- without adapters. Every built-in validator returns a `StandardSchemaV1` object, so it can be mixed freely with Zod, Valibot, ArkType, or any other compliant library in the same `validate` array. Built-in Validators [#built-in-validators] All built-in validators are imported from `@rilaykit/core`. Each one accepts an optional custom error message and returns a Standard Schema object. required(message?) [#requiredmessage] Validates that the value is non-empty. Catches empty strings, `null`, `undefined`, empty arrays, and empty objects. ```ts import { required } from '@rilaykit/core'; required() // Default: 'This field is required' required('Please fill this in') // Custom message ``` email(message?) [#emailmessage] Validates email format. ```ts import { email } from '@rilaykit/core'; email() // Default: 'Please enter a valid email address' email('A valid email is required') // Custom message ``` url(message?) [#urlmessage] Validates URL format using the native `URL` constructor. ```ts import { url } from '@rilaykit/core'; url() // Default: 'Please enter a valid URL' url('Must be a valid link') // Custom message ``` minLength(min, message?) [#minlengthmin-message] Validates minimum string length. ```ts import { minLength } from '@rilaykit/core'; minLength(3) // Default: 'Must be at least 3 characters long' minLength(8, 'Password is too short') // Custom message ``` maxLength(max, message?) [#maxlengthmax-message] Validates maximum string length. ```ts import { maxLength } from '@rilaykit/core'; maxLength(100) // Default: 'Must be no more than 100 characters long' maxLength(50, 'Keep it under 50 characters') // Custom message ``` pattern(regex, message?) [#patternregex-message] Validates against a regular expression. ```ts import { pattern } from '@rilaykit/core'; pattern(/^[a-z0-9]+$/) // Default: 'Value does not match required pattern' pattern(/^\d{5}$/, 'Must be a 5-digit zip code') // Custom message ``` number(message?) [#numbermessage] Validates that the value is a valid number. Also coerces strings to numbers. ```ts import { number } from '@rilaykit/core'; number() // Default: 'Must be a valid number' number('Enter a numeric value') // Custom message ``` min(minValue, message?) [#minminvalue-message] Validates a minimum numeric value. Coerces strings to numbers before comparison. ```ts import { min } from '@rilaykit/core'; min(0) // Default: 'Must be at least 0' min(18, 'You must be 18 or older') // Custom message ``` max(maxValue, message?) [#maxmaxvalue-message] Validates a maximum numeric value. Coerces strings to numbers before comparison. ```ts import { max } from '@rilaykit/core'; max(100) // Default: 'Must be no more than 100' max(999, 'Value cannot exceed 999') // Custom message ``` custom(fn, message?) [#customtfn-message] Creates a synchronous custom validator. The function receives the field value and must return `true` (valid) or `false` (invalid). ```ts import { custom } from '@rilaykit/core'; custom( (value) => value.startsWith('SK_'), 'Must start with SK_' ) // Default message: 'Validation failed' ``` async(fn, message?) [#asynctfn-message] Creates an asynchronous custom validator. The function receives the field value and must return a `Promise`. ```ts import { async } from '@rilaykit/core'; async( async (value) => { const res = await fetch(`/api/check-username?name=${value}`); const { available } = await res.json(); return available; }, 'Username is already taken' ) // Default message: 'Async validation failed' ``` combine(...schemas) [#combinetschemas] Combines multiple Standard Schema validators into a single schema. Runs them in sequence and accumulates all issues. ```ts import { combine, required, minLength, email } from '@rilaykit/core'; const emailValidator = combine( required('Email is required'), email(), minLength(5, 'Email seems too short') ); ``` Standard Schema Support [#standard-schema-support] The [Standard Schema specification](https://standardschema.dev) defines a common interface for validation libraries. Any library that implements `StandardSchemaV1` works directly with RilayKit -- no wrappers, no adapters. | Library | Version | Standard Schema Support | | ----------- | ------- | ----------------------- | | **Zod** | 3.24.0+ | Native support | | **Valibot** | 1.0+ | Native support | | **ArkType** | 2.0+ | Native support | ```tsx import { z } from 'zod'; import { form } from '@rilaykit/forms'; const signupForm = form.create(rilay, 'signup') .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [z.string().email()], }, }); ``` ```tsx import * as v from 'valibot'; import { form } from '@rilaykit/forms'; const signupForm = form.create(rilay, 'signup') .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [v.pipe(v.string(), v.email())], }, }); ``` ```tsx import { type } from 'arktype'; import { form } from '@rilaykit/forms'; const signupForm = form.create(rilay, 'signup') .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [type('string.email')], }, }); ``` Combining Validators [#combining-validators] You can freely mix built-in validators and third-party schemas in the same `validate` array. They are executed in order and all issues are accumulated. ```tsx import { required, minLength } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; import { z } from 'zod'; const profileForm = form.create(rilay, 'profile') .add({ id: 'username', type: 'input', props: { label: 'Username' }, validation: { validate: [ required(), // RilayKit built-in minLength(3), // RilayKit built-in z.string().regex(/^[a-z0-9]+$/), // Zod schema ], }, }); ``` The `validation` property is always an **object** with a `validate` key -- never a bare array. Use `{ validate: [...] }`, not `[...]`. Validation Configuration [#validation-configuration] Field-Level: FieldValidationConfig [#field-level-fieldvalidationconfig] Each field accepts an optional `validation` object that controls what, when, and how validation runs. ```ts interface FieldValidationConfig { /** One or more Standard Schema validators */ validate?: StandardSchema | StandardSchema[]; /** Run validation every time the value changes (default: false) */ validateOnChange?: boolean; /** Run validation when the field loses focus (default: false) */ validateOnBlur?: boolean; /** Debounce delay in milliseconds before validation fires */ debounceMs?: number; } ``` **Usage example:** ```tsx import { required, email } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; const contactForm = form.create(rilay, 'contact') .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: [required(), email()], validateOnBlur: true, debounceMs: 300, }, }) .add({ id: 'name', type: 'input', props: { label: 'Name' }, validation: { validate: [required()], validateOnChange: true, }, }); ``` Form-Level Validation [#form-level-validation] For cross-field validation (e.g. "confirm password must match password"), use `FormValidationConfig` via the `setValidation()` method on a form builder. FormValidationConfig [#formvalidationconfig] ```ts interface FormValidationConfig { /** One or more Standard Schema validators applied to the entire form data */ validate?: StandardSchema | StandardSchema[]; /** Run form-level validation on submit (default: depends on config) */ validateOnSubmit?: boolean; /** Run form-level validation on workflow step change */ validateOnStepChange?: boolean; } ``` **Usage with `setValidation()`:** ```tsx import { z } from 'zod'; import { form } from '@rilaykit/forms'; const passwordSchema = z.object({ password: z.string().min(8, 'Password too short'), confirmPassword: z.string(), }).refine( (data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ['confirmPassword'], } ); const changePasswordForm = form.create(rilay, 'change-password') .add({ id: 'password', type: 'password', props: { label: 'New Password' }, }) .add({ id: 'confirmPassword', type: 'password', props: { label: 'Confirm Password' }, }) .setValidation({ validate: passwordSchema, }); ``` ```tsx import { custom } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; const passwordMatch = custom( (data: any) => data.password === data.confirmPassword, "Passwords don't match" ); const changePasswordForm = form.create(rilay, 'change-password') .add({ id: 'password', type: 'password', props: { label: 'New Password' }, }) .add({ id: 'confirmPassword', type: 'password', props: { label: 'Confirm Password' }, }) .setValidation({ validate: passwordMatch, }); ``` Utility Functions [#utility-functions] RilayKit exports two utility functions for advanced use cases. isStandardSchema(value) [#isstandardschemavalue] Type guard that checks whether a value implements the Standard Schema interface (`~standard` property with version 1, a vendor string, and a validate function). ```ts import { isStandardSchema } from '@rilaykit/core'; isStandardSchema(required()); // true isStandardSchema(z.string().email()); // true isStandardSchema('not a schema'); // false ``` combineSchemas(...schemas) [#combineschemasschemas] Combines multiple Standard Schema objects into a single schema. Unlike passing an array to `validate`, this produces one unified schema object -- useful when you need to pass a single schema somewhere that does not accept arrays. ```ts import { combineSchemas, required, email } from '@rilaykit/core'; import { form } from '@rilaykit/forms'; import { z } from 'zod'; const combinedEmailSchema = combineSchemas( required('Email is required'), email(), z.string().min(5, 'Email too short') ); // Use as a single schema const contactForm = form.create(rilay, 'contact') .add({ id: 'email', type: 'input', props: { label: 'Email' }, validation: { validate: combinedEmailSchema, // single schema, not an array }, }); ``` The difference between `combine()` (from validators) and `combineSchemas()` (from utilities) is purely organizational -- they produce the same result. Use whichever import feels clearest in context. # Advanced Forms import { Callout } from 'fumadocs-ui/components/callout'; The form builder is more than just a one-time configuration tool. It provides a powerful API to dynamically manage, inspect, and even serialize your form definitions. Dynamic Form Management [#dynamic-form-management] You can modify a form configuration *after* it has been created. This is useful for building dynamic UIs where the form structure changes based on user interaction. The builder instance provides several methods for this: * **`.updateField(fieldId, updates)`**: Modifies an existing field's configuration. * **`.removeField(fieldId)`**: Removes a field from the form entirely. * **`.getField(fieldId)`**: Retrieves the configuration for a specific field. * **`.getFields()`**: Returns a flat array of all field configurations. * **`.getRows()`**: Returns the array of all row configurations. * **`.clear()`**: Removes all fields and rows from the configuration. ```tsx import { rilay } from '@/lib/rilay'; import { form } from '@rilaykit/forms'; // Start with a base configuration const myDynamicForm = form.create(rilay, 'dynamic-form') .add({ id: 'username', type: 'text', props: { label: 'Username' } }); // Later, based on some condition... if (shouldAddEmail) { myDynamicForm.add({ id: 'email', type: 'email', props: { label: 'Email' } }); } // Or update a field myDynamicForm.updateField('username', { props: { label: 'Please enter your desired username' } }); // Finally, build the config to pass to the
component const formConfig = myDynamicForm.build(); ``` Remember that the builder methods mutate the builder instance itself. If you need to create multiple variations, use the `.clone()` method first. Cloning [#cloning] The `.clone(newFormId?)` method creates a deep copy of the builder instance, allowing you to create variations of a form without affecting the original. ```tsx const baseForm = form.create(rilay, 'base-form') .add({ id: 'name', type: 'text', props: { label: 'Name' } }); // Create a version for admins const adminForm = baseForm.clone('admin-form') .add({ id: 'adminNotes', type: 'textarea', props: { label: 'Admin Notes' } }); // The original `baseForm` is unaffected const baseFormConfig = baseForm.build(); const adminFormConfig = adminForm.build(); ``` Serialization (JSON Import/Export) [#serialization-json-importexport] Form configurations can be serialized to and from JSON. This is an incredibly powerful feature for: * Storing form definitions in a database. * Building visual form builders where the UI generates a JSON definition. * Transmitting form structures over the network. * **`.toJSON()`**: Exports the current form structure as a JSON-serializable object. * **`.fromJSON(json)`**: Populates a builder instance from a JSON object. ```tsx import { rilay } from '@/lib/rilay'; // 1. Define a form const originalForm = form.create(rilay, 'question-form') .add({ id: 'question', type: 'text', props: { label: 'Your Question' } }); // 2. Serialize it to JSON const jsonDefinition = originalForm.toJSON(); // -> { id: '...', rows: [...] } const jsonString = JSON.stringify(jsonDefinition); // 3. Later, or in another environment, rehydrate it const newJson = JSON.parse(jsonString); const rehydratedForm = form.create(rilay).fromJSON(newJson); const formConfig = rehydratedForm.build(); ``` Note: The `fromJSON` method populates an *existing* builder. It's best practice to create a new form builder before calling `.fromJSON()` to avoid mutating a shared builder instance. Introspection [#introspection] The `.getStats()` method provides a quick overview of the form's structure. ```ts const stats = myFormBuilder.getStats(); console.log(stats); /* { totalFields: 5, totalRows: 4, averageFieldsPerRow: 1.25, maxFieldsInRow: 2, minFieldsInRow: 1 } */ ``` This is useful for debugging or analyzing the complexity of your forms. # Building Forms import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Callout } from 'fumadocs-ui/components/callout'; Building a form configuration starts from your central `rilay` instance. By calling `rilay.form()` (or `form.create(rilay)` with modular packages), you get a form builder that has access to all your pre-configured components and renderers. Before proceeding, make sure you have created and configured a shared `rilay` instance as shown in the [Your First Form](/docs/getting-started/your-first-form) guide. The Form Building Process [#the-form-building-process] 1. Start with .form() [#1-start-with-form] Call `.form()` on your `rilay` instance to begin a new form definition. You should provide a unique ID for your form. ```tsx import { rilay } from '@/lib/rilay'; const loginForm = rilay.form('login'); ``` 2. Add Fields and Rows [#2-add-fields-and-rows] The builder provides a powerful polymorphic `.add()` method that handles multiple use cases: * **Single field**: `.add(fieldConfig)` - Adds a field on its own row * **Multiple fields**: `.add(field1, field2, field3)` - Adds multiple fields on the same row (max 3) * **Array syntax**: `.add([field1, field2], options)` - Explicit row control with options ```tsx import { rilay } from '@/lib/rilay'; const registrationForm = rilay .form('registration') // Add multiple fields on the same row .add( { id: 'firstName', type: 'text', // This type must exist in your component registry props: { label: 'First Name' }, }, { id: 'lastName', type: 'text', props: { label: 'Last Name' }, } ) // Add a single field on its own row .add({ id: 'email', type: 'email', // Assumes an 'email' type is registered props: { label: 'Email Address' }, }); ``` You can also use the array syntax for explicit control over row options: ```tsx import { rilay } from '@/lib/rilay'; const formWithOptions = rilay .form('styled-form') .add([ { id: 'field1', type: 'text', props: { label: 'Field 1' } }, { id: 'field2', type: 'text', props: { label: 'Field 2' } }, ], { spacing: 'loose', alignment: 'center' }); ``` Auto-Generated IDs [#auto-generated-ids] One of the key improvements in the new API is automatic ID generation. If you don't provide an `id` field, one will be generated for you: ```tsx import { rilay } from '@/lib/rilay'; const quickForm = rilay .form('quick-form') .add( { type: 'text', props: { label: 'Name' } }, // Will get id: 'field-1' { type: 'email', props: { label: 'Email' } }, // Will get id: 'field-2' { type: 'text', props: { label: 'Phone' } } // Will get id: 'field-3' ); ``` When to use .build() [#when-to-use-build] You may have noticed we don't always call `.build()` in our examples. This is because components like `` and workflow steps are smart enough to build the configuration for you. However, you should call `.build()` manually when you need the final, serializable `FormConfiguration` object. For instance: * To serialize the form and save it as JSON. * To pass the configuration to a custom function or a third-party tool. * For debugging purposes, to inspect the generated configuration. ```tsx import { rilay } from '@/lib/rilay'; const formConfig = rilay .form('my-form') .add({ id: 'field1', type: 'text' }) .build(); // Manually build the config console.log(formConfig.allFields); ``` Field Configuration [#field-configuration] The `fieldConfig` object you pass to `.add()` has the following shape: ```ts interface FieldConfig { id?: string; // Optional - auto-generated if not provided type: string; // The component type to render from your registry props?: Record; // Props passed to your component renderer } ``` Submit Options [#submit-options] You can configure default submission behavior at the builder level using `.setSubmitOptions()`. These options control how validation interacts with form submission. ```tsx import { required } from 'rilaykit'; import { rilay } from '@/lib/rilay'; const draftForm = rilay .form('draft-form') .add({ id: 'title', type: 'text', props: { label: 'Title' } }) .add({ id: 'content', type: 'textarea', props: { label: 'Content' }, validation: { validate: required() }, }) .setSubmitOptions({ skipInvalid: true }); ``` Two options are available: | Option | Behavior | | ------------- | ---------------------------------------------------------------------------------------------------------------- | | `force` | Bypass validation entirely and submit all current values as-is. Useful for "save draft" scenarios. | | `skipInvalid` | Run validation (errors are still shown in the UI) but exclude invalid fields from the data passed to `onSubmit`. | These defaults can be overridden at submit-time: ```tsx const { submit } = useFormConfigContext(); await submit({ force: true }); // Bypass validation await submit({ skipInvalid: true }); // Submit without invalid fields ``` When both `force` and `skipInvalid` are set, `force` takes priority (validation is skipped entirely). *** Complete Example: Login Form [#complete-example-login-form] Let's tie everything together. Here is how you would build a complete login form configuration, assuming you have a configured `rilay` instance. This definition can be passed directly to the `` component. ```tsx import { rilay } from '@/lib/rilay'; export const loginForm = rilay .form('login-form') .add( { id: 'email', type: 'email', props: { label: 'Email Address', placeholder: 'Enter your email', }, }, { id: 'password', type: 'password', props: { label: 'Password', placeholder: 'Enter your password', }, } ); ``` This `loginForm` builder instance is now ready to be passed to your `` component. # Form Hooks import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; RilayKit forms are powered by Zustand stores, exposing granular selector hooks that minimize re-renders. Instead of one monolithic hook that causes the entire form to re-render on every change, you pick exactly the state slices you need. All hooks must be used inside a component wrapped by `FormProvider` (or the `` component, which includes it). ```tsx import { useFieldValue, useFieldActions, useFormSubmitState, } from '@rilaykit/forms'; ``` Context Hook [#context-hook] `useFormConfigContext()` returns the full form context, including the form configuration, condition helpers, validation, and submission methods. Use it when you need access to multiple concerns at once, but be aware that it subscribes to the entire context object. ```ts import { useFormConfigContext } from '@rilaykit/forms'; const { formConfig, conditionsHelpers, validateField, validateForm, submit } = useFormConfigContext(); ``` FormConfigContextValue [#formconfigcontextvalue] ```ts interface FormConfigContextValue { formConfig: FormConfiguration; conditionsHelpers: { hasConditionalFields: boolean; getFieldCondition(fieldId: string): ConditionEvaluationResult | undefined; isFieldVisible(fieldId: string): boolean; isFieldDisabled(fieldId: string): boolean; isFieldRequired(fieldId: string): boolean; isFieldReadonly(fieldId: string): boolean; }; validateField(fieldId: string, value?: unknown): Promise; validateForm(): Promise; submit(eventOrOptions?: React.FormEvent | SubmitOptions): Promise; } ``` The `submit` function accepts either a `React.FormEvent` (for ``) or a `SubmitOptions` object for programmatic control: ```ts interface SubmitOptions { force?: boolean; // Skip validation, submit all values skipInvalid?: boolean; // Run validation, exclude invalid fields } ``` ```tsx const { submit } = useFormConfigContext(); await submit(); // Standard: validate then submit await submit({ force: true }); // Skip validation entirely await submit({ skipInvalid: true }); // Submit only valid fields ``` `useFormConfigContext()` re-renders whenever **any** part of the context changes. Prefer the granular hooks below for performance-sensitive components. Field State Hooks [#field-state-hooks] These hooks are **read-only selectors** that subscribe to a single field slice in the Zustand store. Each hook only triggers a re-render when its specific slice changes. | Hook | Returns | Re-renders when | | ---------------------------------- | ------------------- | ------------------------- | | `useFieldValue(fieldId)` | `T` | Field value changes | | `useFieldErrors(fieldId)` | `ValidationError[]` | Field errors change | | `useFieldTouched(fieldId)` | `boolean` | Field touch state changes | | `useFieldValidationState(fieldId)` | `ValidationState` | Validation state changes | | `useFieldConditions(fieldId)` | `FieldConditions` | Field conditions change | | `useFieldState(fieldId)` | `FieldState` | Any field state changes | Usage [#usage] ```tsx import { useFieldValue, useFieldErrors } from '@rilaykit/forms'; function PriceDisplay() { const price = useFieldValue('price'); const errors = useFieldErrors('price'); return (
Current price: {price ?? 'N/A'} {errors.map((err) => (

{err.message}

))}
); } ```
```tsx import { useFieldState } from '@rilaykit/forms'; function FieldDebug({ fieldId }: { fieldId: string }) { const { value, errors, validationState, touched, dirty } = useFieldState(fieldId); return (
{JSON.stringify({ value, errors, validationState, touched, dirty }, null, 2)}
); } ```
Types [#types] ```ts type ValidationState = 'idle' | 'validating' | 'valid' | 'invalid'; interface FieldConditions { visible: boolean; disabled: boolean; required: boolean; readonly: boolean; } interface FieldState { value: unknown; errors: ValidationError[]; validationState: ValidationState; touched: boolean; dirty: boolean; } ``` Form State Hooks [#form-state-hooks] These hooks subscribe to **form-level** slices. Like field hooks, they only re-render when their specific slice changes. | Hook | Returns | Re-renders when | | ---------------------- | ------------------------------------ | ---------------------------- | | `useFormSubmitting()` | `boolean` | Submit state changes | | `useFormValid()` | `boolean` | Validity changes | | `useFormDirty()` | `boolean` | Dirty state changes | | `useFormValues()` | `Record` | Any value changes | | `useFormSubmitState()` | `{ isSubmitting, isValid, isDirty }` | Submit-related state changes | Usage [#usage-1] ```tsx import { useFormSubmitState } from '@rilaykit/forms'; function SubmitButton() { const { isSubmitting, isValid, isDirty } = useFormSubmitState(); return ( ); } ``` `useFormValues()` re-renders whenever **any** field value changes. If you only need a single field, prefer `useFieldValue(fieldId)` instead. Action Hooks [#action-hooks] Action hooks return **stable function references** that call directly into the Zustand store. They never subscribe to state, so they never cause re-renders. Action hooks return stable references and **never** trigger re-renders. You can safely pass them as props or use them in event handlers without worrying about performance. Field Actions [#field-actions] `useFieldActions(fieldId)` returns actions scoped to a single field. ```ts import { useFieldActions } from '@rilaykit/forms'; const { setValue, setTouched, setErrors, clearErrors, setValidationState } = useFieldActions('email'); // Update the field value programmatically setValue('user@example.com'); // Mark the field as touched (e.g., on blur) setTouched(); // Set validation errors manually setErrors([{ message: 'Invalid email', code: 'INVALID_EMAIL' }]); // Clear all errors for this field clearErrors(); // Set validation state directly setValidationState('validating'); ``` UseFieldActionsResult [#usefieldactionsresult] ```ts interface UseFieldActionsResult { setValue: (value: unknown) => void; setTouched: () => void; setErrors: (errors: ValidationError[]) => void; clearErrors: () => void; setValidationState: (state: ValidationState) => void; } ``` Form Actions [#form-actions] `useFormActions()` returns actions that operate at the form level. ```ts import { useFormActions } from '@rilaykit/forms'; const { setValue, setTouched, setErrors, setSubmitting, reset, setFieldConditions } = useFormActions(); // Set any field's value setValue('firstName', 'Ada'); // Reset the entire form to default values reset(); // Reset with specific values reset({ firstName: 'Ada', lastName: 'Lovelace' }); // Programmatically set field conditions setFieldConditions('phoneNumber', { visible: true, disabled: false, required: true, readonly: false, }); ``` UseFormActionsResult [#useformactionsresult] ```ts interface UseFormActionsResult { setValue: (fieldId: string, value: unknown) => void; setTouched: (fieldId: string) => void; setErrors: (fieldId: string, errors: ValidationError[]) => void; setSubmitting: (isSubmitting: boolean) => void; reset: (values?: Record) => void; setFieldConditions: (fieldId: string, conditions: FieldConditions) => void; } ``` Condition Hooks [#condition-hooks] These hooks evaluate conditional behaviors (visibility, disabled state, required state, readonly state) for fields based on form data. useConditionEvaluation [#useconditionevaluation] Evaluates a `ConditionalBehavior` configuration against the provided form data and returns the resolved states. ```ts import { useConditionEvaluation } from '@rilaykit/forms'; const { visible, disabled, required, readonly } = useConditionEvaluation( fieldConfig.conditions, // ConditionalBehavior | undefined formData, // Record { visible: true }, // optional default state overrides ); ``` The result is memoized and only recomputed when `conditions` or `formData` change. useFormConditions [#useformconditions] Evaluates all field conditions for an entire form configuration at once. This is what `FormProvider` uses internally. ```ts import { useFormConditions } from '@rilaykit/forms'; const { fieldConditions, // Record hasConditionalFields, // boolean getFieldCondition, // (fieldId: string) => ConditionEvaluationResult | undefined isFieldVisible, // (fieldId: string) => boolean isFieldDisabled, // (fieldId: string) => boolean isFieldRequired, // (fieldId: string) => boolean isFieldReadonly, // (fieldId: string) => boolean } = useFormConditions({ formConfig, formValues }); ``` useFieldConditionsLazy [#usefieldconditionslazy] Lazy evaluation with caching. Reads conditions from the Zustand store and only evaluates when form values actually change (based on a values hash). ```ts import { useFieldConditionsLazy } from '@rilaykit/forms'; const conditions = useFieldConditionsLazy('myField', { conditions: fieldConfig.conditions, // ConditionalBehavior | undefined skip: false, // skip evaluation entirely }); if (!conditions.visible) return null; ``` useConditionEvaluator [#useconditionevaluator] Returns a memoized evaluator function that you can call imperatively for any field. Useful when you need to evaluate conditions for multiple fields on-demand without triggering re-renders. ```ts import { useConditionEvaluator } from '@rilaykit/forms'; const evaluate = useConditionEvaluator(); // Call for any field, at any time const nameConditions = evaluate('name', nameFieldConfig.conditions); const emailConditions = evaluate('email', emailFieldConfig.conditions); ``` Internal Hooks [#internal-hooks] These hooks are used internally by RilayKit components. You rarely need them directly, but they are exported for advanced use cases. | Hook | Purpose | | ---------------------------- | --------------------------------------------------------------------------- | | `useFormValidationWithStore` | Wires field and form validation to the Zustand store | | `useFormSubmissionWithStore` | Handles form submission lifecycle with the store | | `useFormMonitoring` | Tracks form renders, validations, and submissions for performance profiling | Best Practices [#best-practices] 1. **Prefer granular hooks over `useFormConfigContext()`**. A component that only needs a field value should use `useFieldValue(fieldId)` rather than pulling the entire context. 2. **Use `useFieldActions` for programmatic field changes**. Since actions are stable references, they are safe to pass as props without causing unnecessary re-renders in child components. 3. **Use `useFormSubmitState()` for submit buttons**. It bundles exactly the three values a submit button needs (`isSubmitting`, `isValid`, `isDirty`) into a single hook with minimal subscriptions. 4. **Combine read and action hooks in the same component**. A typical field component pairs `useFieldValue` (read) with `useFieldActions` (write) to achieve optimal re-render boundaries. ```tsx import { useFieldValue, useFieldActions, useFieldErrors } from '@rilaykit/forms'; function CustomInput({ fieldId }: { fieldId: string }) { const value = useFieldValue(fieldId); const errors = useFieldErrors(fieldId); const { setValue, setTouched } = useFieldActions(fieldId); return (
setValue(e.target.value)} onBlur={() => setTouched()} /> {errors.map((err) => (

{err.message}

))}
); } ``` # Rendering Forms import { Callout } from 'fumadocs-ui/components/callout'; Once you have a `formConfig`, the `@rilaykit/forms` package provides a set of components to render it. Remember, these components rely on the mandatory renderers you configured on your `ril` instance. The Component [#the-form-component] The `` component is the root of your form. It's a stateful component that manages the entire form lifecycle. ```tsx import { Form } from '@rilaykit/forms'; import { formConfig } from '@/config/my-form'; function MyFormPage() { const handleSubmit = (data) => { // This is called only when the form is valid console.log('Form submitted:', data); }; const handleFieldChange = (fieldId, value, allData) => { console.log(`Field '${fieldId}' changed to:`, value); console.log('Current form data:', allData); }; return ( {/* Form layout components go here */} ); } ``` Props [#props] * `formConfig`: The configuration object generated by the `formBuilder`. * `onSubmit`: A callback function that receives the form data when the form is successfully submitted. By default, it will not be called if the form is invalid. This behavior can be changed with [submit options](/docs/forms/building-forms#submit-options). * `onFieldChange` (optional): A callback that fires whenever any field's value changes. It receives the `fieldId`, the new `value`, and a snapshot of the entire form's data. * `defaultValues` (optional): An object to populate the form with initial values. The keys should match the `id` of your fields. Layout Components [#layout-components] Inside the `
` component, you can use layout components to control how the fields are rendered. [#formbody] This is the simplest way to render your form. The `` component will iterate through the `rows` in your `formConfig` and render them using your registered `FormBodyRenderer` and `FormRowRenderer`. ```tsx Submit ``` for Custom Layouts [#formfield-for-custom-layouts] If you need a fully custom layout that doesn't follow the row structure defined in your `formConfig`, you can use the `` component. This allows you to place any field, anywhere you want. ```tsx

User Details


Save Changes
``` When using ``, you are responsible for the layout. The `FormRowRenderer` is not used. This gives you maximum flexibility. [#formsubmitbutton] This is a smart component that handles form submission using your `FormSubmitButtonRenderer`. It is automatically disabled if the form is invalid or currently submitting. You can customize its content via its `children`. ```tsx {(props) => ( props.isSubmitting ? 'Saving...' : 'Save Profile' )} ``` The `children` can also be a simple string or element, like `Submit`. # Repeatable Fields import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; Repeatable fields allow users to add, remove, and reorder groups of fields at runtime. Think "Add another item", "Add another contact", or any list-like structure within a form. Defining Repeatable Fields [#defining-repeatable-fields] Use `.addRepeatable()` on the form builder to define a repeatable group. It takes an ID and a callback that receives a `RepeatableBuilder`. ```tsx import { form } from '@rilaykit/forms'; import { rilay } from '@/lib/rilay'; import { required } from '@rilaykit/core'; const orderForm = form .create(rilay, 'order') .add({ id: 'customerName', type: 'text', props: { label: 'Customer' } }) .addRepeatable('items', (r) => r .add( { id: 'name', type: 'text', props: { label: 'Item' }, validation: { validate: required() } }, { id: 'qty', type: 'number', props: { label: 'Qty' } } ) .min(1) .max(10) .defaultValue({ name: '', qty: 1 }) ); ``` RepeatableBuilder API [#repeatablebuilder-api] The callback receives a `RepeatableBuilder` with the following methods: | Method | Description | | -------------------------- | ------------------------------------------------------------------------------ | | `.add(...fields)` | Add fields to the template. Same API as `form.add()` — up to 3 fields per row. | | `.addSeparateRows(fields)` | Add fields each on their own row. | | `.min(n)` | Set minimum number of items (defaults to 0). | | `.max(n)` | Set maximum number of items (unlimited if not set). | | `.defaultValue(obj)` | Set default values used when appending new items. | | `.validation(config)` | Set group-level validation for the entire array. | All methods are chainable and return the builder instance. Rendering Repeatable Fields [#rendering-repeatable-fields] Automatic Rendering with [#automatic-rendering-with-formbody] If you use ``, repeatable fields are rendered automatically using your registered `repeatableRenderer` and `repeatableItemRenderer`. ```tsx import { Form, FormBody, FormSubmitButton } from '@rilaykit/forms'; function OrderPage() { return (
console.log(data)}> Place Order ); } ``` Custom Rendering with useRepeatableField [#custom-rendering-with-userepeatablefield] For full control over the layout, use the `useRepeatableField` hook. ```tsx import { useRepeatableField, FormField } from '@rilaykit/forms'; function ItemsList() { const { items, append, remove, move, canAdd, canRemove, count } = useRepeatableField('items'); return (

Items ({count})

{items.map((item) => (
{item.allFields.map((field) => ( ))} {canRemove && ( )}
))} {canAdd && ( )}
); } ``` Hook Return Value [#hook-return-value] ```ts interface UseRepeatableFieldReturn { items: RepeatableFieldItem[]; // Scoped items with composite field IDs append: (defaultValue?: Record) => void; remove: (key: string) => void; move: (fromIndex: number, toIndex: number) => void; canAdd: boolean; // false when count >= max canRemove: boolean; // false when count <= min count: number; } interface RepeatableFieldItem { key: string; // Unique stable key for React index: number; // Current position rows: FormFieldRow[]; // Scoped row configs allFields: FormFieldConfig[]; // Scoped field configs with composite IDs } ``` Default Values [#default-values] When providing default values for a form with repeatables, pass arrays at the top level. RilayKit handles the internal flat-to-nested conversion automatically. ```tsx
{/* ... */}
``` The `onSubmit` callback receives structured data with the same nested format: ```ts // onSubmit receives: { customerName: 'Acme Corp', items: [ { name: 'Widget', qty: 5 }, { name: 'Gadget', qty: 2 }, ] } ``` Validation [#validation] Per-Field Validation [#per-field-validation] Fields within a repeatable group support the same validation as static fields. Validation is applied to each instance independently. ```tsx .addRepeatable('contacts', (r) => r .add({ id: 'email', type: 'email', props: { label: 'Email' }, validation: { validate: [required(), email('Invalid email')], validateOnBlur: true, }, }) .min(1) ) ``` Min/Max Constraints [#minmax-constraints] The `min` and `max` constraints are enforced at validation time. If the number of items is below `min`, a validation error with code `REPEATABLE_MIN_COUNT` is produced. At runtime, the `canAdd` and `canRemove` booleans from `useRepeatableField` reflect these constraints, so you can disable add/remove buttons accordingly. Conditions [#conditions] Fields within repeatable groups support [conditional behavior](/docs/core-concepts/conditions). Conditions are automatically scoped to the current item instance, meaning a condition on `email` within a repeatable will reference that specific item's `email` value, not another item's. ```tsx import { when } from '@rilaykit/core'; .addRepeatable('contacts', (r) => r .add( { id: 'type', type: 'select', props: { label: 'Type', options: ['email', 'phone'] } }, { id: 'email', type: 'email', props: { label: 'Email' }, conditions: { visible: when('type').equals('email'), }, }, { id: 'phone', type: 'tel', props: { label: 'Phone' }, conditions: { visible: when('type').equals('phone'), }, }, ) ) ``` Reordering [#reordering] The `move(fromIndex, toIndex)` function from `useRepeatableField` allows reordering items. This is useful for drag-and-drop implementations or simple up/down buttons. ```tsx function ReorderableList() { const { items, move } = useRepeatableField('items'); return items.map((item, index) => (
{/* fields... */}
)); } ``` Custom Renderers [#custom-renderers] You can register custom renderers for repeatable fields on your `ril` instance: ```tsx const rilay = ril .create() .addComponent('text', { /* ... */ }) .configure({ repeatableRenderer: ({ repeatableId, items, canAdd, onAdd, min, max, children }) => (
{children} {canAdd && ( )}
), repeatableItemRenderer: ({ item, onRemove, canRemove, onMoveUp, onMoveDown, children }) => (
{children}
{canRemove && }
), }); ``` Nested repeatables (a repeatable inside another repeatable) are not supported. Attempting to nest them will throw an error at build time. # Universal Validation with Standard Schema import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; RilayKit features a universal validation system built on [Standard Schema](https://standardschema.dev), allowing you to use **any** Standard Schema compatible validation library directly - including Zod, Yup, Joi, Valibot, ArkType, and more. Universal Validation API [#universal-validation-api] RilayKit uses a single, unified `validate` property that accepts any Standard Schema compatible validation. This means you can use external libraries directly without adapters. The `validation` property accepts: * `validate`: A Standard Schema compatible validator (single or array) * `validateOnChange`: Validate the field whenever its value changes * `validateOnBlur`: Validate the field when the user leaves it (on blur) * `debounceMs`: Debounce validation by a specified number of milliseconds ```tsx import { z } from 'zod'; import { rilay } from '@/lib/rilay'; import { form } from '@rilaykit/forms'; const registrationForm = form.create(rilay, 'registration') .add({ id: 'email', type: 'email', props: { label: 'Email Address' }, validation: { validate: z.string().email('Please enter a valid email'), validateOnBlur: true, }, }) .add({ id: 'password', type: 'password', props: { label: 'Password' }, validation: { validate: z.string().min(8, 'Password too short'), validateOnChange: true, }, }); ``` ```tsx import { rilay } from '@/lib/rilay'; import { required, minLength, email } from '@rilaykit/core'; const registrationForm = form.create(rilay, 'registration') .add({ id: 'email', type: 'email', props: { label: 'Email Address' }, validation: { validate: [required(), email('Please enter a valid email')], validateOnBlur: true, }, }) .add({ id: 'password', type: 'password', props: { label: 'Password' }, validation: { validate: [required(), minLength(8)], validateOnChange: true, }, }); ``` ```tsx import { z } from 'zod'; import { rilay } from '@/lib/rilay'; import { required } from '@rilaykit/core'; const registrationForm = form.create(rilay, 'registration') .add({ id: 'email', type: 'email', props: { label: 'Email Address' }, validation: { validate: [ required('Email is required'), // RilayKit built-in z.string().email('Invalid email format'), // Zod schema z.string().min(5, 'Email too short'), // Another Zod rule ], validateOnBlur: true, }, }); ``` Standard Schema Compatible Libraries [#standard-schema-compatible-libraries] RilayKit works with any validation library that implements the [Standard Schema](https://standardschema.dev) interface: | Library | Version | Standard Schema Support | | ----------- | ------- | ----------------------- | | **Zod** | 3.24.0+ | ✅ Native support | | **Yup** | 1.7.0+ | ✅ Native support | | **Joi** | 18.0.0+ | ✅ Native support | | **Valibot** | 1.0+ | ✅ Native support | | **ArkType** | 2.0+ | ✅ Native support | RilayKit Built-in Validators [#rilaykit-built-in-validators] RilayKit provides Standard Schema compatible validators out of the box: * `required(message?)`: Ensures the field is not empty * `email(message?)`: Validates email format * `url(message?)`: Validates URL format * `minLength(min, message?)`: Minimum string length * `maxLength(max, message?)`: Maximum string length * `pattern(regex, message?)`: Regular expression validation * `number(message?)`: Valid number check * `min(value, message?)`: Minimum numeric value * `max(value, message?)`: Maximum numeric value * `custom(fn, message?)`: Custom synchronous validator * `async(fn, message?)`: Custom asynchronous validator * `combine(...validators)`: Combine multiple validators All built-in validators implement Standard Schema and can be mixed with external libraries. Using External Libraries [#using-external-libraries] ```tsx import { z } from 'zod'; const userForm = form.create(rilay, 'user') .add({ id: 'email', type: 'input', validation: { validate: z.string() .email('Invalid email format') .min(5, 'Email too short'), }, }) .add({ id: 'age', type: 'input', validation: { validate: z.string() .refine(val => { const num = parseInt(val); return !isNaN(num) && num >= 18; }, 'Must be 18 or older'), }, }); ``` ```tsx import * as yup from 'yup'; const userForm = form.create(rilay, 'user') .add({ id: 'email', type: 'input', validation: { validate: yup.string() .email('Invalid email format') .min(5, 'Email too short') .required(), }, }); ``` ```tsx import Joi from 'joi'; const userForm = form.create(rilay, 'user') .add({ id: 'email', type: 'input', validation: { validate: Joi.string() .email() .min(5) .required() .messages({ 'string.email': 'Invalid email format', 'string.min': 'Email too short', }), }, }); ``` Creating Custom Standard Schema Validators [#creating-custom-standard-schema-validators] You can create your own Standard Schema compatible validators: ```ts import type { StandardSchemaV1 } from '@standard-schema/spec'; export function containsRilay(message = 'Value must contain "rilay"'): StandardSchemaV1 { return { '~standard': { version: 1, vendor: 'my-app', validate: (value: unknown) => { if (typeof value === 'string' && value.includes('rilay')) { return { value }; } return { issues: [{ message }] }; }, }, }; } // Use it like any other validator const userForm = form.create(rilay, 'custom') .add({ id: 'username', type: 'input', validation: { validate: [required(), containsRilay()] } }); ``` Form-Level Validation [#form-level-validation] For cross-field validation, use Standard Schema object schemas with the `setValidation` method: ```tsx import { z } from 'zod'; const userSchema = z.object({ password: z.string().min(8, 'Password too short'), confirmPassword: z.string(), }).refine(data => data.password === data.confirmPassword, { message: "Passwords don't match", path: ['confirmPassword'], }); const changePasswordForm = form.create(rilay, 'change-password') .add({ type: 'password', id: 'password', props: { label: 'New Password' } }) .add({ type: 'password', id: 'confirmPassword', props: { label: 'Confirm Password' } }) .setValidation({ validate: userSchema, // Zod object schema for cross-field validation }); ``` ```tsx import * as yup from 'yup'; const userSchema = yup.object({ password: yup.string().min(8, 'Password too short').required(), confirmPassword: yup.string() .oneOf([yup.ref('password')], 'Passwords must match') .required(), }); const changePasswordForm = form.create(rilay, 'change-password') .add({ type: 'password', id: 'password', props: { label: 'New Password' } }) .add({ type: 'password', id: 'confirmPassword', props: { label: 'Confirm Password' } }) .setValidation({ validate: userSchema, // Yup object schema }); ``` ```tsx import { custom } from '@rilaykit/core'; const passwordMatchValidator = custom( (formData: any) => formData.password === formData.confirmPassword, "Passwords don't match" ); const changePasswordForm = form.create(rilay, 'change-password') .add({ type: 'password', id: 'password', props: { label: 'New Password' } }) .add({ type: 'password', id: 'confirmPassword', props: { label: 'Confirm Password' } }) .setValidation({ validate: passwordMatchValidator, }); ``` Advanced Validation Patterns [#advanced-validation-patterns] Async Validation [#async-validation] Standard Schema supports asynchronous validation out of the box: ```tsx import { z } from 'zod'; const emailField = { id: 'email', type: 'email', props: { label: 'Email' }, validation: { validate: z.string() .email('Invalid email format') .refine(async (email) => { // API call to check if email is unique const response = await fetch(`/api/check-email?email=${email}`); const { isUnique } = await response.json(); return isUnique; }, 'Email is already taken'), validateOnBlur: true, debounceMs: 500, // Debounce async validation }, }; ``` Combining Multiple Libraries [#combining-multiple-libraries] Mix and match validation libraries as needed: ```tsx import { z } from 'zod'; import * as yup from 'yup'; import { required, email } from '@rilaykit/core'; const userForm = form.create(rilay, 'mixed') .add({ id: 'email', type: 'input', validation: { validate: [ required('Email is required'), // RilayKit z.string().min(5, 'Too short'), // Zod yup.string().max(100, 'Too long'), // Yup email('Invalid format'), // RilayKit ] } }); ``` Conditional Validation [#conditional-validation] Use form-level schemas for complex conditional validation: ```tsx import { z } from 'zod'; const userSchema = z.object({ userType: z.enum(['personal', 'business']), email: z.string().email(), companyName: z.string().optional(), }).refine(data => { if (data.userType === 'business' && !data.companyName) { return false; } return true; }, { message: 'Company name is required for business users', path: ['companyName'], }); const userForm = form.create(rilay, 'conditional') .add({ id: 'userType', type: 'select', /* ... */ }) .add({ id: 'email', type: 'input', /* ... */ }) .add({ id: 'companyName', type: 'input', /* ... */ }) .setValidation({ validate: userSchema, }); ``` # Installation import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; Quick Start (Recommended) [#quick-start-recommended] The easiest way to get started is with the all-in-one `rilaykit` package. It includes core, forms, and workflows in a single install: ```bash pnpm add rilaykit ``` ```bash npm install rilaykit ``` ```bash yarn add rilaykit ``` ```bash bun add rilaykit ``` Everything is available from a single import: ```tsx import { ril, Form, FormField, Workflow, required, when } from 'rilaykit'; ``` The all-in-one package also adds convenience methods `.form()` and `.flow()` directly on the `ril` instance: ```tsx const r = ril.create() .addComponent('input', { renderer: MyInput }); // Create forms directly from the ril instance const myForm = r.form('contact') .add({ type: 'input', props: { label: 'Name' } }) .build(); // Create workflows directly from the ril instance const myFlow = r.flow('onboarding', 'Onboarding') .step({ title: 'Step 1', formConfig: myForm }) .build(); ``` Package Overview [#package-overview] | Package | License | Description | Use Case | | -------------------- | ------- | ------------------------------------------- | ------------------------------------- | | `rilaykit` | MIT | All-in-one package with enhanced API | **Recommended** - Everything you need | | `@rilaykit/core` | MIT | Core engine, types, validation system | Modular install - foundation only | | `@rilaykit/forms` | MIT | Form builder and React components | Modular install - forms only | | `@rilaykit/workflow` | MIT | Multi-step workflows with advanced features | Modular install - workflows only | Modular Installation [#modular-installation] If you prefer to install only the packages you need (for optimal tree-shaking), you can use the individual packages. `@rilaykit/core` is always required as the foundation. Forms Only [#forms-only] ```bash pnpm add @rilaykit/core @rilaykit/forms ``` ```bash npm install @rilaykit/core @rilaykit/forms ``` ```bash yarn add @rilaykit/core @rilaykit/forms ``` ```bash bun add @rilaykit/core @rilaykit/forms ``` Forms + Workflows [#forms--workflows] ```bash pnpm add @rilaykit/core @rilaykit/forms @rilaykit/workflow ``` ```bash npm install @rilaykit/core @rilaykit/forms @rilaykit/workflow ``` ```bash yarn add @rilaykit/core @rilaykit/forms @rilaykit/workflow ``` ```bash bun add @rilaykit/core @rilaykit/forms @rilaykit/workflow ``` Validation Libraries [#validation-libraries] RilayKit supports any Standard Schema compatible validation library out of the box — no adapters needed: ```bash # Universal validation with Standard Schema - no adapters needed! # For Zod users (recommended) pnpm add zod # For Yup users pnpm add yup # For Joi users pnpm add joi # Or use any other Standard Schema compatible library pnpm add valibot arktype # etc. ``` ```bash # Universal validation with Standard Schema - no adapters needed! # For Zod users (recommended) npm install zod # For Yup users npm install yup # For Joi users npm install joi # Or use any other Standard Schema compatible library npm install valibot arktype # etc. ``` ```bash # Universal validation with Standard Schema - no adapters needed! # For Zod users (recommended) yarn add zod # For Yup users yarn add yup # For Joi users yarn add joi # Or use any other Standard Schema compatible library yarn add valibot arktype # etc. ``` ```bash # Universal validation with Standard Schema - no adapters needed! # For Zod users (recommended) bun add zod # For Yup users bun add yup # For Joi users bun add joi # Or use any other Standard Schema compatible library bun add valibot arktype # etc. ``` System Requirements [#system-requirements] RilayKit requires modern React and TypeScript versions for optimal type safety and performance. Required Dependencies [#required-dependencies] ```json { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "devDependencies": { "typescript": ">=5.0.0" } } ``` Framework Compatibility [#framework-compatibility] RilayKit works with any React-based framework: * **Next.js** (App Router & Pages Router) * **Vite** + React * **Create React App** (CRA) * **Remix** * **Gatsby** * **Expo** (React Native compatible) Verification [#verification] Verify your installation by importing the package: ```tsx title="test-installation.tsx" import { ril, Form } from 'rilaykit'; // If this imports without errors, you're ready to go! console.log('RilayKit is ready!'); ``` Next Steps [#next-steps] * **New to RilayKit?** → Start with [Your First Form](/getting-started/your-first-form) * **Want to see examples?** → Browse the [Examples Gallery](/examples) * **Coming from another library?** → Check our [Standard Schema Migration Guide](/migration/standard-schema) # Your First Form import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Callout } from 'fumadocs-ui/components/callout'; This guide walks you through building a complete contact form with RilayKit. You'll learn core concepts while creating something practical. What You'll Build [#what-youll-build] A contact form with: * Name, email, and message fields * Real-time validation * Conditional field visibility * Custom component styling * Full TypeScript type safety **Estimated time**: 10 minutes • **Prerequisites**: Basic React and TypeScript knowledge 1. Create Component Renderers [#1-create-component-renderers] RilayKit is **headless** — it handles logic while you provide the UI components. Let's create renderers for input fields. A **renderer** is a React component that receives props from RilayKit (value, onChange, error, etc.) and renders your UI. ```tsx title="lib/components.tsx" import { ComponentRenderer, ComponentRenderProps } from 'rilaykit'; // Define the props interface for our input component interface InputProps { label: string; type?: 'text' | 'email' | 'password' | 'number'; placeholder?: string; required?: boolean; multiline?: boolean; rows?: number; } // Create a flexible input renderer that handles different input types export const InputRenderer: ComponentRenderer = ({ id, value, onChange, onBlur, error, props }) => { const inputProps = { id, value: value || '', onChange: (e: React.ChangeEvent) => onChange?.(e.target.value), onBlur, placeholder: props.placeholder, className: ` block w-full rounded-md border px-3 py-2 text-sm ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'} ` }; return (
{props.multiline ? (