Skip to content

Rendering with Formisch ​

The schema package works with Formisch's own package for every framework, with no adapter. It gives a renderer everything it needs: the schema and initial input for Formisch, the layout with every reference resolved, and helpers for paths, values and steps. Below you will learn how a renderer uses them, with React as the example. The Preact, Solid, Svelte, Qwik and Angular pages have a whole tested renderer each, with the same shape.

Creating the form ​

One builder gives Formisch the schema and the initial input, and you the layout to render:

tsx
const builder = createSchemaBuilder()

/** The content of the layout's slots, by name. */
type Slots = Record<string, ReactNode>

interface JsonFormViewProps {
  json: JsonForm
  onSubmit: SubmitHandler<BuiltSchema>
  slots?: Slots
}

export function JsonFormView({
  json,
  onSubmit,
  slots = {},
}: JsonFormViewProps) {
  // Formisch reads its config once, so read the definition once too.
  const [{ steps, ...config }] = useState(() => ({
    schema: builder.build(json),
    initialInput: builder.getInitialInput(json),
    steps: builder.getLayout(json).steps,
  }))
  const form = useForm(config)
  • Use the same builder for all of it, so your own types and rules count everywhere.
  • getLayout() throws a DefinitionError like build(), so run validate() first when the definition comes from outside your code.
  • A definition without a layout has one step with every field, in order, so one renderer renders every definition.

The layout tree ​

getLayout() gives { steps, ui }, with at least one step. Each step has children, a tree of layout nodes, and every "*" is replaced by the fields it places:

NodeWhat it has
fieldnode, the field's FieldNode, and children: the layout of a group's fields, or of one item of a list
sectionsection, as written, with its id, label, description and ui, and children
rowrow, as written, with its id and ui, and children, its cells
slotname
  • Every node has a span, the columns it takes in its row, and 12 outside one. Render a row as a grid of 12 columns, and each cell with grid-column: span N.
  • Every node has a key, unique among its siblings and stable, for frameworks that key what they render.
  • Render a field's layout children, never its node.children: the layout can reorder a group's fields and put text among them.
tsx
function Layout({ form, nodes, indexes, slots }: LayoutProps) {
  return nodes.map((node) => {
    switch (node.type) {
      case 'section':
        return (
          <fieldset key={node.key}>
            <legend>{node.section.label}</legend>
            <Layout
              form={form}
              nodes={node.children}
              indexes={indexes}
              slots={slots}
            />
          </fieldset>
        )
      case 'row':
        // One line of a 12-column grid, where each cell takes its span.
        return (
          <div
            key={node.key}
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(12, 1fr)',
            }}
          >
            {node.children.map((cell) => (
              <div
                key={cell.key}
                style={{ gridColumn: `span ${cell.span}` }}
              >
                <Layout
                  form={form}
                  nodes={[cell]}
                  indexes={indexes}
                  slots={slots}
                />
              </div>
            ))}
          </div>
        )
      case 'slot':
        return <Fragment key={node.key}>{slots[node.name]}</Fragment>
      case 'field':
        return (
          <Field
            key={node.key}
            form={form}
            node={node}
            indexes={indexes}
            slots={slots}
          />
        )
    }
  })
}

Rendering by role ​

A field node has a role. Render each field by its role, so your own types render too:

RoleWhich fieldsRender
valueevery field with a valuea control, connected to Formisch's field at the node's path
groupa container of kind objectits layout children, like in a <fieldset>
arraya container of kind arrayFormisch's field array, and its layout children for each item
displaya display elementfield.text. It has no Formisch field

A FieldNode has field, the field as the definition has it, and its type, role, kind, itemKind, dataPath, definitionPath and children.

Paths ​

A node's dataPath has '*' for each list index: ['lines', '*', 'quantity']. toFieldPath(node.dataPath, indexes) puts in the index of each item the field is in, outermost first, and gives the path Formisch takes: ['lines', 0, 'quantity']. Pass the indexes down as you render the items of a list.

The path comes from the definition at runtime, so Formisch's types can't check it: pass it as never.

Lists ​

A list renders Formisch's field array, and the layout of each item:

