Skip to content

Forms

A controlled field writes every edit directly to app-db. A form often needs a separate draft, validation that appears at the right time, and a submit status that survives renders. The optional re-frame.fresco.forms module provides the first of these, a buffered field whose draft lives in app-db. Validation timing and submit status are ordinary events, subscriptions and a mutation, shown below.

A form has three kinds of state:

  • a draft that may be committed or abandoned
  • a gate that decides when validation and submission are allowed
  • a per-instance status for the write in flight

All three remain ordinary data.

Require the module where its views are used:

(ns app.todos
  (:require [clojure.string :as str]
            [re-frame.core :as rf]
            [re-frame.resources]      ;; mutations, for the submit section
            [re-frame.http.managed]   ;; HTTP transport for those mutations
            [re-frame.fresco :as h]
            [re-frame.fresco.forms :as forms]))

Applications that never require this namespace do not include the module.

Buffered fields

forms/buffered-field is a controlled input with an app-db draft in front of the committed value. Supply a stable control address, the committed value, its revision, and the event that receives a candidate commit:

(rf/reg-sub :todo/title
  (fn [db [_ id]]
    (get-in db [:todos id :title])))

(rf/reg-sub :todo/title-revision
  (fn [db [_ id]]
    (get-in db [:todos id :title-revision] 0)))

(h/defview title-field [{:keys [id]}]
  [forms/buffered-field
   {:control     [:todo id :title]
    :value       (h/sub [:todo/title id])
    ::h/revision (h/sub [:todo/title-revision id])
    :on-commit   [:todo/title-committed id]
    :aria-label  "Todo title"
    :placeholder "What needs doing?"}])

The interaction protocol is fixed:

  • Focus alone does not create a draft. The first edit starts the session.
  • Enter and blur both append the draft candidate to :on-commit and dispatch it.
  • Escape clears the draft and shows :value again. Add :on-cancel when cancellation has domain meaning.
  • Unmount neither commits nor cancels. A virtualized row can leave and return without losing its draft.

The module stores the draft under its own app-db key at the :control address. It therefore appears in snapshots, headless tests, and Xray. Other props such as :placeholder, :class, :name, and test ids pass through to the input; the value, handler, key, and revision slots remain owned by the field.

Use an address that identifies the form instance and field. Two fields with the same address intentionally share a draft, which is usually a bug. :control is required.

buffered-field always renders an <input>, with :type defaulting to "text". For multi-line text, use a controlled :textarea with the draft-in-app-db pattern below.

Accept, reject, or rewrite a candidate

The :on-commit handler decides the result:

  • accept by writing the candidate
  • normalize by writing another value
  • reject by leaving the committed value unchanged

When the handler rejects or rewrites, advance the revision as well:

(rf/reg-event :todo/title-committed
  (fn [{:keys [db]} [_ id candidate]]
    (let [title (str/trim candidate)]
      (if (str/blank? title)
        {:db (update-in db
                        [:todos id :title-revision]
                        (fnil inc 0))}
        {:db (-> db
                 (assoc-in [:todos id :title] title)
                 (update-in [:todos id :title-revision]
                            (fnil inc 0)))}))))

The revision makes same-value rejection observable. If the committed value is "Buy milk" and the user submits a blank draft, retaining "Buy milk" does not change the value. Advancing the revision replaces the displayed draft with the committed value on the next render.

For async acceptance, the settle event writes the accepted value and advances the revision. On the next render the field shows that value, and its new Enter/blur callbacks carry the new revision. They cannot commit the old draft.

The cell below runs title-field with that handler. Edit the title and the committed value underneath does not move until you press Enter or leave the field. Escape throws the draft away. Clear the field and press Enter: the handler rejects the blank title, and the advanced revision puts the committed title back.

