Philosophy
The design principles behind RilayKit — schema-first forms, headless architecture, type accumulation, and layer separation.
RilayKit rests on a few deliberate architectural decisions. Knowing them helps you use the library well.
Schema-First Design
Forms are data, not imperative code. Instead of scattering state, handlers, and validation effects across JSX, a RilayKit form is a declarative data structure: plain, serializable, introspectable, and composable — "JSON Schema for forms, but type-safe."
import { form, required, email } from 'rilaykit';
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 builder separates the what (fields, rules, conditions) from the how (rendering, events). The resulting configuration can be passed to a React provider, stored in a database, or inspected by tooling — without touching rendering code.
Headless by Design
RilayKit generates zero HTML and zero CSS. It is a pure logic layer: state management, validation orchestration, condition evaluation, workflow navigation. You provide everything visual — markup, styling, ARIA, animations. Your renderers are plain React components that receive standardized props from the engine, so RilayKit works with any design system.
Headless means you own accessibility: ARIA attributes, focus management, keyboard navigation. See Renderers for patterns.
Immutability and Type Accumulation
Each builder method returns a new typed instance. .component('input', …) extends the type to include 'input'; adding 'select' grows it to 'input' | 'select'. This gives full autocompletion on .add({ type: '…' }), catches typos at compile time, and propagates component prop types through the chain.
const rilay = ril.create()
.component('input', { renderer: Input })
.component('select', { renderer: Select });
form.create(rilay, 'test')
.add({ id: 'field', type: 'input', props: { /* Input props autocompleted */ } })
.add({ id: 'field2', type: 'unknown' }); // Compile errorBecause the API is immutable, calling .component() without capturing the return value has no effect — by design.
Separation of Layers
- Registry (
ril.create().component(...)) — maps type names to renderers and default props. Configured once per application. - Builder (
form.create(rilay).add(...)) — constructs form and workflow configurations as data. No React dependency. - Provider (
<Form formConfig={...}>) — React context managing state, validation, and rendering orchestration. - Renderer (your components) — receives props, renders UI. Entirely yours.
Registry (ril.create)
| component definitions
Builder (form.create / flow.create)
| form/workflow config
Provider (<Form> / <Flow>)
| state + props
Renderer (your components)
| HTML + CSS
UserRegistry and Builder run anywhere — Node, tests, build scripts. Only the Provider touches React. So you can test configurations without rendering, generate configs server-side, share registries across apps, and swap renderers without touching configuration.
Serialization as a First-Class Concern
Configurations serialize with .toJSON() and restore with .fromJSON() — natural, since they are already plain data. This enables visual form builders, server-driven forms, version-controlled form definitions, and A/B variants via .clone().
const json = JSON.stringify(myForm.toJSON());
// Later, rehydrate it
const restored = form.create(rilay).fromJSON(JSON.parse(json));Serialization is structural — field definitions, validation rules, and conditions, but not renderer functions. After fromJSON(), use the same ril instance to supply the renderers.