Skip to content

A ClojureScript micro-library for managing state in a Helix / React app

License

Notifications You must be signed in to change notification settings

mccraigmccraig/deepstate

Repository files navigation

deepstate

Clojars Project cljdoc badge

A ClojureScript microlib for state management in a Helix-based React app

Summary

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

(require '[deepstate.action :as a])
(require '[deepstate.action.async :as a.a])
(require '[deepstate.action.axios :as a.ax])

Model

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

How is this different to any other 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

Core functions:

  • use-action - a hook used by components to interact with state. It returns a state value and a dispatch function
  • dispatch - a fn returned by the use-action hook which sends an action to be handled
  • def-action - a macro which defines a generic action handler. There are more specialised variants such as:
    • def-state-action - defines an action handler which only modifies state
    • def-async-action - defines an action handler which runs a promise-based async computation and records the evolving status and result in state with a standard schema
    • def-axios-action - an async action for axios requests which parses the responses

A simple example

Shows 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

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.

dispatch

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-effects

There 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 later

Defines a generic action handler. It takes

  • the ::a/action key used in an action map
  • destructuring for the state and action-map
  • a body form defining the effects the handler returns - which may use the bindings destructured from the 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:

  1. a form returning a promise of the async-action-data
  2. (optional) initialisation effects - may also return ::a/cancel to cancel the action without evaluating the promise form
  3. completion effects

The 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

example

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

license

Copyright © 2023 mccraigmccraig of the clan mccraig

Distributed under the MIT License.

About

A ClojureScript micro-library for managing state in a Helix / React app

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published