(require '[clojure.string :as str]
         '[re-frame.core :as rf]
         '[re-frame.fresco :as h]
         '[re-frame.fresco.forms :as forms])

(rf/reg-event :todo/initialise
  (fn [_ _]
    {:db {:todos {1 {:id 1 :title "Buy milk"}}}}))

(rf/reg-sub :todo/title
  (fn [db [_ id]]
    (get-in db [:todos id :title])))

(rf/reg-sub :todo/title-revision
  (fn [db [_ id]]
    (get-in db [:todos id :title-revision] 0)))

(rf/reg-event :todo/title-committed
  (fn [{:keys [db]} [_ id candidate]]
    (let [title (str/trim candidate)]
      (if (str/blank? title)
        {:db (update-in db
                        [:todos id :title-revision]
                        (fnil inc 0))}
        {:db (-> db
                 (assoc-in [:todos id :title] title)
                 (update-in [:todos id :title-revision]
                            (fnil inc 0)))}))))

(h/defview title-field [{:keys [id]}]
  [forms/buffered-field
   {:control     [:todo id :title]
    :value       (h/sub [:todo/title id])
    ::h/revision (h/sub [:todo/title-revision id])
    :on-commit   [:todo/title-committed id]
    :aria-label  "Todo title"
    :placeholder "What needs doing?"}])

(h/defview title-editor [{:keys [id]}]
  [:div
   [title-field {:id id}]
   [:p "Committed: " (pr-str (h/sub [:todo/title id]))]])

[h/frame-root {:id :app :initial-events [[:todo/initialise]]}
 [title-editor {:id 1}]]

A field that will never be externally reset, rejected, or rewritten may use a constant revision such as 0. That choice means an active draft is never replaced merely because :value changed.

The module also settles two common races:

  • Escape followed by blur. Cancel removes the live draft. The trailing blur finds no session to commit and does nothing.
  • Repeated commits. Enter followed by blur, double Enter, or a late cancel affects the session once. Later operations for the ended session are idempotent no-ops.

Gate validation by interaction

A buffered field commits one value at a time, which suits editing a todo in place. A form whose fields save together, such as the todo editor built in the rest of this chapter, keeps one draft map in app-db and edits it with ordinary controlled inputs.

Validation can remain a pure function of the draft. Error display should be gated so a blank form does not report every problem on first paint. Track which fields were touched and whether the user attempted submission:

(def blank-editor
  {:draft             {:title "" :notes ""}
   :baseline          {:title "" :notes ""}
   :touched           #{}
   :submit-attempted? false})

(defn validate [{:keys [title]}]
  (cond-> {}
    (str/blank? title)
    (assoc :title "Title is required.")))

(rf/reg-event :todo.editor/edit-field
  (fn [{:keys [db]} [_ field text]]
    {:db (assoc-in db [:todo.editor :draft field] text)}))

(rf/reg-event :todo.editor/touch-field
  (fn [{:keys [db]} [_ field]]
    {:db (update-in db
                    [:todo.editor :touched]
                    (fnil conj #{})
                    field)}))

(rf/reg-sub :todo.editor/field
  (fn [db [_ field]]
    (get-in db [:todo.editor :draft field])))

(rf/reg-sub :todo.editor/field-error
  (fn [db [_ field]]
    (let [{:keys [draft touched submit-attempted?]}
          (:todo.editor db)]
      (when (or submit-attempted?
                (contains? touched field))
        (get (validate draft) field)))))

A controlled field marks itself touched on blur, and displays the derived error as something a screen reader can find:

(h/defview editor-title-field [_]
  (let [error (h/sub [:todo.editor/field-error :title])]
    [:fieldset.form-group
     [:label {:for "todo-title"} "Title"]
     [:input.form-control
      {:id               "todo-title"
       :type             :text
       :placeholder      "Todo title"
       :value            (h/sub [:todo.editor/field :title])
       :aria-invalid     (if error "true" "false")
       :aria-describedby (when error "todo-title-error")
       :on-input         [:todo.editor/edit-field :title ::h/value]
       :on-blur          [:todo.editor/touch-field :title]}]
     (when error
       [:div.error-messages {:id "todo-title-error" :role "alert"} error])]))

:aria-invalid marks the value as wrong, :aria-describedby names the node that explains why, and role="alert" makes a screen reader announce that node when it appears. The error node is absent rather than empty while the error is hidden, so :aria-describedby never points at an id missing from the document.

Because the error is derived from the current draft, it clears as soon as a touched field becomes valid. For a buffered field, mark the field touched in its :on-commit handler and use the same gated subscription.

The cell below runs this field with a Save button whose event only records the attempt; the real submit comes in the next sections. The form opens with no error. Tab into the field and out again, or press Save, and the error appears. Type a title and it clears.

(require '[clojure.string :as str]
         '[re-frame.core :as rf]
         '[re-frame.fresco :as h])

(def blank-editor
  {:draft             {:title "" :notes ""}
   :baseline          {:title "" :notes ""}
   :touched           #{}
   :submit-attempted? false})

(defn validate [{:keys [title]}]
  (cond-> {}
    (str/blank? title)
    (assoc :title "Title is required.")))

(rf/reg-event :todo.editor/open
  (fn [{:keys [db]} _]
    {:db (assoc db :todo.editor blank-editor)}))

(rf/reg-event :todo.editor/edit-field
  (fn [{:keys [db]} [_ field text]]
    {:db (assoc-in db [:todo.editor :draft field] text)}))

(rf/reg-event :todo.editor/touch-field
  (fn [{:keys [db]} [_ field]]
    {:db (update-in db
                    [:todo.editor :touched]
                    (fnil conj #{})
                    field)}))

(rf/reg-event :todo.editor/attempt-submit
  (fn [{:keys [db]} _]
    {:db (assoc-in db [:todo.editor :submit-attempted?] true)}))

(rf/reg-sub :todo.editor/field
  (fn [db [_ field]]
    (get-in db [:todo.editor :draft field])))

(rf/reg-sub :todo.editor/field-error
  (fn [db [_ field]]
    (let [{:keys [draft touched submit-attempted?]}
          (:todo.editor db)]
      (when (or submit-attempted?
                (contains? touched field))
        (get (validate draft) field)))))

(h/defview editor-title-field [_]
  (let [error (h/sub [:todo.editor/field-error :title])]
    [:fieldset.form-group
     [:label {:for "todo-title"} "Title"]
     [:input.form-control
      {:id               "todo-title"
       :type             :text
       :placeholder      "Todo title"
       :value            (h/sub [:todo.editor/field :title])
       :aria-invalid     (if error "true" "false")
       :aria-describedby (when error "todo-title-error")
       :on-input         [:todo.editor/edit-field :title ::h/value]
       :on-blur          [:todo.editor/touch-field :title]}]
     (when error
       [:div.error-messages {:id "todo-title-error" :role "alert"} error])]))

(h/defview editor-form [_]
  [:form {:on-submit [:todo.editor/attempt-submit]}
   [editor-title-field]
   [:button {:type :submit} "Save todo"]])

[h/frame-root {:id :editor :initial-events [[:todo.editor/open]]}
 [editor-form]]

Materialise the submit gate once

The handler needs to reject an invalid submission, and anything else that asks may this be saved? must get the same answer. Compute that decision once rather than maintaining two validation paths. A flow can materialise it into app-db:

(def can-submit-flow
  [:todo.editor/can-submit?
   {:doc         "Valid AND different from the loaded baseline."
    :inputs      [[:todo.editor :draft]
                  [:todo.editor :baseline]]
    :output-path [:todo.editor :can-submit?]}
   (fn [draft baseline]
     (and (empty? (validate draft))
          (not= draft baseline)))])

(rf/reg-event :todo.editor/register-flow
  (fn [_ _]
    {:fx [[:rf.fx/reg-flow can-submit-flow]]}))

(rf/reg-sub :todo.editor/can-submit?
  (fn [db _]
    (boolean (get-in db [:todo.editor :can-submit?]))))

Register the flow once, when the frame is created, by listing :todo.editor/register-flow in the frame's :initial-events:

[h/frame-root {:id             :app
               :initial-events [[:todo/initialise]
                                [:todo.editor/register-flow]]}
 [editor-form]]

The subscription is the public read of the gate; the handler reads the same value directly from db, so the two cannot drift:

(def save-instance :todo.editor/save)

(rf/reg-event :todo.editor/submit
  (fn [{:keys [db]} _]
    (cond-> {:db (assoc-in db [:todo.editor :submit-attempted?] true)}
      (get-in db [:todo.editor :can-submit?])
      (assoc :fx [[:dispatch
                   [:rf.mutation/execute
                    {:mutation :todo/save
                     :params   (get-in db [:todo.editor :draft])
                     :instance save-instance
                     :reply-to [:todo.editor/replied]}]]]))))

A rejected submit still sets :submit-attempted?, which reveals every remaining field error at once, each announced and tied to its control.

So keep the submit button enabled while the form is invalid. A :disabled button drops out of the tab order and announces nothing, so a keyboard or screen-reader user is never told which field is wrong, and :submit-attempted? can never be set. Disable the button only while the write is in flight, when a second submission really would be wrong.

Do not use :aria-disabled for the invalid state either. WAI-ARIA defines aria-disabled="true" as "not editable or otherwise operable", which is false here: pressing Save is exactly what reveals the errors. Reserve :aria-disabled for an action the handler really refuses, such as a pager arrow already at the last page.

Read submit status by instance

Run the write as a mutation under a stable form instance. The form reads that instance's status as data:

(h/defview editor-form [_]
  (let [save (h/sub [:rf/mutation {:instance save-instance}])]
    [:form {:on-submit [:todo.editor/submit]}
     (when (:error? save)
       [:ul.error-messages
        [:li "Save failed — check the fields and try again."]])
     [editor-title-field]
     [:button.btn.btn-primary
      ;; Disabled only while the save is in flight; validity is shown
      ;; on the fields, not here.
      {:type     :submit
       :disabled (:pending? save)}
      "Save todo"]]))

:pending? is the busy flag. Disable fields or buttons from the mutation instance rather than a local boolean that can be left behind after an error. There is no done-callback protocol. Completion arrives through the named reply event:

(rf/reg-event :todo.editor/replied
  (fn [{:keys [db]} [_ {:keys [status]}]]
    (when (= :ok status)
      {:db (assoc db :todo.editor blank-editor)
       :fx [[:dispatch
             [:rf.mutation/clear {:instance save-instance}]]]})))

Registering the :todo/save mutation, request encoding, cache invalidation, cancellation, and supersession are covered in Async resources. A form only executes the mutation and reads its instance.

Troubleshooting

Most failures here are behavioural. The named ones come from the pieces underneath: reg-state for a bad :control, and :rf.error/fresco-revision-not-controlled for a misplaced revision.

Symptom Cause Fix
Errors appear on the untouched form Error display is not gated Show each error only after that field is touched or submission was attempted
Pressing Save on an invalid form reveals nothing The button is :disabled while invalid, so the submit never fires and :submit-attempted? is never set Disable only while the write is in flight; leave the button enabled while invalid and refuse in the handler
A screen reader says Save is unavailable, but pressing it is the only way to see the errors The button carries :aria-disabled while invalid — ARIA reads that as "not operable" Drop :aria-disabled; put the state on the fields with :aria-invalid and an :aria-describedby message
The button enables but the handler rejects, or the reverse The two sites recompute validity independently Materialise one gate; subscribe in the view and read the same db value in the handler
Escape clears the field and the old draft returns on blur A second draft copy exists outside the module Keep one addressed draft. The module's trailing blur already no-ops after cancel
Two fields overwrite one another's drafts Their :control addresses collide Include form instance and field identity, for example [:todo id :title]
A buffered field reports :rf.error/sub-exception at its first render, with cause :rf.error/fresco-state-bad-argument :control is missing or nil Pass a stable address such as [:todo id :title]
A rejected or normalized draft remains visible The committed value stayed equal and the revision did not advance Move ::h/revision whenever a commit rejects or rewrites
An external value update does not replace the active edit Value changed under an equal revision Advance the revision only when the application intends to replace the draft
A late async acceptance overwrites newer work The settle event wrote without a revision/supersession fence Settle value and revision together; apply the mutation's supersession policy
An old draft reappears after later navigation Draft state is durable and no causal owner cleared it Clear it on route entry, successful save, explicit cancel, or another domain end event
The save-failed message stays after the user has moved on The mutation instance keeps its settled error until replaced Re-execute, or dismiss it with [:rf.mutation/clear {:instance …}] at the intended point
The form submits and the browser reloads :on-submit holds an h/event or a plain function — a callback owns its own event and is never auto-prevented Call .preventDefault in the callback, or use the data spelling [:todo.editor/submit], which auto-prevents

When not to use the forms module

Use a direct controlled input for a search box, filter, settings toggle, or other value that should update app-db immediately and has no abandonable draft.

Use the forms module when a field needs commit/cancel buffering. Interaction-gated errors and a readable submit lifecycle need no module; they are the patterns on this page. Each buffered field adds an address and commit protocol to app-db. For a reg-view screen, Build a form keeps the draft, the touched fields and the submit status in one app-db slice.

Advanced

Delayed protocol events

The revision fence is established by rendering the field again. It is not a check against your domain's current revision at dispatch time: a saved protocol event carrying the old revision can still match a retained old draft. Do not queue the field's internal commit events for later. For delayed server replies, use mutation supersession and the settle-merge recipe.

Draft lifetime

A draft survives re-render, remount, virtualization, and navigation, so every durable draft needs an owner that ends it: route entry, explicit cancel, or the successful save reply. Drafts live under the forms/drafts state concern, keyed by :control; end one by clearing that address:

{:fx [[:dispatch [::h/clear forms/drafts [:todo id :title]]]]}

A form that may block navigation should derive dirty? from the same draft and baseline and feed that value to the route's :can-leave guard (see Guard unsaved changes). Do not create a second dirty flag that can drift from the form state.

Keystroke cost

A buffered field still writes its draft to app-db on every keystroke. Buffering changes when the committed domain value moves; it does not make the draft DOM-local. This keeps the edit visible to tests and tools.

For a dense grid where that cost is too high, use an explicitly uncontrolled input or a measured native island. The forms module is not a performance escape from controlled fields.

Problems these patterns avoid

Each row is a hand-written pattern and the model this page uses in its place:

Hand-written pattern Failure it creates Model on this page
External value plus local atom for each field Two sources must be synchronized, usually during rendering One addressed draft in app-db
Detect reset by comparing values Reasserting an equal committed value cannot make a rejected draft disappear Reset is signalled separately with ::h/revision
Force a reset after every commit Accepted async commits can flicker between stale and new values Commit is decided against current state; stale commits become no-ops
Inspect a callback's arity for a done function Completion becomes an undocumented callback protocol Mutation status is per-instance data
Create draft state in a render closure Re-render or remount destroys the edit Draft and status live at stable addresses