Skip to content

Instantly share code, notes, and snippets.

@janwirth
Created March 19, 2022 15:52
Show Gist options
  • Save janwirth/13ef3b0fa09d0c85cbd0a2885e234445 to your computer and use it in GitHub Desktop.
Save janwirth/13ef3b0fa09d0c85cbd0a2885e234445 to your computer and use it in GitHub Desktop.
Elm slack conversation on Widget APIs and Return helpers.
erlandsona 1 month ago
Feel like I keep running into this as I’m building widget API’s based on other widget API’s…
I keep wishing I had a function with a type signature of msg -> msg -> msg that would behave like Cmd.batch but not require me to make my view signature Html (Cmd msg).
jessta 1 month ago
type Msg = Batch (List Msg) | Pizza | Pies
jessta 1 month ago
Is that what you mean?
jessta 1 month ago
A way to batch msgs?
jhbrown 1 month ago
Why can’t you just pass List msg until you’re ready to hit it with Cmd.batch?
erlandsona 1 month ago
So really what I’m working with is something like
view : (String -> msg) -> String -> Element msg
view wrap value =
element [onChange wrap, otherSpecialStyles] (text value)
But I want to be able to do something internal to this view onChange as well so ultimately what I think I need is smt like
module ... exposing (Msg(..), IMsg)
type Msg
= OnChange String
| Msg IMsg
type IMsg
= OnChange_ String
view : String -> Element Msg
view value =
element [onChange (OnChange_ >> Msg), otherSpecialStyles] (text value)
This “defunctionalizes” the callback & will enable me to handle the internal msgs that by way of opaque types I’m forcing the caller to call my update function for & expose the behavior for the caller to hook into.
It just means I’m TEA-ifying the interface instead of having a nice clean singular view function.
Obviously I could add a second callback for the internal msg’s instead but I still have to force the caller to call this modules update and more callbacks means more caller’s Msg Constructors. (edited)
jessta 1 month ago
I read your post three times and I’m still confused what you’re trying to do.
jessta 1 month ago
The type of your view function there would be:
view : String -> Element IMsg
erlandsona 1 month ago
There’s a couple ways to encapsulate a stateful “widget” in Elm (probably more I’m missing here tho)
CPS (aka: callbacks)
Defunctionalized callbacks (outmsg?)
Problem with both or either is that they force the caller to “hook up a TEA” for the “Component”. Yeh yeh, I know, components considered harmful, we’re way past that.
I’m wrapping an input (but I’ve run into this across pretty much any component that requires some degree of state, buttons, modals, etc…) where I want to take some action (render a label) as part of the focus event of the input and then in a wrapping component (autocomplete) I want to also hook into the focus event (to hide/show the results).
For a Button it might be that I want to run an animation on hover but also enable the caller to hook into the hover event.
So ultimately I want Internal Msg’s & External Msg’s attached to the same dom event. (edited)
erlandsona 1 month ago
The type of your view function there would be:
Fixed.
erlandsona 1 month ago
It was mostly pseudo code to explain the issue.
jessta 1 month ago
The solution to this (assuming I know what you’re referring to) is ‘OutMsg’
jessta 1 month ago
For a Button it might be that I want to run an animation on hover but also enable the caller to hook into the hover event.
The button update function would return a value that indicated that a hover occurred and then the caller could then use that to update other state. (edited)
erlandsona 1 month ago
What I’m saying is I’m already aware of the two existing solutions I’ve referred to eg: Translator & OutMsg and the variants there-in (builder, attribute list, etc…), what I’m asking is if there’s something else I haven’t heard of or tried yet that could make it such that callers don’t have to hook up a TEA just because I want to control the boolean state of something internal to a view?
I’m just sort of wishing there was a way to merge two dispirit msg’s together that like bother implemented Monoid or something so that I could just say Msg -> msg -> msg but now that I’m writing it out I don’t even think a function like that makes any sense without TEA (something to connect the dots) anyways.
erlandsona 1 month ago
Sorry just in the middle of my design process and wrecking my brain trying to work out the constraints I want out of the API vs the constraints I can’t have cause Elm isn’t dependently typed (not that that would necessarily fix my issue I just vaguely imagine I could finagle this stuff a bit more fancy-like w/ dep-types.) :man-shrugging: (edited)
erlandsona 1 month ago
Thanks all for rubber-:duck: in’ anyways :man-bowing:
ggpeti 1 month ago
@erlandsona are you aware of elm-io? I guess you are now
erlandsona 1 month ago
Went down that rabbit-hole for a couple weeks.
And while if I was starting from scratch I might reach for it trying to integrate it with an existing application (while possible) doesn’t play nice with the Effect pattern (which we use for reading/writing to a Shared state obj)
Happy to go into more details but basically it boiled down to me having to break the monad laws to get it to work with shared state but by breaking them I broke being able to chain andThen -based actions that we’re also trying to seq Shared.Msgs. :sob:
erlandsona 1 month ago
Sometimes I just CRAVE that elm had GADT’s & Existentially Quantified type params :man-shrugging: such as life.
Other langs/ecosystems don’t have elm-ui so tradeoffs :man-shrugging:
erlandsona 1 month ago
I did ReasonML at my last gig and being able to existentially quantify over an Async type is REALLY convenient for driving state changes for requests…
erlandsona 1 month ago
Of course you also have access to the hooks api (albeit types get a bit finnicky).
ggpeti 1 month ago
ok let's step back a bit. Is Html.map not enough?
erlandsona 1 month ago
What I think people are missing from the fact that I posted this as a question is that I’m not already aware of the existing solutions. Yes Html.map works fine.
It just means the callers have to consume onChange events in their respective update . Which they have to do either way.
erlandsona 1 month ago
Just wondering if anything other than Translator / OutMsg have surfaced I think. Or if people had tinkered with something like Html (Cmd msg) or if that even makes any sense.
erlandsona 1 month ago
I’ve definitely built stuff like Html Model where I change the model as the Msg which works out pretty well in some cases :man-shrugging:
ggpeti 1 month ago
what would you use existentially quantified types for? can you illustrate it with an example? (edited)
ggpeti 1 month ago
also, how did you break the monad laws? feel free to answer these, just prodding out of curiosity
erlandsona 1 month ago
Yeh totally!
how did you break the monad laws?
To be fair, I’m not actually sure cause there weren’t any tests validating the monad laws worked in the first place :sweat_smile: that said, when andThen actions “work” like you’d expect with actions NOT emitting a Shared.Msg then it’s safe to say I broke something… I’m attributing it to the monad laws cause everything works as expected sans-modifications… but I couldn’t figure out how to get Shared.Msg’s out without modifying the internals of the lib :confused: :man-shrugging:
what would you use existentially quantified types for?
https://gist.github.com/erlandsona/1fb108d32ca6bf01e49da9521aedc63f#file-reducert-re-L18
Syntax is Reason but it’s basically TEA… the nice thing about existentials is I can drive the state of the structure of a value in a single Msg constructor rather than forcing Callers to provide lensy access to the model :man-shrugging:
erlandsona 1 month ago
Updating the state of the Async according to axios callbacks…
https://gist.github.com/erlandsona/1fb108d32ca6bf01e49da9521aedc63f#file-request-re-L25
erlandsona 1 month ago
And the first two files are the consumers of the “Interface”… basically, they just pass an endpoint as a string, and store an Async value in the Model and then provide a Msg constructor that carries the same Async value and then that one top-level requestable thing drives the state changes for any request.
It’s basically takes the shape of what you’d do in OO with dynamic dispatch :man-shrugging: (edited)
ggpeti 1 month ago
okay, but how about the initial suggestion to have a Batch (List Msg) constructor as well? then all messages would be in the same typenamespace so to speak
ggpeti 1 month ago
essentially giving rise to an implicit Free
erlandsona 1 month ago
Real issue is I’m dealing with (String -> msg) & type Msg = so we’re really talking about Batch (List (msg, Msg)) but providing both for every instance of either is the wrong idea, so what I really need was a way to “unify” aka: sum type Msg msg = Out msg | Msg Internal now they’re both a
Element (Msg msg) so I’m movin’ forward with that and seeing where I end up :man-shrugging:
ggpeti 1 month ago
Good luck with it. I assume Element is a wrapper around Html.
Now it seems to me that in order to emit an external msg, the widget has to know about the root Msg type anyway. So why not just keep one global Msg like
type Msg
= Batch (List Msg)
| WidgetA WidgetA.Msg
| WidgetB WidgetB.Msg
(edited)
ggpeti 1 month ago
so the elements can actually go back to Element Msg
erlandsona 1 month ago
type Msg msg = Out msg | Msg Internal
That’s basically what this^ is.
ggpeti 1 month ago
written out once per internal msg type
ggpeti 1 month ago
why write Msg Toplevel.Msg in each widget with a locally defined Msg instead of simply Toplevel.Msg everywhere?
ggpeti 1 month ago
the message instances could even be embedded like Nav (Menu Close) (edited)
ggpeti 1 month ago
you could literally send messages across widgets
ggpeti 1 month ago
and to the root application like Logout
John Pavlick:face_with_cowboy_hat: 1 month ago
this is all a billion miles over my head but:
Just wondering if anything other than Translator / OutMsg have surfaced I think. Or if people had tinkered with something like Html (Cmd msg) or if that even makes any sense.
i think @Jan Wirth has been playing with something like this, views that emit commands directly, or something close to that
rupertlssmith 1 month ago
"callers don’t have to hook up a TEA just because I want to control the boolean state of something internal to a view?"
I think if you have widgets with internal state and events, and you need to draw >1 of them on the same page, you have little choice but to do a nested TEA thing with them. If you only need 1, you can make the caller own the state.
Also sent to the channel
ggpeti 1 month ago
thanks for this discussion btw, I will now profess this the true way to build modular elm apps:
separate modules for widgets/pages
with a Msg type
with a Model
with an update function on the local Msg and Model type
with a view function returning Html Toplevel.Msg
a branching toplevel Msg type
as a FreeList (practically with constructor Batch (List Msg)
with branches for all modular Msgs
this way each widget/page can message each widget/page AND the toplevel, AND the message count can be freely changed via Html.map
:eyes:
1
joakin 1 month ago
I like to make data structures with domain functions and view, and the view takes as parameters the msg to emit on each event. (edited)
joakin 1 month ago
Then it is up to the user of the data to call the domain functions where it sees fit, and to pass in the messages to be emitted by the view
ggpeti 1 month ago
You mean the view takes the message constructor, I suppose. Which also means the view function's return type is the non-constrained Html msg.
This is fully compatible with the convention I'm proposing, as long as at the callsite you parametrize your type to be a toplevel Msg. Meaning this view function in a Login component:
viewInput : (String -> msg) -> Html msg
viewInput wrap =
input [ onInput wrap ] []
and this message type in the toplevel:
type Msg
= Batch (List Msg)
| Login Login.Msg
and this message type in Login:
type Msg
= Input String
you would embed the login's input field view as viewInput (Login << Input).
But you could also embed it as viewInput (Account << EmailAddress).
rupertlssmith 1 month ago
I think a (stateful) widget library probably forces you down the nested TEA route. But for building applications, I think encapsulating pages and sending messages between them is not a good idea at all. Here is my argument and alternative (alternative is to flatten and open up the state using extensible records): https://discourse.elm-lang.org/t/should-a-module-expose-its-msg-constructors/8054/10 (edited)
rupertlssmith 1 month ago
I tend to build application modules around slices of the model now, and profess that to be the true way to build modular Elm applications (but not packages, or widget libraries). (edited)
ggpeti 1 month ago
ok @rupertlssmith maybe sending messages across widgets is not a good idea but you still want to send messages from a widget to the root application, right?
o root Msg <--------------
/ \ v----------------------|
o o widgets/pages own Msgs -
ggpeti 1 month ago
even your model slices approach meshes well with what I laid out above
ggpeti 1 month ago
one Model with slices, one Msg with branches
ggpeti 1 month ago
(and batching)
ggpeti 1 month ago
I think the strongest argument against all this is that as a module writer, I don't care about which app I'm embedded in, therefore don't make me construct root Msgs in my view.
ggpeti 1 month ago
and that can be mitigated by Html.map
ggpeti 1 month ago
I don't find the discomfort in this hypothetical project layout.
rupertlssmith 1 month ago
Yes to one Msg with branches. I will create a branch in the Msg for a group of events that are only used within one module, hence no other module needs to know about them.
jhbrown 19 days ago
@rupertlssmith - picking up this old thread - when you start creating modules, how do you handle state? In particular, you’ve advocated, if I understand it right, for slicing the main model with extensible types — do you just pass the whole main model into a submodule’s update and then the submodule stores its state in a page field on the main model or something…?
rupertlssmith 18 days ago
No, I create a slice of the model as an extensible record, and build the submodule around that type. An example here: https://discourse.elm-lang.org/t/should-a-module-expose-its-msg-constructors/8054/2
module User exposing (login, handleLoginResponse, showUserId)
type alias User a =
{ a | username : String
, apiKey : String
}
rupertlssmith 18 days ago
But if I have a Page type say, with 1 constructor for each page, this pattern no longer applies. Since the fields each page needs are different, and I don't want to just flatten all of them into the Model (or they would end up as lots of Maybes).
jhbrown 18 days ago
Yeah, I was asking more about the latter case, where a Page has a bunch of fields for its user input state, etc.
rupertlssmith 18 days ago
Yeah, I have some patterns I use for that, but I am not sure I have worked it out clearly enough to summarize yet as a pattern that I would recommend. The difficultly is when a Page want to update its own fields and also the fields of the top-level Model, but I don't want to use an out message - sometimes I have written the page around the top-level Model, so that it has access to everything it needs to make changes to. Other times I have written the page around its Page specific fields where that is all it needs to update, and kept all the code that needs to change the top-level Model outside of the page. (edited)
jhbrown 18 days ago
I’m presently imagining something where the page update looks something like this (very handwavy):
type alias Model = ....
type Msg = ...
type GlobalModel a = {a | auth: UserAuth, page: page}
update: (Msg -> msg) -> (Model-> page) -> msg -> GlobalModel global -> Model -> (GlobalModel global, Cmd msg)
update toGlobalMsg toGlobalPage globalModel pageModel =
...
SomeMsg -> (globalModel | page = toGlobalPage updatedPageModel}, Cmd.map toGlobalMsg pageCmds)
jhbrown 18 days ago
Gotta run for a bit, but will read on return.
rupertlssmith 18 days ago
What I generally ended up doing is something like this:
module Top exposing (..)
import Update2 as U2
update model msg =
case (model.page, msg) of
(SomePage pageModel, SomeMsg) ->
U2.pure pageModel
|> U2.andThen PageModule.doSomethingWithPageModel
|> U2.andMap (setPage SomePage model)
|> U2.andThen maybeDoSomethingWithTopLevelModel
setPage cons model pageModel =
( { model | page = cons pageModel }
, Cmd.none
)
(edited)
rupertlssmith 18 days ago
The difficulty is if doSomethingWithPageModel needs to return some intermediate value that determines what happens in maybeDoSomethingWithTopLevelModel. Most of the time I have found that I can avoid that.
jhbrown 18 days ago
Gotcha. So you still do unpacking and repacking in top-level update, view, etc.
Jan Wirth 17 days ago
I use custom elements to encapsulate stuff like dropdown state, and for views in my application I wrote my own Return monad
Jan Wirth 17 days ago
module Return.Model exposing (..)
import Http
import Http.Detailed
import Json.Decode as Decode
import RemoteData
import Request.Model
import Toast
type alias ReturnInternal model msg =
{ toasts : List Toast.Toast
, newSeed : Bool
, shouldRefresh : Bool
, model : model
, cmd : Cmd msg
, requests : List ( RequestReturn -> msg, RequestData, Request.Model.Params )
}
type alias Wrap model msg outerModel outerMsg =
{ mapMsg : msg -> outerMsg
, mapModel : model -> outerModel
}
type RequestReturn
= RequestReturn (Return RequestData RequestReturn)
type Return model msg
= Return
(ReturnInternal
-- the responses to be consumed by the using model
model
-- the self-contained updating state that is emitted with the messages
msg
)
| FinalDeauthentication Http.Metadata String
type alias RequestData =
RemoteData.RemoteData (Http.Detailed.Error String) ( Http.Metadata, Decode.Value )
(edited)
Jan Wirth 17 days ago
Any component in my app can deauth the session, hence I need to propagate state
Jan Wirth 17 days ago
Then what I do is
Return.the {model | someUpdatedField = bleh}
|> Return.withRequest myRequest
|> Return.withToast "Request initiated"
|> Return.needsRefresh -- tell parent component that it needs to refresh some data, that's most brittle part
Jan Wirth 17 days ago
The parent then
child.update msg childModel
|> Return.wrapModel setChildModel
|> Return.mapMsg ChildMsg
|> Return.onNeedsRefresh (Return.withRequest someRequest)
Jan Wirth 17 days ago
It scales fairly well across 30k LOC
jhbrown 17 days ago
Interesting. Is requests an outmsg field, roughly?
Jan Wirth 17 days ago
I updated my message. Probably what I’m doing here could be considered an outMsg.
Jan Wirth 17 days ago
The whole thing is a monad I think?
Jan Wirth 17 days ago
Here is how I fetch my data given that the user is logged in. You can add requests on top by mapping over the model.
fetchDataHook :
AppConfig appMsg appModel
-> Return.Model.Return (Framework.Model.Model appModel) (Framework.Msg.Msg appMsg appModel)
-> Return.Model.Return (Framework.Model.Model appModel) (Framework.Msg.Msg appMsg appModel)
fetchDataHook spec =
Return.andThen
{ mapMsg = identity
, mapModel =
\model ->
case Authentication.Model.getSessionData model.auth of
Nothing ->
Return.the model
Just loggedInModel ->
Return.the loggedInModel
|> spec.fetchData model
|> Return.wrap { mapMsg = Framework.Msg.AppMsg, mapModel = always model }
}
Jan Wirth 17 days ago
I don’t find the design very elegant TBH, still a fair amount of boilerplate. I should remove the mapMsg field from my types and have a separate mapMsg function.
Jan Wirth 17 days ago
Plus I’m not entirely convinced of the Request chaining that I built here is right way to go.
Jan Wirth 17 days ago
It’s a flawed design, but the Return.the thing |> Return.withRequest fetchData style I like a lot.
erlandsona 2 hours ago
So I think the issue you’re running into with re to the ambiguity of whether or not “this is a monad” is where is this function?
andThenForReturn : (a -> Return x b) -> Return x a -> Return x b
if you can’t write this function where it makes sense then it’s prolly just an Applicative
andMap : (Return x (a -> b)) -> Return x a -> Return x b
andMap = Return.map2 (<|) -- usually we use the flipped version...
see @joelq articles on “ubiquitous patterns”
Otherwise usually if you just have a regular (re: covariant) Functor.
If the type you’re writing has 2 type parameters and you’re writing “map”-like (a -> b) -> f@List/Maybe/Whatever a -> f b functions for BOTH type parameters like in the case of Result you can mapOk or mapErr that’s actually a “Bi”-Functor… where the proper ‘single’ function is bimap which is composed of lmap and rmap
erlandsona 2 hours ago
So for these TEA “update helpers”, like elm-return or using Tuple functions or you’re own bespoke combinators usually people refer to TEA as a Monad because it is.
But you have to be aware of which type parameter you’re operating over because there’s two and operating over either gets you different behaviors.
EG:
https://package.elm-lang.org/packages/Fresheyeball/elm-return/latest/Return#andThen
andThen : (a -> Return msg b) -> Return msg a -> Return msg b notice how this function is saying I can’t modify the shape of the msg type. And it can’t actually run any functions on that msg type either because it doesn’t have any concrete information about it to DO anything with it other than identity which wouldn’t make any sense.
erlandsona 2 hours ago
You’re approach reminds me a lot of chrilves/elm-io I ran into some serious issues trying to use that in our codebase because it rely’s on recursion to implement andThen and it’s underlying type breaks the elm debugger and because of the recursion when I tried to integrate it with our existing state sharing mechanism modeled after @ryan’s elm-spa Effect.elm where I would expect each state of the Http transitions to be reflected in the model, those transitions only existed in the recursion and the model only reflected the final output of the computation.
The takeaway for me was
IO is synchronous so only use it for Platform.worker
And there’s some tricks to forceRendering but it breaks the monad laws and ends up halting the rest of certain chains. And was generally difficult to work with.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment