Liking cljdoc? Tell your friends :D

reagent-form

io.github.bangmodcloud/reagent-form — namespace bangmod.form.*

Form state, validation and submission handling for Reagent. register-field hands back a ready-to-spread props map for an [:input ...] (value, change/blur/focus handlers, id, type — no event wiring of your own); handle-submit gates submission on every field validating first.

Why reagent-form?

Two reasons, and they're the whole pitch.

A field is one expression. No action types, no per-field event handlers, no schema DSL, nothing to register anywhere else. register-field returns the complete controlled-input wiring — value, on-change, on-blur, on-focus, id, type — as a props map you spread straight onto the input, and handle-submit is the entire submission pipeline (validate everything → collect values → call your function → track submitting/error state):

[:input (register-field :email {:type "email" :validators [required]})]

That line is a working, validated, controlled field. The quick start below is a complete login form and fits on one screen.

Big forms don't re-render on every keystroke. Field state lives in one atom, but no component ever watches that atom directly — register-field sets up a reagent reaction per field (one for the display value, one for the display error), and a reaction only notifies its watchers when its own output actually changes. So a keystroke in :email recomputes cheap lookups for the other fields' reactions, but their outputs are unchanged — only components that read :email re-render. The cost of a keystroke scales with the components showing that field, not with the size of the form.

Nested forms push this further: FieldArray and FieldGroup register their aggregate value on the parent as a reaction, once — typing inside a row of a 50-row array never writes to the parent form's state at all. The parent only reads through that reaction when something asks for the values (submit, get-form-values); until then the edit stays local to the row's own sub-form. Validation churn is invisible too: error reactions emit nil until a field has been touched, so re-validating untouched fields changes nothing the render layer can see.

The one honest caveat: the granularity is your component boundaries. A single component that renders fifty register-field calls still re-renders as one unit when any of them changes — split big forms into per-section components (FieldGroup/FieldArray push you that way anyway) and only the edited section re-renders.

Install

See the root README for deps.edn / git-dependency snippets.

Quick start

(ns myapp.feature.authentication.view
  (:require [bangmod.form.core :as form]
            [clojure.string :as str]))

(defn required [value]
  (when (str/blank? (str value))
    "This field is required."))

(defn login-form-card []
  (let [login-form (form/create-form :login)
        {:keys [register-field handle-submit get-field-display-error get-is-submitting]}
        (form/make-api login-form)
        on-submit (fn [{:keys [email password]}]
                    (js/console.log "submit:" email password)
                    (form/create-success-submission-result))]
    (fn []
      [:form {:on-submit (handle-submit on-submit)}
       [:div.form-group
        [:label {:for "email"} "Email"]
        [:input.input (register-field :email {:id "email" :type "email"
                                               :validators [required]})]
        (when-let [err (get-field-display-error :email)] [:p.error-text err])]

       [:div.form-group
        [:label {:for "password"} "Password"]
        [:input.input (register-field :password {:id "password" :type "password"
                                                  :validators [required]})]
        (when-let [err (get-field-display-error :password)] [:p.error-text err])]

       [:button.btn.btn-primary {:type "submit" :disabled (get-is-submitting)}
        (if (get-is-submitting) "Submitting..." "Log in")]])))

(form/create-form :login) registers the form under :login globally (form/get-form :login retrieves it elsewhere), which is why it only needs calling once, outside render.

API reference

There are two ways to call this API on a form. (form/make-api form) returns a map of the 12 functions listed below, already bound to form — destructure it once and spread the result through your component, as in the quick start. Or skip make-api and call the protocol functions in bangmod.form.api directly, passing form as the first argument yourself: (api/register-field form field-name field-config). Both operate on the same ReagentForm instance, so mixing them on one form is fine — make-api is just a convenience layer over bangmod.form.api's IForm protocol, not a different API. Reach for the direct form when you need one of the few IForm functions make-api doesn't expose — see below.

bangmod.form.core:

Function / componentDescription
(create-form form-id) / (create-form form-id {:keys [initial-values]})Creates and registers a form under form-id. initial-values is a map of field-name -> value (or anything derefable holding one — reagent atom/reaction/cursor, plain atom), used before a field is touched.
(make-api form)Returns the bound functions below as a map, meant to be destructured once. Throws if form isn't a ReagentForm.
(create-success-submission-result) / (create-failed-submission-result msg)The two values an on-submit fn (passed to handle-submit) must produce, directly or via a core.async channel.
FieldArray, FieldGroupComponents for repeating/nested field groups — see below.

