rilaykit
Core concepts

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 error

Because the API is immutable, calling .component() without capturing the return value has no effect — by design.

Separation of Layers

  1. Registry (ril.create().component(...)) — 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. No React dependency.
  3. Provider (<Form formConfig={...}>) — React context managing state, validation, and rendering orchestration.
  4. 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
User

Registry 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.

On this page