421anon/elm-flow/6.0.2

elm-flow

Write effectful Elm logic as composable steps.

Beginners: Flow is a data type representing an Elm imperative program that can do things ("read/write the model", "call this API and take its return value", "wait for an incoming value on a port") and the library runs them. Your views emit Flow values as messages; nothing else to wire up. There is simply no update function.

Experts: Flow s is a free monad over state, Elm commands, subscription continuations, concurrent fan-out, and join points. Its internal constructors - Pure, Get (s -> Flow s a), Set s (Flow s a), Command (Cmd (Flow s a)), Await (channel-keyed subscription continuation that opens a new channel or resumes an open one), Batch, Gather (join point collecting branch results), Deliver (a branch's JSON-encoded result handed to its join point) - form a composable algebra over Elm's runtime effects. The Msg/update dispatch layer is eliminated; an interpreter takes its place.

What makes Flow s a monad? The fact that:

  • there is a Flow.pure function that constructs a program with no effects, simply returning a value
  • there is a Flow.map function that lifts a function into the program
  • there is a Flow.join function that allows programs returning an inner program to run that program
  • that the above three uphold trivial monad laws (such as mapping the identity function doesn't change the Flow)

This library merges concepts and code from two packages:

We wish to express our deep respect and heartfelt thanks to the authors of these packages. elm-flow is largely an amalgamation of the two enabled by the generosity of their authors, publishing these under the permissive MIT and BSD-3 licenses, respectively. We hereby republish our entire derivative work under BSD-3.

Quick start

import Flow exposing (Flow)
import Html
import Html.Events

type alias Model =
    { count : Int }

main : Flow.Program () Model ()
main =
    Flow.element
        { init = \_ -> ( { count = 0 }, Flow.none )
        , view = view
        , subscriptions = \_ -> Sub.none
        }

view : Model -> Html.Html (Flow Model ())
view model =
    Html.button
        [ Html.Events.onClick (Flow.modify (\m -> { m | count = m.count + 1 })) ]
        [ Html.text ("Count: " ++ String.fromInt model.count) ]

Channels

Channels connect subscriptions to a Flow pipeline. Use Flow.connect to describe a channel as a ChannelSpec from an Elm subscription port and a command port (or Flow.listen when a subscription port alone is enough), then pass it to Flow.await (receive one value) or Flow.subscribe (handle values indefinitely); each of these opens a live Channel from the spec. Pass a port directly, or wrap it to decode values; values that fail to decode are dropped.

-- In Ports.elm
port wsMessages : (Json.Decode.Value -> msg) -> Sub msg
port wsConnect  : String -> Cmd msg

type ServerMessage
    = ChatMessage String
    | RoomEvent String

serverMessage : Json.Decode.Decoder ServerMessage
serverMessage =
    Json.Decode.oneOf
        [ Json.Decode.map ChatMessage (Json.Decode.field "chat" Json.Decode.string)
        , Json.Decode.map RoomEvent (Json.Decode.field "event" Json.Decode.string)
        ]

-- Assumes a Model with:
--   messages  : List String   -- lens `messages`
--   events    : List String   -- lens `events`
--   listening : Bool          -- lens `listening`

roomChannel : String -> Flow.ChannelSpec ServerMessage
roomChannel roomId =
    Flow.connect
        (\toMsg -> Ports.wsMessages (Json.Decode.decodeValue serverMessage >> Result.toMaybe >> Maybe.andThen toMsg))
        (\_ -> Ports.wsConnect roomId)

handleMessage : ServerMessage -> Flow Model ()
handleMessage msg =
    case msg of
        ChatMessage text -> Flow.over messages ((::) text)
        RoomEvent ev     -> Flow.over events   ((::) ev)

-- Runs until `model.listening` is set to False.
-- Cancellation is lazy: the subscription stops after the next incoming message.
listenRoom : String -> Flow Model ()
listenRoom roomId =
    Flow.subscribeWhile (\model _ -> model.listening) handleMessage (roomChannel roomId)
        |> Flow.map never

leaveRoom : Flow Model ()
leaveRoom =
    Flow.setAll listening False

FFI with dynamic dispatch

Instead of creating a new port for every JavaScript interaction, you can use Flow.ffi to call many JS functions by name using a single pair of ports.

You provide the JS function name, an encoder for the outgoing data, and a decoder for the return value.

-- 1. Define one pair of ports
port ffiOut : { key : String, fn : String, value : Json.Encode.Value } -> Cmd msg
port ffiIn : ({ key : String, value : Json.Decode.Value } -> msg) -> Sub msg

-- 2. Wire them into a helper
callJs : String -> (a -> Json.Encode.Value) -> Json.Decode.Decoder b -> a -> Flow s b
callJs =
    Flow.ffi Ports.ffiOut Ports.ffiIn

-- 3. Call any JS function by name
fetchData : String -> Flow Model ()
fetchData id =
    callJs "fetchData" Json.Encode.string decodeData id
        |> Flow.andThen handleData
// On the JavaScript side:
app.ports.ffiOut.subscribe(async (req) => {
    if (req.fn === "fetchData") {
        const data = await fetch("/api/data/" + req.value).then(r => r.json());
        // Return the data using the exact same key
        app.ports.ffiIn.send({ key: req.key, value: data });
    }
});

More examples

Async action with state preparation and error recovery

Read state, do preparatory work, fire a command, recover on failure - all in a single linear pipeline with no Msg variants or pattern-match dispatch:

runJob : Int -> Flow Model ()
runJob id =
    Flow.get
        |> Flow.andThen
            (\model ->
                -- flush any pending edits before running
                Flow.when model.hasPendingEdits flushEdits
                    |> Flow.seq (setStatus id Loading)
                    |> Flow.seq (callApi (Api.start id model.config))
            )
        |> Flow.andThen
            (\result ->
                case result of
                    Ok _ ->
                        Flow.pure ()

                    Err _ ->
                        setStatus id Failed
            )

Joining concurrent branches

batch forks a Flow into branches; gather joins them back into one, continuing once every branch has finished with all results in branch order. Results are held by the runtime as JSON while other branches are still running, so gather takes a Codec (an encoder and a decoder):

loadUsers : List String -> Flow Model ()
loadUsers userIds =
    Flow.batch userIds
        |> Flow.andThen fetchUser
        |> Flow.gather (Flow.Codec encodeUser userDecoder)
        |> Flow.andThen (\users -> Flow.modify (\m -> { m | users = users }))

When only completion matters, gather_ waits for every branch and continues once with (), no Codec needed:

saveAll : List Draft -> Flow Model ()
saveAll drafts =
    Flow.batch drafts
        |> Flow.andThen saveDraft
        |> Flow.gather_
        |> Flow.seq (Flow.modify (\m -> { m | status = AllSaved }))

A join counts on every command producing exactly one message. If a command in a branch produces several, as Cmd.batch can, the first message to arrive continues the branch, whichever command in the batch it came from, and the rest run outside the join, uncollected. If it produces none, like an outgoing port, the join waits forever. Tasks and HTTP requests are always fine; send commands that never answer before the batch or after the gather. The gather docs have the details.

Optics helpers

Lenses give you composable read/write paths into nested data. The state monad gives you sequenced reads and writes. Put them together and you get a Flow that reads like imperative mutation.

We recommend watching the recording of a talk by Edward Kmett titled "Lenses: A Functional Imperative".

The optics helpers (try, forAll, getAll, over, setAll, ifHas, whenHas, unlessHas, setting, locking, viaLens, viaPrism) work with erlandsona/elm-accessors optics to target sub-fields without manual getter/setter boilerplate.

type alias Model =
    { user : { name : String, email : String, loginCount : Int }
    }

-- Lenses: user, name, email, loginCount (defined once, composed with <<)

recordLogin : String -> String -> Flow Model ()
recordLogin newName newEmail =
    Flow.setAll (user << name) newName
        |> Flow.seq (Flow.setAll (user << email) (String.toLower newEmail))
        |> Flow.seq (Flow.over (user << loginCount) ((+) 1))

viaLens zooms into a sub-model through a lens so you can write a self-contained Flow against it and reuse it anywhere that sub-model appears:

-- operates only on User, with no knowledge of Model
normaliseUser : Flow User ()
normaliseUser =
    Flow.over name String.trim
        |> Flow.seq (Flow.over email String.toLower)

-- embed into the full model through the `user` lens
Flow.viaLens user normaliseUser

viaPrism does the same through a prism, running the sub-Flow only when the prism matches and returning Just of its result (or Nothing when it does not, including when it stops matching while the sub-Flow is suspended). The prism must target the whole model, so the model is a custom type; a lens composed with a prism is not a prism:

type Model
    = Active ActivePage
    | Inactive

handleActivePage : Flow Model (Maybe ())
handleActivePage =
    Flow.viaPrism active handlePageEvent

API quick reference

Composing flows

Control flow

Terminating a fiber

Reading and writing the model

Optics

  • try (read through an optic, producing Just or Nothing),
  • forAll (fork a fiber per value targeted by an optic),
  • getAll (read all values targeted by a traversal),
  • over / setAll (modify or set value(s) through an optic),
  • ifHas / whenHas / unlessHas (branch on whether an optic matches),
  • setting (hold a Bool lens True for a computation),
  • locking (skip if a Bool lens is already True).

Zooming into sub-models

  • viaLens (zoom a sub-Flow into a larger model through a lens),
  • viaPrism (zoom a sub-Flow into a larger model through a prism),
  • replace (zoom with a custom getter and setter).

Commands and tasks

Concurrency

  • batch / batchM (fork into multiple fibers),
  • async (fire-and-forget a sub-computation),
  • gather / gather_ (wait for all fibers, collecting or discarding their results),
  • Codec (encoder and decoder for gather).

Rendering

  • yield (hand control back to the runtime so the browser can repaint, then continue),
  • forceRendering (yield after every state write).

Channels

Running a program

License

BSD-3-Clause