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:
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 ()
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 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 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 }))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.
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 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,
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 handlePageEventComposing flows
pure (lift a value),andThen / map / seq / return (sequence and transform),join / ap / flap / compose (monadic plumbing),traverse / mapM (run a list of flows in sequence, collecting results).Control flow
Terminating a fiber
none (terminate the current fiber),assertJust (terminate if Nothing),assertOk (terminate if Err),assertCondition (terminate if predicate fails),fromMaybe (unwrap a Maybe or terminate).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
lift (lift a Cmd),liftUpdate (lift a TEA-style update function),performTask (run an infallible Task),attemptTask (run a fallible Task, discarding its Result),attemptTaskWith (run a fallible Task and handle the Result).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
ChannelSpec / Channel / ChannelKey,connect / listen (describe a channel from ports, with or without a startup command),filterChannel (drop unwanted channel values),await (suspend until a channel delivers one value),subscribe (handle every value indefinitely),subscribeWhile (handle values while a predicate holds),openChannel / nextOnChannel (low-level primitives behind await and subscribe),ffi (call a JavaScript function by name through a single port pair).Running a program
Program / element / document / application.BSD-3-Clause