skuttleman / defacto

A light-weight, highly customizable state store for clojure(script)

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

defacto

The defacto state store library. A lightweight, highly customizable state store for clojure(script). You've heard of re-frame or redux? Same patterns. New glue.

;; deps.edn
{:deps {skuttleman/defacto {:git/url "https://github.com/skuttleman/defacto"
                            :git/sha "{SHA_OF_HEAD}"}}}

Usage

(require '[defacto.core :as defacto])
(require '[clojure.core.async :as async])

;; make some command handlers
(defmethod defacto/command-handler :stuff/create!
  [{::defacto/keys [store] :as ctx-map} [_ command-arg :as _command] emit-cb]
  (do-stuff! ctx-map command-arg)
  ;; command handlers can do work synchronously and/or asynchronously
  (async/go
    (let [result (async/<! (do-more-stuff! ctx-map command-arg))]
      (if (success? result)
        (do
          (async/<! (do-more-more-stuff! ctx-map command-arg))
          (emit-cb [:stuff/created command-arg]))
        (defacto/dispatch! store [:another/command! result])))))

;; make some event handlers
(defmethod defacto/event-reducer :stuff/created
  [db [_ value :as _event]]
  (assoc db :stuff/value value))

;; make some subscription handlers
(defmethod defacto/query-responder :stuff/?:value
  [db [_ default]]
  (or (:stuff/value db) default))


;; make a store
(def my-store (defacto/create {:some :ctx} {:stuff/value nil}))

;; make a subscription
(def subscription (defacto/subscribe my-store [:stuff/?:value 3]))

;; dispatch a command
(defacto/dispatch! my-store [:stuff/create! 7])

;; emit an event
(defacto/emit! my-store [:some/event {...}])

Use with Reagent

I love reagent, and I use it for all my cljs UIs. Making a reactive reagent store with defacto is super easy!

(ns killer-app.core
  (:require
    [cljs.core.async :as async]
    [cljs-http.client :as http]
    [defacto.core :as defacto]
    [reagent.core :as r]
    [reagent.dom :as rdom]))

(def component [store]
  (r/with-let [sub (defacto/subscribe [:some/?:page-data 123])]
    (let [{:keys [status data error]} @sub]
      [:div.my-app
       [:h1 "Hello, app!"]
       [:button {:on-click #(defacto/dispatch! store [::fetch-data! 123])}
        "fetch data"]
       [:div
        (case status
          :ok data
          :bad error
          "nothing yet")]])))

                                  ;; anything can go in the `ctx-map`, such
(defn app-root []                 ;; as fns you may want to mock/stub in tests
  (r/with-let [store (defacto/create {:http-fn http/request} {:init :db} {:->sub r/atom})]
                                                                      ;; using [[r/atom]] gets you
                                                                      ;; **reactive subscriptions**!!
    [component store]))

(rdom/render [app-root] (.getElementById js/document "root"))

(defmethod defacto/command-handler ::fetch-data!
  [{::defacto/keys [store] :keys [http-fn]} [ id] emit-cb]
  (async/go
    (let [result (async/<! (http-fn {...}))
          ;; deref-ing the store is NOT reactive and can be used inside command handlers
          current-db @store
          ;; then you can query the db directly instead of using reactive subscriptions
          page-data (defacto/query-responder current-db [:some/?:page-data])]
      (do-something-with page-data)
      (if (= 200 (:status result))
        (emit-cb [::fetch-succeeded {:id id :data (:body result)}])
        (emit-cb [::fetch-failed {:id id :error (:body result)}])))))

(defmethod defacto/event-reducer ::fetch-succeeded
  [db [_ {:keys [id data]}]]
                     ;; query the db from events or other queries
  (let [current-data (defacto/query-responder db [:some/?:page-data id])]
    (assoc-in db [:my-data id] (merge current-data {:status :ok :data data}))))

(defmethod defacto/event-reducer ::fetch-failed
  [db [_ {:keys [id error]}]]
  (assoc-in db [:my-data id] {:status :bad :data error}))

(defmethod defacto/query-responder :some/?:page-data
  [db [_ id]]
  (get-in db [:my-data id]))

Modules

defacto uses a "plugin" architecture so functionality can be extended by including modules. Here are a few to get you started.

  • defacto-forms+ - this combines defacto-res and defacto-forms to help you link user-input with an asynchronous resource.
  • defacto-res - a low-level module for managing asynchronous "resources" in your state store
  • defacto-forms - a simple isolation layer for managing arbitrary user-input

Concepts

The design is vaguely CQS (like most state stores). Here are the concepts and conventions that defacto uses.

Commands

command - a message "dispatched" through a defacto store via defacto.core/dispatch!. Commands are the primary mechanism for "getting stuff done". They can cause any side effect you need (such as emitting events). A command should be a vector with a keyword in first position. Prefer qualified keywords which are present-tense verbs ending with a !.

action - the keyword in first position of a command vector.

handler - the implementation for responding to a specific command action.

;; example command
[:my.domain/create-thing! {:thing :params}]

Events

event - a message "emitted" through a defacto store via dispatching a command (or defacto.core/emit! for convenience). Events are the only way to update defacto's internal db. An event should be a vector with a keyword in first position. Prefer qualified keywords which are past-tense verbs.

event-type - (aka type) is the keyword in first position of an event vector.

reducer - the implementation for updating the db value in response to a specific event type. The reducer must be a pure function.

;; example event
[:my.domain/thing-created {:thing :details}]

Queries

query - a message used to request data from defacto's internal db. They can be used to subscribe to the internal db's updates. A query should be a vector with a keyword in first position. Prefer qualified keywords which are nouns. I like prefixing the name with ?: to make them stand out more in code and autocomplete in my editor.

resource - the keyword in first position of a query vector.

responder - the implementation for finding data in the db relevant to a specific query resource. The responder must be a pure function.

subscription - a deref-able, watch-able ref type that will update when the result of a query changes.

;; example query
[:my.domain/?:thing {:id 123}]

How?

sequenceDiagram
    participant ...
    APP-->>STORE: subscribe! (data?)
    ...->>APP: user interaction, HTTP, WS, etc
    APP->>STORE: dispatch! command
    loop
        STORE->>handler: command
        handler->>...: do stuff!
        handler->>STORE: dispatch! command(s)
    end
    handler->>STORE: emit! event(s)
    STORE->>reducer: DB, event
    reducer->>STORE: DB
    STORE-->>APP: updated sub (data)

Why?

Good question. re-frame is awesome, but it's usually too heavy-weight for my purposes. Sometimes I just want to build things out of tiny, composable pieces.

About

A light-weight, highly customizable state store for clojure(script)


Languages

Language:Clojure 100.0%