tsx
/** A list: Formisch's field array, and the layout of each item. A slot never is in one. */
function ArrayField({ form, node, indexes }: FieldProps<LayoutField>) {
  const { field, dataPath } = node.node
  // The path comes from the definition at runtime, so Formisch's types can't check it.
  const path = toFieldPath(dataPath, indexes) as never
  const array = useFieldArray(form, { path })
  const add = () =>
    insert(form, {
      path,
      initialInput: builder.getItemInput(field) as never,
    })
  return (
    <fieldset>
      <legend>{field.label ?? field.name}</legend>
      {array.items.map((item, index) => (
        <div key={item}>
          <Layout
            form={form}
            nodes={node.children}
            indexes={[...indexes, index]}
            slots={{}}
          />
          <button
            type="button"
            onClick={() => remove(form, { path, at: index })}
          >
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={add}>
        Add
      </button>
      {array.errors && <small>{array.errors[0]}</small>}
    </fieldset>
  )
}
  • builder.getItemInput(field) is the input of a new item, with the defaults its fields have in the definition.
  • Key each item by its id from Formisch's items, so it keeps its inputs when another one is removed. remove, move and swap work as in any Formisch form.
  • Show the list's own errors, like a required list's message, from its field array.

Value fields ​

A value field connects to Formisch's field at its path, and sets what the element gives through coerceInput():

tsx
function ValueField({ form, node, indexes }: FieldProps<FieldNode>) {
  const store = useField(form, {
    path: toFieldPath(node.dataPath, indexes) as never,
  })
  // Turns what the element gives into the field's value, e.g. '5' into 5 for a number.
  const set = (input: unknown) =>
    store.onChange(coerceInput(input, node) as never)
  return (
    <label>
      <span>
        {node.field.label ?? node.field.name}
        {isRequired(node.field) && ' *'}
      </span>
      {/* One control for every type here. See "Controls" for the right one for each. */}
      <input
        {...store.props}
        type={node.kind === 'number' ? 'number' : 'text'}
        value={(store.input as string | number | undefined) ?? ''}
        placeholder={node.field.placeholder}
        onChange={(e) => set(e.target.value)}
      />
      {store.errors && <small>{store.errors[0]}</small>}
    </label>
  )
}

coerceInput(input, node) turns what an element gives into the value of the field's kind: '5' into 5 and '' into undefined for a number, and a FileList into a file, or a list of files. Anything else is passed as it is, and the schema reports it.

Spread Formisch's element props for the field's name, focus and blur, and set the value yourself: with the field store's setter, or with setInput(form, { path, input }), which every Formisch package has.

Controls ​

Every built-in type has a native control. Pick it by the field's type, and by ui.widget if your definitions use it:

TypeControl
text, email, password, url, tel, number, date, time<input> with the same type
datetime<input type="datetime-local">
textarea<textarea>
checkbox<input type="checkbox">, setting its checked
select, radio<select> or radios, from field.options
multiselect<select multiple>, or checkboxes with "ui": { "widget": "checkboxes" }
file<input type="file">, with multiple when its kind is array

For choices, set the option's own value, not the element's string, so 2 stays a number. Field components shows how.

  • isRequired(node.field) is true when the definition makes a field required, like for an asterisk. The schema checks it: Formisch's <Form> turns the browser's own checks off.
  • Form rules need no wiring. Their issues have the path of the field they name, so Formisch shows them on that field.
  • When you look up a component by node.type, checkComponents(nodes, components) throws a TypeError that names every type without one, so check before you render. checkLayoutComponents(layout, components, slots) does the same for layout types and slots.

Steps ​

A form with steps is one Formisch form with one schema, so Next validates the whole form and lets only the issues on the current step's fields keep the user there:

tsx
// Next validates the whole form, and only the issues on this step's fields keep the user here.
const next = async () => {
  const found = issuesOfStep(step, (await validate(form)).issues)
  if (found.length) {
    const first = found.find((issue) => issue.field.role === 'value')
    if (first) focus(form, { path: first.path as never })
    return
  }
  // That validation set errors on the next step's fields too, which nobody saw.
  for (const path of fieldPathsOfStep(
    steps[index + 1] as LayoutStep,
    getInput(form),
  )) {
    setErrors(form, { path, errors: null } as never)
  }
  setIndex(index + 1)
}
  1. issuesOfStep(step, issues) gives the issues on the step's fields, in layout order, each with its field and Formisch path. With any, it stays, and focuses the first field that has an element: a group or a list has none.
  2. That validation also set errors on the next step's fields. fieldPathsOfStep(step, getInput(form)) gives the path of each field of a step, with every list item there is, to clear them with setErrors.
  3. Back never validates. Submit is the last step's, and Formisch validates the whole form again.

The Vue and React adapters do more, like restoring a step's errors when the user comes back: see multi-step forms.

Conditions ​

For the conditions of the affects package, read what is hidden from builder.getVisibility(json), for the form's input:

  • visibility.of(getInput(form)) gives the state for the input now. It is the same object until what is hidden changes, so a renderer updates only then.
  • state.shownNodes(nodes, indexes) gives the nodes to render: without hidden fields and sections, and without the groups, sections and rows whose fields are all hidden. Call it for each list item, with its indexes.
  • state.isStepHidden(step) tells which steps Next and Back skip.
  • state.toClear lists the fields to set blank: set each with Formisch's setInput, in an effect.

A builder without the extension, or a definition without conditions, hides nothing, so the same renderer renders every definition.

Placing fields ​

To render a field away from the rest, in code rather than in the layout, find it in builder.getFieldTree(json) by its dot path, and leave it out where you render the rest:

  • findField(fields, 'coupon', { placed: true }) gives the field at a dot path, and throws a TypeError for a path that names no field, or a field of a list's items with placed.
  • selectFields(fields, { only, except }) gives the tree with just the fields only names, without the fields except names.
  • shownNodes(nodes, indexes, isShown) leaves fields out of the layout, and the sections and rows left without fields.
  • dotPathOf(node) gives a node's dot path, and allFields(fields) every node of the tree.

Framework differences ​

FrameworkPackageWhat changes
Preact@formisch/preactA field's state is a signal: field.input.value, field.errors.value
Solid@formisch/solidcreateForm, and the path as a getter
Svelte@formisch/sveltecreateForm, and the form and the path as getters
Qwik@formisch/qwikuseForm$, which builds the schema again wherever the form resumes
Angular@formisch/angularinjectForm in ngOnInit, and [formischControl] where the element holds the value
Vue@formisch/vueComponents set up once: pass the path to useField as a getter. Or use the Vue adapter

The whole renderer ​

The React renderer, in one file
tsx
import {
  focus,
  Form,
  type FormStore,
  getInput,
  insert,
  remove,
  setErrors,
  type SubmitHandler,
  useField,
  useFieldArray,
  useForm,
  validate,
} from '@formisch/react'
import {
  coerceInput,
  createSchemaBuilder,
  type FieldNode,
  fieldPathsOfStep,
  isRequired,
  issuesOfStep,
  type JsonForm,
  type BuiltSchema,
  type LayoutField,
  type LayoutNode,
  type LayoutStep,
  toFieldPath,
} from '@whatsaaaa/form-builder-schema'
import { Fragment, type ReactNode, useState } from 'react'

const builder = createSchemaBuilder()

/** The content of the layout's slots, by name. */
type Slots = Record<string, ReactNode>

interface JsonFormViewProps {
  json: JsonForm
  onSubmit: SubmitHandler<BuiltSchema>
  slots?: Slots
}

export function JsonFormView({
  json,
  onSubmit,
  slots = {},
}: JsonFormViewProps) {
  // Formisch reads its config once, so read the definition once too.
  const [{ steps, ...config }] = useState(() => ({
    schema: builder.build(json),
    initialInput: builder.getInitialInput(json),
    steps: builder.getLayout(json).steps,
  }))
  const form = useForm(config)
  const [index, setIndex] = useState(0)
  const step = steps[index] as LayoutStep

  // Next validates the whole form, and only the issues on this step's fields keep the user here.
  const next = async () => {
    const found = issuesOfStep(step, (await validate(form)).issues)
    if (found.length) {
      const first = found.find((issue) => issue.field.role === 'value')
      if (first) focus(form, { path: first.path as never })
      return
    }
    // That validation set errors on the next step's fields too, which nobody saw.
    for (const path of fieldPathsOfStep(
      steps[index + 1] as LayoutStep,
      getInput(form),
    )) {
      setErrors(form, { path, errors: null } as never)
    }
    setIndex(index + 1)
  }

  return (
    <Form of={form} onSubmit={onSubmit}>
      {/* Keyed by the step, so another step's fields don't take over this one's components. */}
      <Layout
        key={index}
        form={form}
        nodes={step.children}
        indexes={[]}
        slots={slots}
      />
      {index > 0 && (
        <button type="button" onClick={() => setIndex(index - 1)}>
          Back
        </button>
      )}
      {index < steps.length - 1 ? (
        <button type="button" onClick={next}>
          Next
        </button>
      ) : (
        <button type="submit">Save</button>
      )}
    </Form>
  )
}

interface LayoutProps {
  form: FormStore
  nodes: readonly LayoutNode[]
  /** The index of each array item the nodes are in, outermost first. */
  indexes: readonly number[]
  slots: Slots
}

function Layout({ form, nodes, indexes, slots }: LayoutProps) {
  return nodes.map((node) => {
    switch (node.type) {
      case 'section':
        return (
          <fieldset key={node.key}>
            <legend>{node.section.label}</legend>
            <Layout
              form={form}
              nodes={node.children}
              indexes={indexes}
              slots={slots}
            />
          </fieldset>
        )
      case 'row':
        // One line of a 12-column grid, where each cell takes its span.
        return (
          <div
            key={node.key}
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(12, 1fr)',
            }}
          >
            {node.children.map((cell) => (
              <div
                key={cell.key}
                style={{ gridColumn: `span ${cell.span}` }}
              >
                <Layout
                  form={form}
                  nodes={[cell]}
                  indexes={indexes}
                  slots={slots}
                />
              </div>
            ))}
          </div>
        )
      case 'slot':
        return <Fragment key={node.key}>{slots[node.name]}</Fragment>
      case 'field':
        return (
          <Field
            key={node.key}
            form={form}
            node={node}
            indexes={indexes}
            slots={slots}
          />
        )
    }
  })
}

/** A field by its role. A group and an array render their fields from the layout. */
function Field({
  form,
  node,
  indexes,
  slots,
}: Omit<LayoutProps, 'nodes'> & { node: LayoutField }) {
  switch (node.node.role) {
    case 'display':
      return <Display node={node.node} />
    case 'group':
      return (
        <fieldset>
          <legend>{node.node.field.label ?? node.node.field.name}</legend>
          <Layout
            form={form}
            nodes={node.children}
            indexes={indexes}
            slots={slots}
          />
        </fieldset>
      )
    case 'array':
      return <ArrayField form={form} node={node} indexes={indexes} />
    case 'value':
      return <ValueField form={form} node={node.node} indexes={indexes} />
  }
}

function Display({ node }: { node: FieldNode }) {
  const text = String(node.field.text ?? '')
  if (node.type === 'heading') {
    const Heading = `h${Number(node.field.level ?? 2)}` as 'h2'
    return <Heading>{text}</Heading>
  }
  return node.type === 'paragraph' ? <p>{text}</p> : <hr />
}

interface FieldProps<TNode> {
  form: FormStore
  node: TNode
  indexes: readonly number[]
}

/** A list: Formisch's field array, and the layout of each item. A slot never is in one. */
function ArrayField({ form, node, indexes }: FieldProps<LayoutField>) {
  const { field, dataPath } = node.node
  // The path comes from the definition at runtime, so Formisch's types can't check it.
  const path = toFieldPath(dataPath, indexes) as never
  const array = useFieldArray(form, { path })
  const add = () =>
    insert(form, {
      path,
      initialInput: builder.getItemInput(field) as never,
    })
  return (
    <fieldset>
      <legend>{field.label ?? field.name}</legend>
      {array.items.map((item, index) => (
        <div key={item}>
          <Layout
            form={form}
            nodes={node.children}
            indexes={[...indexes, index]}
            slots={{}}
          />
          <button
            type="button"
            onClick={() => remove(form, { path, at: index })}
          >
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={add}>
        Add
      </button>
      {array.errors && <small>{array.errors[0]}</small>}
    </fieldset>
  )
}

function ValueField({ form, node, indexes }: FieldProps<FieldNode>) {
  const store = useField(form, {
    path: toFieldPath(node.dataPath, indexes) as never,
  })
  // Turns what the element gives into the field's value, e.g. '5' into 5 for a number.
  const set = (input: unknown) =>
    store.onChange(coerceInput(input, node) as never)
  return (
    <label>
      <span>
        {node.field.label ?? node.field.name}
        {isRequired(node.field) && ' *'}
      </span>
      {/* One control for every type here. See "Controls" for the right one for each. */}
      <input
        {...store.props}
        type={node.kind === 'number' ? 'number' : 'text'}
        value={(store.input as string | number | undefined) ?? ''}
        placeholder={node.field.placeholder}
        onChange={(e) => set(e.target.value)}
      />
      {store.errors && <small>{store.errors[0]}</small>}
    </label>
  )
}