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 constructors - Pure, Get (s -> Flow s a), Set s, Command (Cmd (Flow s a)), Await (channel-keyed subscription continuation), Batch, Gather (join point collecting branch results) - 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:
Flow.pure function that constructs a program with no effects, simply returning a valueFlow.map function that lifts a function into the programFlow.join function that allows programs returning an inner program to run that programThis library merges concepts and code from two packages:
chrilves/elm-io - free monad interface for Elmbrian-watkins/elm-procedure - continuation-passing channels and subscriptionsWe 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.
import Flow exposing (Flow)
import Html
import Html.Events
type alias Model =
{ count : Int }
main : Flow.Program () Model Never
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 connect subscriptions to a Flow pipeline. Use Flow.connect to build a channel 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).
-- In Ports.elm
port wsMessages : (ServerMessage -> msg) -> Sub msg
port wsConnect : String -> Cmd msg
type ServerMessage
= ChatMessage String
| RoomEvent 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
Ports.wsMessages
(\_ -> 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)
leaveRoom : Flow Model ()
leaveRoom =
Flow.setAll listening FalseInstead 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 });
}
});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
)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 }))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, over, setAll, viaLens, viaPrism) work with
erlandsona/elm-accessors
lenses 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 normaliseUserviaPrism 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):
handleActivePage : Flow Model (Maybe ())
handleActivePage =
Flow.viaPrism activePage handlePageEventCore
pure (lift a value),lift (lift a Cmd),get / set / modify (read and write the model),andThen / map / seq (sequence and transform),batch / batchM (run multiple branches),gather / gather_ (wait for all branches, collecting or discarding their results),none (terminate the current branch).Channels
connect / listen (describe a channel from ports, with or without a startup command),filterChannel (drop unwanted channel values),openChannel / nextOnChannel (low-level primitives behind await and subscribe).Async
await (suspend until a Channel delivers one value),subscribe (handle every value indefinitely),subscribeWhile (handle values while a predicate holds),yield (force a render cycle before continuing),async (fire-and-forget a sub-computation).Control flow
when (conditional execution),return (run a Flow for its effects, then produce a fixed value),bracket_ (acquire/release resources),setting (hold a Bool lens True for a computation),locking (skip if a Bool lens is already True).Assertions
assertJust (terminate if Nothing),assertOk (terminate if Err),assertCondition (terminate if predicate fails),fromMaybe (unwrap a Maybe or terminate).Tasks
performTask (run an infallible Task),attemptTask (fire-and-forget a fallible Task),attemptTaskWith (run a fallible Task and handle the Result).Optics
try (read through an optic, producing Just or Nothing),forAll (read through an optic, terminating if it does not match),getAll (read all values targeted by a traversal),over (modify value(s) through an optic),setAll (set value(s) through an optic),viaLens (zoom a sub-Flow into a larger model through a lens),viaPrism (zoom a sub-Flow into a larger model through a prism).FFI
ffi (call a JavaScript function by name through a single port pair).BSD-3-Clause