What is fork?
Forms look easy until you actually build them. The initial
<input> takes five minutes. Then you spend the
next week wiring up change handlers, tracking which fields the user has
touched, figuring out when to show validation errors, managing submission
state, handling server-side validation responses, and dealing with dynamic
field groups that can be added or removed on the fly. All of this ends up
scattered across atoms, subscriptions, and event handlers until the whole
thing becomes a maintenance problem.
Fork takes that entire layer off your plate. You pass a config map and a render function, and the library handles state initialization, change tracking, blur tracking, dirty detection, validation orchestration, submission lifecycle, and server request coordination. Your code stays focused on what the form looks like and what happens when it submits. The form plumbing just works.
Rationale
Most ClojureScript form solutions fall into two camps. The heavy ones give you pre-built components tied to a specific CSS framework, and you end up fighting them the moment your design doesn't fit. The thin ones hand you a couple of atoms and leave every hard problem on your plate: touched tracking, validation timing, submission guards, server-side error integration. You still write all the same boilerplate.
Fork sits in the space between. It's an orchestrator, not a framework. It manages the state lifecycle that is genuinely painful to get right, and gets out of the way for everything else. Your components. Your CSS. Your validation library. Fork doesn't render anything or make decisions it can't reverse.
Here's what you get for free:
- State orchestration — values, touched fields, dirty tracking, disabled state, submission counters. All managed in a single ratom with composable, side-effect-free helpers
- Pluggable validation — bring any validation library (Vlad, Malli, custom). Pass a function, get errors back. Fork blocks submission until they clear and only shows errors after the field is touched
- Field arrays — dynamic field groups with insert, remove, and drag-and-drop reordering. Touched state tracks correctly across additions and deletions
- Server request handling — built-in debounce/throttle for server-side validation, waiting state tracking, and error injection. The form won't submit while a server request is pending
- Reagent & Re-frame — identical API for both. Use fork.reagent for local state or fork.re-frame for global state with subscriptions and events. Switch between them by changing one require
And here's what you stay in control of: your components, your CSS, your validation logic, your submission handlers. Fork doesn't render a single DOM element. It keeps the form lifecycle organized and predictable. You write the UI.
Installation
;; deps.edn
fork {:mvn/version "2.4.3"}
;; or via git
fork {:git/url "https://github.com/luciodale/fork.git"
:sha "<commit-sha>"};; project.clj (Leiningen)
[fork "2.4.3"]Quick Start
The simplest possible form. One input, one render function, zero configuration beyond initial values. Everything lives in a single namespace:
(ns app.core
(:require [fork.reagent :as fork]))
(defn my-form
[{:keys [values handle-change handle-blur]}]
[:div
[:p "Read back: " (values "input")]
[:input
{:name "input"
:value (values "input")
:on-change handle-change
:on-blur handle-blur}]])
(defn app []
[fork/form {:initial-values {"input" "hello"}}
my-form])
That's the whole thing. Fork takes two arguments: a config map and a
component function. The component receives a map of handlers and state
accessors as its first argument. Notice how values
is a function: call it with a field name and get the current value back.
handle-change and handle-blur
wire directly to your input's event attributes.
Submission
A form that actually does something when submitted. This example uses Re-frame to dispatch the submission, track the submitting state, and reset it after a simulated server response:
(ns app.core
(:require
[fork.re-frame :as fork]
[re-frame.core :as rf]))
(rf/reg-event-fx
:submit-handler
(fn [{db :db} [_ {:keys [values dirty path]}]]
{:db (fork/set-submitting db path true)
:dispatch-later [{:ms 1000
:dispatch [:resolved-form path values]}]}))
(rf/reg-event-fx
:resolved-form
(fn [{db :db} [_ path values]]
(js/alert values)
{:db (fork/set-submitting db path false)}))
(defn app []
[fork/form {:path [:form]
:form-id "my-form"
:prevent-default? true
:clean-on-unmount? true
:on-submit #(rf/dispatch [:submit-handler %])}
(fn [{:keys [values form-id handle-change
handle-blur submitting? handle-submit]}]
[:form
{:id form-id
:on-submit handle-submit}
[:input
{:name "input"
:value (values "input")
:on-change handle-change
:on-blur handle-blur}]
[:button
{:type "submit"
:disabled submitting?}
"Submit Form"]])])
The :on-submit callback receives a map with
:values, :dirty,
:path, :state, and
:reset. The dirty key
tells you which values changed from their initial state. Use
set-submitting to toggle the submission guard:
Fork prevents double-submits while submitting?
is true.
Composable State Helpers
Fork's global helpers (set-submitting,
set-waiting,
set-error,
set-server-message) are deliberately
side-effect-free. They take state and return updated state. This makes
them composable inside a single swap! or
Re-frame event, preventing unnecessary re-renders:
;; Composable: one state update, not two
(swap! state #(-> %
(fork/set-submitting path true)
(update :some-key inc)))
;; In a Re-frame event
(rf/reg-event-db
:handle-response
(fn [db [_ path result]]
(-> db
(fork/set-submitting path false)
(fork/set-server-message path "Saved!"))))Next Steps
- Configuration — all form options in detail
- Validation — pluggable validation with any library
- Field Arrays — dynamic field groups with drag-and-drop
- API Reference — every handler and option