esc

Type to search...

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
;; 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
;; 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:

app/core.cljs
(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:

app/core.cljs
(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-helpers.cljs
;; 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