A ClojureScript Finite State Machine library compatible with many state management tools. Valkyrie provides a robust toolkit to model application state transitions with validation, side effects, and a clean API.
{:deps {dev.jaide/valkyrie {:mvn/version "2025.4.15"}}}
[dev.jaide/valkyrie "2025.4.15"]
When it comes to frontend projects, a positive virtue of languages like ReScript or TypeScript is a strong sense of correctness and confidence in your data. Finite State Machines provide a strong sense of confidence in system behavior leading to a more satisfying and productive development experience.
While there are other FSM libraries targeting ClojureScript, I felt there were a few key shortcomings I wanted to address:
Note that any API that handles validation is expecting a hash-map mapping keys to Valhalla-compatible validator functions. See the examples below for how to work with them, or check out https://github.com/jaidetree/valhalla for more info.
Valkyrie provides a simple yet powerful API for defining and using finite state machines in your ClojureScript applications.
First, create a specification for your state machine:
(require
'[dev.jaide.valkyrie.core :as fsm]
'[dev.jaide.valhalla.core :as v])
(def fsm-spec (fsm/create :my-machine))
Define the valid states for your machine:
(-> fsm-spec
(fsm/state :idle)
(fsm/state :pending {:url (v/string)})
(fsm/state :fulfilled {:data (v/hash-map (v/string) (v/string)})})
(fsm/state :rejected {:message (v/string)}))
Define the actions that can trigger state transitions:
(-> fsm-spec
(fsm/action :fetch {:url (v/string)})
(fsm/action :resolve {:data (v/hash-map (v/string) (v/string))})
(fsm/action :reject {:message (v/string)})
(fsm/action :reset))
Define side effects that occur during state transitions:
(fsm/effect fsm-spec :fetch-data
{:url string?}
(fn [{:keys [effect dispatch]}]
(-> (js/fetch (:url effect))
(.then (fn [response] (.json response)))
(.then (fn [body]
(dispatch {:type :resolve :data response})))
(.catch (fn [error]
(dispatch {:type :reject :message (.-message error)})))
;; Return cleanup function (optional)
(fn cleanup []
(println "Cleaning up fetch effect"))))
Define how states transition in response to actions:
(-> fsm-spec
(fsm/transition
{:from [:idle]
:actions [:fetch]
:to [:pending]}
(fn [state action]
{:value :loading
:context {:data nil}
:effect {:id :fetch-data
:url (:url action)}}))
(fsm/transition
{:from [:loading]
:actions [:resolve]
:to [:fulfilled]}
(fn [state action]
{:value :fulfilled
:context {:data (:data action)}}))
(fsm/transition
{:from [:loading]
:actions [:reject]
:to [:rejected]}
(fn [state action]
{:value :rejected
:context {:message (:message action)}}))
(fsm/transition
{:from [:fulfilled :rejected]
:actions [:reset]} ;; :to is not required when defining keyword transitions
:idle))
;; Set initial state
(fsm/initial fsm-spec :idle)
Create a state machine instance:
(def fsm (fsm/atom-fsm fsm-spec))
Trigger state transitions by dispatching actions:
(fsm/dispatch fsm {:type :fetch :url "https://api.example.com/data"})
Access the current state:
;; Using deref
@fsm
;; => {:value :loading, :context {:data nil}, :effect {:id :fetch-data, :url "..."}}
;; Using get
(get fsm :value)
;; => :loading
;; Using get-in
(get-in fsm [:context :data])
;; => #js { "some-key" "some-value" }
Listen for state transitions:
(def unsubscribe
(fsm/subscribe fsm
(fn [transition]
(println "Transitioned from" (get-in transition [:prev :value])
"to" (get-in transition [:next :value])
"via" (get-in transition [:action :type])))))
;; Later, to stop listening:
(unsubscribe)
Properly dispose of the machine when done:
(fsm/destroy fsm)
(def traffic-light (fsm/create :traffic-light))
(-> traffic-light
(fsm/state :red)
(fsm/state :yellow)
(fsm/state :green)
(fsm/action :next)
(fsm/transition
{:from [:red] :actions [:next] :to [:green]}
:green)
(fsm/transition
{:from [:green] :actions [:next]} ;; :to is not required when transition is a single keyword
:yellow)
(fsm/transition
{:from [:yellow] :actions [:next]}
:red)
(fsm/initial :red))
(def light (fsm/atom-fsm traffic-light {:state {:value :red}}))
;; Cycle through the lights
(fsm/dispatch light {:type :next}) ;; => green
(fsm/dispatch light {:type :next}) ;; => yellow
(fsm/dispatch light {:type :next}) ;; => red
(def fetcher (fsm/create :data-fetcher))
(-> fetcher
(fsm/state :idle)
(fsm/state :pending {:url (v/string)})
(fsm/state :fulfilled {:data (v/assert (constantly true))})
(fsm/state :rejected {:message (v/string)})
(fsm/action :fetch {:url (v/string)})
(fsm/action :resolve {:data (v/assert (constantly true))})
(fsm/action :reject {:message (v/string)})
(fsm/action :reset)
(fsm/effect :fetch-data
{:url string?}
(fn [{:keys [effect dispatch]}]
(-> (js/fetch (:url effect))
(.then #(.json %))
(.then #(dispatch {:type :resolve :data %}))
(.catch #(dispatch {:type :reject :message (.-message %)})))
nil)) ;; No cleanup needed
(fsm/transition
{:from [:idle] :actions [:fetch] :to [:pending]}
(fn [state action]
{:value :pending
:context {:url (:url action)}
:effect {:id :fetch-data :url (:url action)}}))
(fsm/transition
{:from [:pending] :actions [:resolve] :to [:fulfilled]}
(fn [state action]
{:value :fulfilled
:context {:data (:data action)}}))
(fsm/transition
{:from [:pending] :actions [:reject] :to [:error]}
(fn [state action]
{:value :rejected
:context {:message (:message action)}}))
(fsm/transition
{:from [:fulfilled :rejected] :actions [:reset]} ;; :to is not required when transition is a single keyword
:idle)
(fsm/initial :idle))
(def data-fetcher (fsm/atom-fsm fetcher))
;; An initial state may be provided on instantiation
;; (def data-fetcher (fsm/atom-fsm fetcher {:state :idle}))
;; Usage
(fsm/dispatch data-fetcher {:type :fetch :url "https://api.example.com/data"})
Valkyrie is designed to be adaptable to different state management systems. You can implement your own adapters by following the IStateMachine
protocol:
Implement the ILookup
and the IDeref
protocols to allow state access:
IDeref
(-deref [this] ...)
ILookup
(-lookup [this k] ...)
(-lookup [this k not-found] ...)
Implement the dispatch
method to handle actions:
(dispatch [this action] ...)
Implement the subscribe
method to allow listeners:
(subscribe [this listener] ...)
Implement the destroy
method for cleanup:
(destroy [this] ...)
Look at the atom-fsm example in core.cljs for how to implement an adapter.
Valkyrie provides a way to generate Mermaid diagrams from your state machines:
(println (fsm/spec->diagram fsm-spec))
This will output a Mermaid flowchart that you can paste into documentation or a Mermaid-compatible viewer.
Distributed under the GNU-GPL-3.0 license
Can you improve this documentation? These fine people already did:
jaide, jaide" (aider) & jaide (formerly eccentric-j)Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close