re-frame-async-flow-fx

repository·master·Indexed 41 days ago

https://github.com/day8/re-frame-async-flow-fx

A re-frame library for coordinating asynchronous, stateful tasks with dependencies using a data-driven approach. It allows developers to declaratively define complex startup sequences (boot flows), manage parallel task execution, and handle failures gracefully within the re-frame event-driven architecture. It requires re-frame >= 0.8.0.

Tokens
3.1K
Snippets
10
Records
13
Agent score
2%

What's inside re-frame-async-flow-fx

  1. Understand the design philosophy of async control flow

    master

    The library is built on the principle that managing asynchronous task flow is equivalent to managing a state machine. To handle async flows effectively, you need three components:

    1. Retained State: Data describing the current progress of the flow.
    2. Events: Triggers (FSM triggers) that announce when something has happened or failed.
    3. Transition Rules: A set of rules that define how the application state transitions and what further activity is triggered when events arrive.

    Unlike approaches like redux-saga which encode state machines into imperative code (using generators), re-frame-async-flow-fx adopts a data-driven approach. This follows the Clojure preference for "programming in data," allowing for more complex state machines (including failure states and cascades) to be managed via data tables rather than messy, nested imperative logic.

  2. How async flows and rules work in re-frame

    master

    The re-frame-async-flow-fx library coordinates asynchronous tasks by treating them as a series of events. The core mental model is When-E1-Then-E2: when a specific event (E1) occurs, the system dispatches another event (E2).

    A flow is a collection of these rules. Rules allow for complex coordination, such as:

    • Dispatching E3 when both E1 and E2 have occurred.
    • Dispatching E3 when either E1 or E2 occurs.
    • Dispatching multiple events (E2 and E3) when E1 occurs.

    To use this pattern effectively, tasks should follow a 3-event structure:

    1. An event to start the task (e.g., :do-task).
    2. An event if the task succeeds (e.g., :success-task).
    3. An event if the task fails (e.g., :fail-task).
    ;; Example of a flow describing a boot process
    [{:when :seen?        :events :success-db-connect   :dispatch-n '([:do-query-user] [:do-query-site-prefs])}
     {:when :seen-both?   :events [:success-user-query :success-site-prefs-query] :dispatch [:success-boot] :halt? true}
     {:when :seen-any-of? :events [:fail-user-query :fail-site-prefs-query :fail-db-connect] :dispatch [:fail-boot] :halt? true}
     {:when :seen?        :events :success-user-query   :dispatch [:do-intercom]}]
  3. Understand the problem: Coordinating asynchronous app boot sequences

    master

    re-frame-async-flow-fx is designed to solve the complexity of managing application startup sequences in re-frame.

    When an app boots, it must coordinate multiple tasks (e.g., opening websockets, establishing database connections, loading from LocalStore, or fetching S3 configurations). These tasks present several challenges that this library addresses:

    • Asynchronous Dependencies: Tasks are rarely synchronous. You need a way to manage time and dependencies without the complexity of manual state machines or core.async channels.
    • Failure Handling: If a critical startup task fails (e.g., "No Internet connection"), the library helps transition the app into a controlled failed state rather than leaving it in an inconsistent state.
    • Parallelism and Efficiency: To minimize boot time, tasks that do not depend on each other should run in parallel. The library allows for complex dependency trees (e.g., "Start Task A; once finished, start B and C in parallel; once both B and C finish, start D").
    • Non-essential Tasks: Some tasks (e.g., background logging) might be non-essential to the core app functionality and should be handled differently if they fail.
    • Data-Driven Configuration: Following Clojure idioms, the control flow is defined as data, making it easy to reason about in a single location rather than scattered across multiple functions.
  4. Enable debugging for async flows

    master

    You can enable detailed console logging to debug complex flows using two methods:

    1. Globally: Call (async-flow-fx/enable-debug? true) in your top-level events namespace.
    2. Per-flow: Add the :debug? true property to your flow definition map.

    When debugging, it is recommended to provide an :id in your flow map to differentiate log entries when multiple flows are active.

    ;; Global enablement
    (async-flow-fx/enable-debug? true)
    
    ;; Per-flow enablement
    {:id :my-flow-id
     :debug? true
     :first-dispatch [:init]
     :rules [...]}
  5. Run tests for async-flow-fx

    master

    The library uses standard cljs.test.

    To run tests in a browser:

    lein watch

    To run tests with Karma:

    npm install karma-cli -g
    lein ci
    # Browser
    lein watch
    
    # Karma
    npm install karma-cli -g
    lein ci
  6. Install re-frame-async-flow-fx

    master

    Add day8.re-frame/async-flow-fx as a project dependency. This library requires re-frame >= 0.8.0.

    ;; Add to your project dependencies (e.g., in deps.edn or project.clj)
    [day8.re-frame/async-flow-fx "0.4.0"]
  7. Initiate the application boot process

    master

    In your application's main entry function, use dispatch-sync to trigger the initial boot event (e.g., [:boot]).

    Using dispatch-sync instead of dispatch ensures that the application database (app-db) is synchronously initialized before mounting components or views that subscribe to that state. This prevents views from attempting to render with uninitialized state.

    (defn ^:export main
      []
      (dispatch-sync [:boot])            ;; <--- boot process is started
      (reagent/render
        [this-app.views/main]
        (.getElementById js/document "app")))
  8. Register the :async-flow effect handler

    master

    To enable the :async-flow effect handler in re-frame, you must include a require for day8.re-frame.async-flow-fx in your event registration namespace (e.g., events.cljs). Even if you do not call any functions from the library directly, the mere presence of this require causes the effect handler to self-register with re-frame.

    (ns app.events
      (:require
        [day8.re-frame.async-flow-fx :as async-flow-fx] ;; <-- required for self-registration
        ...))
  9. Use event predicates and dispatch functions for advanced flows

    master

    For complex logic where you need to inspect the data carried within an event, use functions for :events (as a predicate) or for :dispatch-fn.

    Example: Dispatching based on event data If a file upload success event returns an ID, use :dispatch-fn to pass that ID to the next event:

    {:when :seen? :events :upload/success
     :dispatch-fn (fn [[e id]] [[:remote/file-uploaded id]])}

    Example: Using a predicate to filter events To dispatch a server error only when an HTTP response status is 500 or higher:

    {:when :seen? :events (fn [[e status]] (and (= e :http/response-received) (>= status 500)))
     :dispatch [:server/error]}
    {:when :seen? :events :upload/success
     :dispatch-fn (fn [[e id]] [[:remote/file-uploaded id]])}
    
    {:when :seen? :events (fn [[e status]] (and (= e :http/response-received) (>= status 500)))
     :dispatch [:server/error]}
  10. Implement the :boot event handler

    master

    Register an event handler using reg-event-fx for your boot event. The handler should perform synchronous state updates (via the :db key) and then trigger the asynchronous flow by returning the :async-flow key containing your flow definition.

    (reg-event-fx                    ;; note the -fx
      :boot                          ;; usage: (dispatch [:boot])
      (fn [_ _]
        {:db (-> {}                  ;; synchronous work
                task1-fn
                task2-fn)            ;; e.g. setting loading state
         :async-flow (boot-flow)}))   ;; kick off the async process
  11. Avoid re-frame warnings with async-flow-fx/notify

    master

    In an async flow, you may want to dispatch events that serve only as signals and do not require a registered re-frame handler. To avoid re-frame warnings about unregistered events, use the built-in no-op handler ::async-flow-fx/notify within your event sequence.

    ;; Using notify to signal a success event without needing a dedicated handler
    {:when :seen? :events [[::async-flow-fx/notify :success-db-connect]]   
     :dispatch-n '([:do-query-user] [:do-query-site-prefs])}
    [{:when :seen? :events [[::async-flow-fx/notify :success-db-connect]]   
      :dispatch-n '([:do-query-user] [:do-query-site-prefs])}
     ...}]
  12. Define an asynchronous flow

    master

    Create a function that returns a declarative data structure describing the sequence of asynchronous tasks. The structure uses :first-dispatch to start the flow and :rules to define transitions based on observed events.

    Key keys in the rule map:

    • :when: The condition to check (e.g., :seen? for a single event, :seen-any-of? for a set of events).
    • :events: The event(s) to watch for.
    • :dispatch: The event to trigger when the condition is met.
    • :halt?: A boolean indicating if the flow should stop after this rule.
    (defn boot-flow
      []
      {:first-dispatch [:do-X]              ;; what event kicks things off ?
       :rules [                             ;; a set of rules describing the required flow
         {:when :seen? :events :success-X  :dispatch [:do-Y]}
         {:when :seen? :events :success-Y  :dispatch [:do-Z]}
         {:when :seen? :events :success-Z  :halt? true}
         {:when :seen-any-of? :events [:fail-X :fail-Y :fail-Z] :dispatch [:app-failed-state] :halt? true}]})