If anything Reader and Writer are not strictly needed. The distinction between Reader, Writer, and State is largely about semantics and safety more than powers or capabilities. If you had just State you could
- put configuration in the state and read it out
- put logs in the state and then just write to them.
So, if you wanted, if you had no need to fail fast with errors and so only needed RWS then you could instead use the elm-state 3.0.1 package. If you wanted fail fast you could technically just have a StateT ExceptT.
However, what discriminating gives you is a loud signal about how the data should be used. Configuration provided by the Reader is not returned as a result when you run the monad stack. Anything in Reader declares “Hey! This data is read-only.”. Conversely, things written (tell) to the writer are invisible to the rest of the stack. If some prior computation wrote something that should not impact subsequent computations because written/logged things invisible - and that is supposed to be a good thing. Now code in a parent stack can run child stacks manually and inspect their logs BUT a stack cannot see the logs of prior and you have to got out of your way to break the contract for children.
For example, if you had a REALLY complicated computation that needed to run for an Elm component you might do something like
-- a Stack for a given Model, Msg pair
type alias Stack config a =
RWSE config (Cmd Msg) Model Error a
-- This computation is clear it needs only a personApi and it produces no value
fetchPerson : PersonId -> Stack { r | personApi : PersonApi } ()
fetchPerson =
LocalCache.fetchIfNecessary personCacheLens (.personApi >> .getPerson) ReceivePerson
fetchPet : PetId -> Stack { r | petApi : PetApi } ()
fetchPet =
LocalCache.fetchIfNecessary petCacheLens (.petApi >> getPet) ReceivePet
update : { r | personApi : PersonApi, petApi : PetApi } -> Msg -> Model -> ( Model, Cmd Msg )
update config msg model =
let
runStack stack =
case S.runWith config model stack of
(newModel, cmds, Ok ()) ->
( newModel, Cmd.batch cmds )
(_, _, Err apiError) ->
( { model | error = ApiError.display apiError }, Cmd.none )
in
case msg of
LoadPeople personIds ->
runStack (S.for_ personIds fetchPerson)
ReceivePerson (personId, personResult) ->
LocalCache.receive personCacheLens personId personResult
LoadPets petIds ->
runStack (S.for_ petIds fetchPet)
ReceivePet (petId, petResult) ->
LocalCache.receive petCacheLens petId petResult
Where the fetchIfNecessary might be
fetchIfNecessary :
Lens model (Sort.Dict id (RemoteData ApiError a))
-> (config -> (id -> Cmd (Result ApiError a)))
-> (id -> Result ApiError a -> msg)
-> id
-> RWSE config (Cmd msg) model ApiError ()
fetchIfNecessary lens fetchFromConfig returnToMsg id =
let
load =
S.ask
|> S.andThen (\config ->
S.tell [ fetchFromConfig config id |> Cmd.map (returnToMsg id) ]
)
|> S.andThen_ (S.modify <| Lens.update lens <| Dict.insert id RemoteData.Loading)
in
S.get
|> S.andThen
(\model ->
case Maybe.withDefault RemoteData.NotAsked <| Dict.get id (lens.get model) of
RemoteData.Success _ ->
S.doNothing
RemoteData.Loading ->
S.doNothing
RemoteData.NotAsked ->
load
RemoteData.Failure error ->
if ApiError.canNeverRecover error then
S.throw error
else
load -- we'll try again!
)
Now all of this probably looks like a ton of ceremony for no justifiable reason but
- It is really useful when you looping where each iteration of the loop may need to update state and produce commands while potentially failing.
for_, combine (sequence), combineMap (traverse), and foldM are REALLY useful when you need them.
- It provides a principled foundation for chaining model updates and command production. We have Cmd.Extra and a bunch of Apis in the community that solve the problem simply and work well for the 95% case but a Reader Write State Error monad stack is just a little more robust.
As a side note: I am aware that the example above could potentially send dozens or hundreds of independent requests which would often be a terrible idea due to Http overhead. 1. It was just an example. 2. In a real world scenario I might use this pattern if I had a service worker on the client that batched commands (waiting for a quiet period) and then sent them to an application gateway. If I did that I would be choosing client-side Api simplicity over performance.