Forms¶
One controlled field is not enough. You need multi-field drafts, validation that does not scream early, submit that cannot double-fire, and async loads that do not wipe keystrokes.
One field is ordinary Freehand wiring. This page covers a form — one re-frame slice, a blessed shape, and seed-merge.
Draft + baseline + touched + errors + submit-attempted? Seed only untouched keys.
The blessed form-slice shape¶
Prefer one coherent map per form (or per form instance), not a scatter of unrelated keys:
{:draft {…} ; what the inputs show / edit
:baseline {…} ; last known good server/domain values
:touched #{…} ; fields the user has edited (or set of keys)
:errors {…} ; field → message (or structured errors)
:submit-attempted? false}
Names can vary; the jobs should not. The fitness harness “blessed form-slice” is this five-slot shape (P3):
| Slot | Job |
|---|---|
| draft | current field values under edit |
| baseline | values to reset to / merge from server |
| touched | which fields have been interacted with |
| errors | validation messages (often derived or event-written) |
| submit-attempted? | user tried to submit — unlocks “show all errors” |
Busy / pending is not a required form-slice key. Derive it from your mutation instance, resource, or a sibling flag, and fold it into submit-enabled? / disabled UI (below).
Ordinary app-db under your path (:editor, [:forms invoice-id], …). Freehand
does not ship a form macro — only this shape as shared convention.
Field wiring (still A / B / C)¶
Each field is still a Freehand controlled input (live domain or app-owned draft):
(v/defview email-field [_]
(let [draft (v/sub [:editor/draft])
error (v/sub [:editor/field-error :email])
text (:email draft)]
[:label
"Email"
[:input {:value (or text "")
:on-input [:editor/edit-field :email ::v/value]
:on-blur [:editor/blur-field :email]}]
(when error [:p.error error])]))
Typical event duties:
- edit-field — update
:draft, add key to:touched - blur-field — optional per-field validate into
:errors - submit — set
:submit-attempted? true, validate all, dispatch mutation if ok
Show errors only when it helps¶
A common corpus pattern: do not scream about empty required fields before the user has touched them or tried to submit.
(rf/reg-sub :editor/field-error
(fn [db [_ field]]
(let [{:keys [errors touched submit-attempted?]} (:editor db)]
(when (or submit-attempted? (contains? touched field))
(get errors field)))))
Derived submit enabled? gates usually combine:
- required draft fields present
- no blocking errors (under the same touched/attempted policy)
- not busy on a mutation
Keep that logic in reg-sub, not in the view.
Seed merge: never clobber typed fields (R-C1)¶
This is the live footgun in many apps (including this repo’s RealWorld editor history): a late async load replaces the whole form slice and wipes keystrokes.
Blessed spelling — seed only untouched fields:
(rf/reg-event :editor/article-loaded
(fn [{:keys [db]} [_ slug article]]
(let [{:keys [draft touched]} (get db :editor)
seeded (reduce-kv
(fn [d k v]
(if (contains? touched k)
d ; user already typed here — keep it
(assoc d k v))) ; otherwise take server value
draft
article)]
{:db (assoc db :editor {:slug slug
:draft seeded
:baseline article
:touched (or touched #{})})})))
Rules of thumb:
- On first open,
touchedis empty → full seed from server. - After the user types, those keys stay in
touched→ late replies cannot overwrite them. - Untouched fields still pick up late server data (title arrived, user never focused it).
- Reset / “reload form” is an explicit event that clears
touchedon purpose.
If you use a single whole-map assoc of the server payload into :draft, you
will reintroduce the clobber bug.
Day-one checklist¶
- One form-slice map (or instance-keyed path) for the form
- Fields wired A/B with edit →
touched - Errors gated on touched or submit-attempted
- Late loads use seed-merge (never full
assocover draft) - Submit validates once, then starts the mutation
If something feels wrong¶
| Symptom | Fix |
|---|---|
| Typing wiped by slow GET | seed only keys not in touched |
| Errors on empty form before touch | gate on touched / submit-attempted? |
| Double submit | busy/pending from mutation path |
Two editors share one :editor |
instance key in the path |
| “Need a form macro” | convention above — Freehand does not ship one |
Submit, busy, and disabled¶
The view stays passive: draft, errors, and busy come from subs; submit is one event vector (or form options map).
;; Busy is commonly a mutation/resource fact, not a form-slice key
(rf/reg-sub :editor/busy?
(fn [db _]
(boolean (get-in db [:mutations :editor-save :pending?]))))
(rf/reg-sub :editor/can-submit?
(fn [db _]
(let [{:keys [draft errors]} (:editor db)
busy? (get-in db [:mutations :editor-save :pending?])]
(and (not busy?)
(seq (:title draft))
(empty? errors)))))
(rf/reg-event :editor/submit
(fn [{:keys [db]} _]
(let [editor (assoc (:editor db) :submit-attempted? true)
errors (validate-editor editor)]
(if (seq errors)
{:db (assoc db :editor (assoc editor :errors errors))}
{:db (assoc db :editor (assoc editor :errors {}))
:fx [[:dispatch [:editor/save (:draft editor)]]]}))))
(v/defview editor-actions [_]
(let [busy? (v/sub [:editor/busy?])
can-save (v/sub [:editor/can-submit?])]
[:button {:disabled (or busy? (not can-save))
:on-click [:editor/submit]}
(if busy? "Saving…" "Save")]))
Rules of thumb:
- Validate in events/subs, not only in the button’s
disabledexpression. - Busy / pending (mutation instance, resource, or sibling flag) owns double-submit; clear it on success and failure in the mutation path.
- Form-level options map for native submit is fine:
{:on-submit {:event [:editor/submit] :prevent-default true}}. - Server errors merge into
:errorswithout wiping:draftor:touched.
Dual-field live transform (no local state)¶
Some screens need two fields that reformat each other (temperature C/F, currency pair). Keep raw typing and which field is active in re-frame — not in a component atom — so mid-keystroke text does not reformat under the caret:
;; Conceptual slice
{:c-text "22" :f-text "71.6" :active :c}
;; Subs: active field echoes raw text; the other shows derived display
(rf/reg-sub :temp/c-display
(fn [db _]
(let [{:keys [c-text f-text active]} (:temp db)]
(if (= active :c) c-text (f->c-string f-text)))))
;; Events: typing sets :active + raw text; optional derive of the sibling raw
(rf/reg-event :temp/c-typed
(fn [{:keys [db]} [_ text]]
{:db (update db :temp
#(assoc % :active :c :c-text text
:f-text (c->f-string text)))}))
[:input {:value (v/sub [:temp/c-display])
:on-input [:temp/c-typed ::v/value]}]
[:input {:value (v/sub [:temp/f-display])
:on-input [:temp/f-typed ::v/value]}]
That is ordinary Freehand pattern A with a careful sub graph — not a controller and not host-local buffers.
Async races beyond seed-merge¶
R-C1 protects draft from late seeds. Related races use the same rule: decide in the re-frame handler against committed state, and carry identity/generation in the intent when needed.
| Race | Blessed approach |
|---|---|
| Late article load vs typing | seed only untouched keys (above) |
| Typeahead: slow reply after newer query | store request-id / generation; ignore stale replies |
| Debounced search | cancel-and-replace in the effect layer; one semantic “query changed” event |
| Stale blur after Escape | commit handler consults live session; ignore retired generation |
| Unmount mid-flight | owner/route cleanup; do not couple domain success to view mount |
Typeahead / debounce stay ordinary re-frame (generation supersession); package a controller only when reuse demands it.
Multiple instances¶
Two open editors (or a list of line items) need instance keys in the path:
Do not store two forms under one global :editor key unless only one can exist.
Reusable field protocols that are not the whole form use explicit :control
addresses.
Testing forms¶
Prefer headless Freehand tests:
- Make a frame with init / seed events.
dispatch-syncthe same edit intents the tree would show ([:editor/edit-field :email "a@b.c"]).- Render with
t/renderand assert values, error visibility, disabled submit. - For R-C1: edit a field, then dispatch a late load; assert the typed field survived.
What Freehand does not ship¶
| Not in Freehand core | Your job / library |
|---|---|
form-slice macro |
the map shape above as convention |
| Automatic validation engine | your validate fn + subs |
| Built-in form component set | build with v/defview + data events |
| Mount-time fetch | routes / resources own loading |