Journal

September 25, 2026 · 6 min read

Forms that know what they submit

A form built from the input its operation accepts: its controls, its checks, what a submit hands over, and how that value travels to the server and back. How foldkit-form works, end to end.

Every post on this blog was written in a form: the title, the address, the excerpt and the body you are reading. The page builder is a form too, with the whole page as one of its fields. Both are foldkit-form. This post follows one value from a keystroke to the database and back.

Built from what is submitted

A form here is not a list of fields written by hand. It is built from the input of the operation it feeds. A post is published by CreatePost or UpdatePost, two Remote mutations that take the same input:

export const PostInput = Schema.Struct({
  title: Post.fields.title.schema,
  slug: Post.fields.slug.schema,
  excerpt: Post.fields.excerpt.schema,
  cover: Post.fields.cover.schema,
  body: Post.fields.body.schema,
})

export const CreatePost = Mutation.make('CreatePost', {
  Input: PostInput,
  Output: { id: PostId },
})

The form is made from that same Schema, read through the Entity, so each key knows which field it writes (trimmed):

export const PostForm = Form.make('PostForm', Entity.input(Post, PostInput), {
  inputs: {
    slug: Cms.slug('title', { prefix: '/blog/' }),
    title: Input.multiline(),
    excerpt: Input.multiline(),
    body: Input.multiline(),
  },
})

One Schema, three readers: the form checks what is typed against it, the mutation declares it as its input, and the server decodes what arrives with it. They cannot disagree, because there is nothing to keep in step.

Inputs

Each key gets a control, chosen from its Schema unless inputs names one:

  • a string is a text field, and Input.multiline() makes it a text area;
  • a number is a number field, whose draft stays text while it is typed;
  • a boolean is a toggle, and a choice of a few literals is a select;
  • a key that holds another Entity’s id is a picker over that Entity, one or many, and Input.search() makes it one that searches as you type;
  • Input.hidden() carries a key without showing it, such as the id being edited;
  • Input.following writes a key from another until the author writes it themselves, which is how the address follows the title here (Cms.slug is built on it);
  • Input.kind makes a control of your own (a date, a price), and Input.bundle makes a whole Bundle a control, with a Model of its own.

The page form uses the last: its document key is the page builder, which holds a page, a selection and an undo history. The form counts one of its Messages as an edit only when it changes the page, so selecting a block saves nothing and dropping one saves like a typed word.

Validation, in four layers

What a control holds is a draft: the text as it is typed. 1. is a fine draft that is not a number yet. The form keeps each draft in the Model with its state: not checked yet, valid, invalid with a reason, or waiting on a check.

  • The key’s own Schema, as it changes, so a mistake is said where it is made.
  • Rules across keys, said on the form rather than on one field.
  • Checks that ask the server, for rules only it can answer.
  • The server’s own refusal, after a submit.

A key is required exactly when its Schema refuses an empty value: the title is, because it is checked with isMinLength(1), and the cover is not.

An address must not be taken by another post, which only the server knows, so the address has a check that asks:

PostForm.pipe(Form.checks({ slug: Cms.addressFree('posts') }))

A check runs once the key’s own Schema passes, with the decoded value. The key reads as waiting meanwhile, and an answer that arrives after a newer edit is dropped. A post may keep its own address, because the form knows which it edits.

Another post can still take the address between the check and the publish. Then the server refuses the publish, and the editor puts the refusal back on the address field (Message.Refused), where it clears on the next edit.

An operation may also ask more than the form. A page’s publish input checks the whole page against the site’s catalog, so a page can be saved as a draft while it is unfinished and still be refused at publish, with its fields marked.

On submit

A submit asks for the whole: every key valid, every running check answered, the input decoded. A submit made while a check is still running waits for it and goes out when it passes. Then the form hands its parent one value, typed as the operation’s input, as an out Message. What happens next is the parent’s.

So the form has no “submitting” state. It submits nothing itself, and whoever runs the operation knows whether it is under way: here Remote, whose store records every mutation from start to answer.

From a value to a mutation

The parent places the form with an onOut that turns the value into a mutation. In a plain application that is a few lines:

const Placed = Page.at(Slot, {
  onOut: submitted => model => {
    const started = Data.mutate(model, CreatePost, submitted.value)
    return { model: started.model, commands: [started.command] }
  },
})

Data.mutate records the mutation in the Model and returns the Command that sends it; the Command’s answer comes back as a Message that settles it. The studio’s editor does the same through foldkit-cms, which runs the application’s own CreatePost or UpdatePost with the draft’s value when you press Publish.

How it travels

Remote asks a RemoteClient for the transport, and the application gives it one. This demo’s is small: every request is one JSON body,

{ "operation": "mutate", "payload": { "requestId": "…", "mutation": "CreatePost", "input": { … } } }

posted to /remote with the reader’s chair in a header when a server runs on a machine, or handed to the same function in the page when the server runs in your browser, as it does here. The request id makes an answer settle its own mutation once, however it arrives.

On the server

RemoteServer finds the mutation by name and decodes its input with the same Schema the form was built from. A request made by hand, around the form, meets the same rules and is refused with “Invalid mutation input”. Then it asks the application whether this reader may do this: here, only an editor may publish. Only then does the application’s own handler run, which for a publish writes the row in one transaction with the CMS’s bookkeeping. The answer is encoded with the mutation’s output Schema, and a failure goes back as a message, never as a stack.

Optimistic updates

A mutation can show its result before the server answers. Data.mutate takes optimistic operations (a patch to an entity, a row added to a list), and Remote lays them over its store as a layer. The answer releases the layer: the server’s values replace it when it succeeds, and it simply goes when it fails, so the screen never keeps a change the server refused.

The studio’s Preview is the same mechanism with no request at all: the form’s current value laid over the store, so the post reads as it would once published. Publishing itself is not optimistic here. It says “Publishing…” until the server answers, because a refused publish shown as done would be worse than a wait.

Without scripts

Progressive enhancement is built in two places. foldkit-mixins-form draws plain, accessible HTML and names every control by its key, so an ordinary form post carries the drafts. And foldkit-ssr can render a form as a real <form method="post"> whose submit the server answers: it rebuilds the Model, runs the same update and Commands, and sends back the page. The same Messages, with no script in the browser.

This demo does not use it yet: the public site is rendered at build time, but the studio needs its scripts. A studio served per request could.

Drawn from outside

The form draws nothing itself. foldkit-mixins-form draws it, with every part a named Slot: each label tied to its field, aria-invalid and aria-describedby set, and errors announced. This blog gives it the look of a page to write on by attaching Styles to those Slots. Its placeholders (“Post title”, “Begin writing your post…”) are a Behavior attached the same way, not a fork of the view.

← More from the blog