Bound functions returned by make-api:

FunctionDescription
register-field field-name field-configRegisters a field, returns input props: :value, :on-change, :on-blur, :on-focus, :id, :type, :placeholder, plus anything else from field-config. See below for field-config.
deregister-fields field-name-or-listRemoves one field (keyword) or several (collection) from form state.
get-field-display-value field-nameCurrent value, falling back to initial value then :default-value.
get-field-display-error field-nameCurrent error, or nil if the field hasn't been touched.
get-raw-field-value field-nameCurrent value with no fallback.
change-field-value field-name valueSets a value, marks touched, validates. What the default :on-change calls.
validate-field field-nameRe-runs validators against the current value.
touch field-nameMarks touched (so its error becomes visible) and validates, without changing value.
get-all-fields-errors({:field name :error err} ...) for every field currently in error.
get-is-submittingtrue while a submission is in flight.
get-form-display-errorForm-level error from create-failed-submission-result (or from an on-submit that threw). nil while submitting.
handle-submit on-submit-fnReturns an :on-submit handler — see below.

field-config keys for register-field:

  • :validators — vector of validator functions (below). Default [].
  • :default-value — value before the field has a real or initial value.
  • :id — defaults to field-name. :type — defaults to "text". :placeholder — no default; passed through only if you provide one.
  • :on-change / :on-blur / :on-focus — override the generated handler.

Calling bangmod.form.api directly

bangmod.form.api defines the full IForm protocol make-api wraps — every function above, plus a few make-api leaves out because they're rarely what a component needs:

FunctionDescription
(api/get-form-values form)A plain map of every field's current raw value — the same shape handle-submit passes to on-submit-fn, available any time, not just at submit.
(api/validate-all-fields form)Touches and validates every field, returns the first error found (or nil). Runs the same check handle-submit runs, without submitting — useful for a "can I move to the next wizard step" check.
(api/get-initial-values form)The form's :initial-values, as given to create-form.

Same call shape either way — form first, then whatever the function normally takes:

(require '[bangmod.form.api :as api])

(api/register-field login-form :email {:validators [v/required]})
(api/get-form-values login-form)
;; => {:email "a@b.com" :password "secret"}

This isn't a theoretical escape hatch — FieldArray and FieldGroup (below) are themselves built this way: they hold a form value with no component of their own bound to it via make-api, and call api/register-field, api/change-field-value, api/get-form-values and api/validate-all-fields on it directly, because a nested/repeated form's own validator needs validate-all-fields, which make-api doesn't expose.

Writing a validator

A validator is a 1-arg function: the field's raw value in, an error (truthy, conventionally a string) or nil out. Validators run in order; the first to return an error wins.

(defn required [value]
  (when (clojure.string/blank? (str value))
    "This field is required."))

Submitting

(handle-submit on-submit-fn) returns a fn for :on-submit. It calls .preventDefault, touches and validates every field, and — only if none now has an error — marks the form submitting and calls (on-submit-fn field-values) with a plain map of every field's raw value (destructure directly: (fn [{:keys [email password]}] ...)). If any field has an error, on-submit-fn is never called; the errors are already visible since every field was just touched. on-submit-fn's return value — directly, or eventually via any core.async read port — must be (create-success-submission-result) or (create-failed-submission-result msg); either way this clears get-is-submitting and, on failure, sets get-form-display-error to msg. An on-submit-fn that throws (or returns something else entirely) is treated as a failed submission — the form never sticks in a submitting state.

Field arrays and field groups

FieldArray and FieldGroup register a field whose value is itself a list of sub-forms or a single nested sub-form. Both take :form (a form, or a form-id keyword) and :name (the field name they register under on the parent), plus a render prop.

FieldGroup — render prop receives the nested form to build a make-api from, same as any other form:

[form/FieldGroup {:form parent-form :name :billing-address}
 (fn [nested-form]
   (let [{:keys [register-field get-field-display-error]} (form/make-api nested-form)]
     [:div.address-group
      [:div.form-group
       [:label "Street"]
       [:input (register-field :street {:validators [v/required]})]
       (when-let [err (get-field-display-error :street)] [:span.error err])]]))]

FieldArray — render prop receives (add-fn remove-fn forms): call add-fn (optionally with a map of initial values) to append a sub-form, (remove-fn index) to drop one, and render forms (a vector of sub-forms) yourself:

[form/FieldArray {:form parent-form :name :items}
 (fn [add-fn remove-fn item-forms]
   [:div
    (doall
     (map-indexed
      (fn [idx item-form]
        (let [{:keys [register-field get-field-display-error]} (form/make-api item-form)]
          ^{:key idx}
          [:div.item-row
           [:input (register-field :title {:placeholder "Item title"})]
           [:input (register-field :qty {:type "number" :validators [v/required]})]
           [:button {:type "button" :on-click #(remove-fn idx)} "Remove"]
           (when-let [err (get-field-display-error :qty)] [:span.error err])]))
      item-forms))
    [:button {:type "button" :on-click #(add-fn {:qty 1})} "+ Add item"]])]

:element-removal-strategy (default :both, or :element-only) controls how a removed element's slot is treated against :initial-values on re-render.

Custom controls (non-native :on-change)

The generated :on-change reads (.. event -target -value) — right for a plain <input>, not for a control (a date picker, a react-select) that hands :on-change something else. Override it and write straight to form state with change-field-value:

[date-picker (register-field :start-date
               {:validators [v/required]
                :on-change  #(change-field-value :start-date %)})]

Real-world example

A complete login form: two validated fields, an API-driven error banner, a loading state, and a redirect once authentication succeeds (the redirect half on its own, with more context, is in reagent-router's docs — this is the same login-panel, in full):

(ns myapp.feature.authentication.view
  (:require [reagent.core :as r]
            [re-frame.core :as rf]
            [clojure.string :as str]
            [bangmod.router.core :as router]
            [bangmod.form.core :as form]
            [myapp.feature.authentication.event :as auth]
            [myapp.validators :as v]))

(defn- login-form-card [login-form]
  (let [{:keys [register-field handle-submit get-field-display-error]}
        (form/make-api login-form)
        api-err @(rf/subscribe [:auth/error])
        loading? @(rf/subscribe [:auth/loading?])
        on-submit (fn [{:keys [email password]}]
                    (auth/login! (str/lower-case (str/trim (str email)))
                                 password "client-app-id")
                    (form/create-success-submission-result))]
    [:div.login-box
     (when api-err
       [:div.banner.banner-danger
        (cond
          (str/includes? api-err "disabled") "This account has been disabled. Contact your Administrator."
          (str/includes? api-err "credentials") "Invalid email or password."
          :else api-err)])

     [:form {:on-submit (handle-submit on-submit)}
      [:div.form-group
       [:label {:for "login-email"} "Email"]
       [:input.input (register-field :email {:id "login-email" :type "email"
                                             :validators [v/required]
                                             :class (when (get-field-display-error :email) "input-error")})]
       (when-let [err (get-field-display-error :email)] [:p.error-text err])]

      [:div.form-group
       [:label {:for "login-password"} "Password"]
       [:input.input (register-field :password {:id "login-password" :type "password"
                                                 :validators [v/required]
                                                 :class (when (get-field-display-error :password) "input-error")})]
       (when-let [err (get-field-display-error :password)] [:p.error-text err])]

      [:button.btn.btn-primary {:type "submit" :disabled loading?}
       (if loading? "Logging in..." "Log in")]]]))

(defn login-panel []
  (let [login-form (form/create-form :login)
        user-sub (rf/subscribe [:auth/user])
        redirect! (fn [] (when @user-sub (router/navigate! :account)))]
    (r/create-class
     {:component-did-mount  (fn [_] (redirect!))
      :component-did-update (fn [_] (redirect!))
      :reagent-render       (fn [] [login-form-card login-form])})))

auth/login! dispatches the login request and updates :auth/user, :auth/error, :auth/loading? asynchronously (typically built on reagent-http-api) — on-submit returns success immediately since, from the form's point of view, "submitting" is just "kick off the login"; the redirect is what reacts to it actually completing.

Gotchas

  • The default :on-change assumes a native DOM change event — see "Custom controls" above for anything else.
  • An invalid submit touches every field and returns; on-submit-fn is never called. There's no separate "on invalid" callback — check get-field-display-error / get-all-fields-errors in render.
  • get-form-display-error goes quiet while submitting, so a stale error from a previous attempt won't flash before the new attempt's result replaces it.
  • create-form registers into process-global state keyed by form-id. Calling it again with the same id silently replaces the previous form in that registry — a component still holding the old ReagentForm value keeps working, just disconnected from what get-form now resolves to elsewhere.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close