A ClojureScript microlib for state management in a Helix-based React app
deepstate is a tiny library providing hooks-based state management operations
for Helix apps.
It's probably not very performant as the single source of truth in a large app
(there is nothing like
Reagent's Reactions) -
but it doesn't need to be the single source of truth, and the
deepstate primitives are flexible, and straightforward
to use in an async world
(require '[deepstate.action :as a])
(require '[deepstate.action.async :as a.a])
(require '[deepstate.action.axios :as a.ax])
deepstate reduces a stream of events (called actions) onto a state value.
under the hood lives a React useReducer hook, which deepstate builds on
to help you define complex actions with ease
useReducer ?deepstate is fundamentally a vanilla React useReducer, but the values
dispatched to the underlying React useReducer are functions of state -
(fn <state>) -> <action-effects>, i.e. a function of state returning
action-effects. The action-effects may include state updates, but may
also include navigation, further dispatches and a promise of
later delivery of more action-effects. This approach provides a lot of
flexibility for dealing with computations (such as async requests) with
evolving state, at the expense of some difficulty creating the function values.
deepstate makes it easy to create and use these functions
state
value and a dispatch functiondispatch - a fn returned by the
use-action
hook which sends an action to be handledShows a synchronous state-only action and an asynchronous action. Clicks result in consistent data however they are interleaved:
(a/def-state-action ::inc-counter
[state _action]
(update state ::counter inc))
(a.a/def-async-action ::inc-delay
[state
{data ::a/data
:as _async-action-state}
_action]
;; a promise of the action data
(promesa.core/delay 2000 5)
;; effects which can use the destructured action data
;; or other state
{::a/state (update state ::counter + data)})
(def action-ctx (a/create-action-context))
(defnc App
[]
(let [[{counter ::counter :as __state} dispatch] (a/use-action {::counter 0})]
(d/div
(d/p "Counter: " counter)
(d/button {:on-click (fn [_] (dispatch ::inc-counter))} "inc")
(d/button {:on-click (fn [_] (dispatch ::inc-delay))} "+5 delay"))))
Another example showing how the data returned by an async action can be destructured to conditionally create effects:
(a.ax/def-axios-action ::fetch-apod
[state
{status ::a/status
:as async-action-state}
action]
;; a promise of the action data
(axios/get "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY")
;; only create a navigate effect when successful
(when (= ::a/success status)
{::a/navigate "/show-pic"}))
(def action-ctx (a/create-action-context))
(defnc App
[]
(let [[{{status ::a/status
data ::a/data
:as apod} ::fetch-apod}
dispatch] (a/use-action {})]
(d/div
(d/p "Status:" (str status))
(d/p "APOD:" (pr-str data))
(d/button {:on-click (fn [_] (dispatch ::fetch-apod))} "Fetch!"))))
Components interact with deepstate via the
use-action
hook, which returns a [state dispatch] pair of the current state and a
function to dispatch an action map to update state.
actions are maps which describe an operation to change state (or perform
some other effect). They have
an ::a/action key which selects a handler, and may have any other keys
the particular handler requires.
An action is dispatched causing a handler to be invoked according
to the ::a/action key in the action. The handler
returns a (fn <state>) -> action-effects i.e. a function of state,
which when invoked returns
a map of (all optional) action-effects (returning no effects is
not very useful, but perfectly fine).
It is possible to define an action handler directly, with
(defmethod a/handle <key> [action] (fn [state] ...)), but it's
easier to use one of the sugar macros, which allow for some
convenient destructuring:
action-effectsThere are currently 4 effects available:
::a/state - a new state value::a/navigate - a url to navigate to::a/dispatch - an action-map | [action-map] to be dispatched::a/later - a promise of a fn Promise<(fn <state>) -> action-effects>
to provide more effects laterDefines a generic action handler. It takes
::a/action key used in an action mapstate and action-map(a/def-action ::change-query
[state
{q :q
:as _action}]
{::a/state (assoc state ::query q)
::a/navigate "/show-state"})
Defines an action handler which only modifies state - the body form evaluates
to the updated state (i.e. not an action-effects map)
(a/def-state-action ::change-query
[state
{q :q
:as _action}]
(assoc state ::query q))
Defines a promise-based async action handler, which creates a
promise to retrieve some async-action-data and manages an
async-action-state structure in the state to record progress
and results. async-action-data has shape:
{::a/id <random-uuid>
::a/status ::a/inflight|::a/success|::a/error
::a/data <async-action-data>
::a/error <async-action-error>}
The body of the def-async-action definition has 2 or 3 forms:
async-action-data::a/cancel to cancel
the action without evaluating the promise formThe body forms are evaluated separately, and may all use bindings from the bindings vector. Several bindings vector arities are offered:
[action-bindings][state-bindings action-bindings][state-bindings next-async-action-state-bindings action-bindings][state-bindings async-action-state-bindings next-async-action-state-bindings action-bindings]So a simple async action may access the next-async-action-stateand
navigate on completion:
(a.a/def-async-action ::run-query
[__state
{status ::a/status
{id :id} ::a/data
:as _next-action-state}
{q :q
:as __action}]
(run-query q)
(when (= ::a/success status)
{::a/navigate (str "/item/" id)}))
While another action may debounce by comparing the async-action-state
with the next-async-action-state:
(a.a/def-async-action ::debounced
[__state
{p-status ::a/status
:as _action-state}
{n-id ::a/id
:as _next-action-state}
{q :q
:as _action}]
(run-query q)
;; debounce if there is already an inflight query
(when (= ::a/inflight p-status)
::a/cancel)
(when (= ::a/success status)
{::a/navigate (str "/item/" n-id)}))
These def-async-action will assoc the async-action-state map in the
global state at the action key path (the path can be overridden
by providing an ::action/path key in the action map), with the shape.
Exactly like def-async-action,
but the action-data-promise is expected to be an axios
promise, and the responseor error will be parsed into the async-action-state
See the example
folder in the git repo. It's a modified
lilactown/helix-todo-mvc
with an updated React Router,
state management converted to deepstate, and the ::add action being
made async with a simulated network delay and a last-inflight-request-wins
debounce
Build and run the example with npm start
Copyright © 2023 mccraigmccraig of the clan mccraig
Distributed under the MIT License.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |