Beta release of step: an experimental package for clean update functions

I’m excited to finally show off a package I’ve been using at work and on side projects for ~10 months: step

The core data structure exposed is a Step model msg a, which is intended to be a light abstraction over the usual (model, Cmd msg) value usually returned from update. If you’ve ever wondered if there was a cleaner way to call nested update functions, or confused yourself trying to apply the “OutMsg” pattern, I think you’ll find it useful.

The package docs provide the motivation, a discussion of prior work, and a few simple usage examples. Maybe the best way to get a lay of the land is to look at the diff for this commit, which step-ifies elm-spa-example. I’ll try to add an examples folder over the coming weeks as well

Consider this a beta release; I want to have people kick the tires and give constructive feedback. If the community finds it useful, I’ll re-release it as elm-some-better-name. I’m eager for all feedback, but particularly eager for answers to the following questions:

  • Do the docs do a good job of getting you up to speed?
  • Are the functions in the package named well?
  • What should the package’s name be?
  • Is the third type variable in the Step type worth its weight?

Feel free to leave questions and comments here, on GitHub, or in person at elm-conf/strangeloop.

Thanks!

13 Likes

This looks really interesting! Just a couple of quick comments.

Your foldSteps is something like Update.Extra.sequence, which is a function I use a lot. It allows you to have simple, fine-grained messages in your Msg type, and yet easily combine them together into a kind of “composite” message when that makes sense. So, I wouldn’t discourage it in the way that your docs do.

The limitations on withCmd seem arbitrary. Why wouldn’t you sometimes want to say Step.stay |> Step.withCmd myHttpCall? For withCmd to silently do nothing in that case seems like a foot-gun. And it seems avoidable … I suppose you’d have to change your internal type so that you always possibly have a Cmd, but that doesn’t seem problematic.

1 Like

Love the idea and especially the onExit functionality. I found the docs to be clear. My suggestion for package name is elm-update. Then the functions would be Update.within, Update.to, Update.orElse, Update.stay (or alt. Update.remain).

Unless there are any cases where nested updates are not to update pages then Step.within could be renamed to Step.page (or Update.page). The new name would make it more specific, concrete and hint more towards it’s specific use case.

You mentioned my package Chadtech/return as an inspiration. Thats really nice. And its really cool for me to see how someone else it taking on this problem, not only because these ideas are just interesting to consider but also because its implicitly feedback about my work on Chadtech/return. So thank you for that.

I do have feedback below, and it looks like a lot of it is negative. Im sorry.

Also, I dont know if its possible, but I would love to collaborate towards a common tool; with you or anyone else interested in these problem. It doesnt have to be Chadtech/return; I dont mind depreciating it. I just really like that theres often only one Elm package for any given use-case.

Common Ground

Im totally with you regarding fresheyeball/elm-return being too haskelly, and janiczek/cmd-extra just not having enough API. The API between your new package and Chadtech/return have a lot of very similar functions so it seems like we are really close to a common understanding.

Uncommon Ground

I think the biggest break-iest difference we have is that I have had cases where I update the model and pass on an a in the same update (given your Step model msg a type). An example use case would be a login page that in the same moment the user successfully logs in, it also changes to a success state, like the UI having a green border with the text “You have successfully logged in!”. Under that case in the update function you want to update the Login.Model and return a User simultaneously. I had a project where a majority of the OutMsg also came with Cmd Msg that weren’t just Cmd.none. Returning a Cmd msg and an a or a Model and an a simultaneously doesnt look possible with your api. Am I wrong about that?

General Feedback

0 Names
Ive seen within else-where with the name mapBoth. I think thats a better name. To me, theres nothing particularly “within”-y about within. I would say the same about to, Step and stay. Maybe to could be fromModel. Regarding Step, lots of things feel like steps, including decoding steps, validation steps, and view function steps. I would think the name should be more particular to the use case of update functions.

1 orElse
I think orElse functions are cool and useful in many cases. However, in this case I see it as an anti-pattern. Its kind of weird to be generating multiple possible conclusions to your update, and then after the fact determining which conclusion to use using orElse. Heres an alternative to your example for orElse that has fewer lines, case statements, and let statements, and it doesnt involve this extra concept of ‘orElse’ (cool as orElse may be, I always prefer fewer concepts to be at work).

    case msg of
          AddEvent e ->
              Step.to { model | calendar = e :: model.calendar }

          AddContact c ->
              Step.to { model | contacts = c :: model.contacts }

          _ ->
              Step.stay
3 Likes

This will take some time to grok, but I intend to do that.

1 Like

I find this exploration really interesting, there is definitelly a need to fill here.

We looked a several ways of doing this and end up with our own Return module that heavily inspired by elm-return. We didn’t use that package because we pass three elements in the tuple. Chadtech/return has Return3 but it doesn’t fit what we want. @Chadtech I will be interesting in discussing a more general solution.

Regarding Step. One pain point for us with update functions is forgetting to include a command in the final result. e.g.

let
   (model1, cmd1) =
       update1
  (model2, cmd2) =
       update2
  (model3, cmd3) =
       update3
in
  (..., Cmd.batch [ cmd1, cmd2 ])

Is easy to forget adding a command in there when adding a new nested update.
With Return we move each update to its own function, avoiding this mistake:

   (model, Cmd.none)
      |> Return.andThen update1
      |> Return.andThen update2
      |> Return.andThen update3

andThen will batch the new commands.

Is there an equivalent in Step?

1 Like

I’m really interested in this approach. I made very similar thing at work.

Is the third type variable in the Step type worth its weight?

Maybe this won’t matter because I would wrap this type for my app, like:

type alias MyStep model msg = Step model msg Events
type Events = Event1 | Event2 | ...

For what it’s worth, what I find nice is not only the ability of returning events, but also separating error from Task. Is it possible with this module? In my experience, each page fetches something from the server but the error handling lives in the parent. The simplified version of my “yet another Return” implementation is here (actually it contains more events). This way I can tell parent “give me only the right thing” without thinking about errors.

Thanks for taking a look Ryan

Maybe the wording in the docs is a little harsh (“Only use this to …”), I’m not opposed to making int a bit more diplomatic. But I do think the argument against using foldSteps in app code is similar to the argument for not manually dispatching messages with Task.perform (Task.succeed MyMsg)): in both cases, the alternative is to factor the code of those update function branches out into named functions, compose them together, and then update the state with the composed function in one shot. To me, that like that transformation will produce better code, but I can definitely see it being a matter of taste.

RE withCmd: I admit it’s been a while since I settled on that design, and I’m having trouble remembering exactly what my reasoning was. I think I mostly just liked the explicitness of knowing which state you’re in when you issue commands; I also feel like there’s an issue in the interaction with orElse, but trying to jog my memory with an example for ~10 minutes didn’t yield anything.

I plan to tackle whether orElse is useful or not in my reply to @Chadtech; In my head right now it’s 50/50. So once that’s done, I can come back and evaluate whether the restriction still makes sense.

Update is definitely a top candidate. Transition is also up there. The main appeal of Step to me is the shortness.

Also keep in mind that the datatype, module name, and package name don’t all need to align with one another perfectly. As an example, I could imagine having the package name be elm-update, and the recommended import line being import Update exposing (Step). That might help avoid the generic-ness of the name Step that @Chadtech pointed out, by priming people’s expectations a bit.


There are cases! This claim definitely needs some examples to back it up, but I think there’s a happy spot between “pages only” and “every piece of state”: it’s something like “state concerned with a self contained interaction”. You can sort of think of step as my set of best practices for working in that happy place.

1 Like

I really appreciate you taking the time to reply Chad. I hope my critiques of return in my docs didn’t come off as a shot across the bow. I think being so close, but with consequential differences means we’re on to something!

I think on some level, our different experiences have led us to a fundamental disagreement on what a good API looks like here. I subjectively like Step, to, stay and within enough to believe they’re experiments worth trying. That being said, I am absolutely down to collaborate as much as possible.

For instance, you’ve got me wondering whether orElse is in fact worth it. I’m going to look through my code that uses it to see how big of an impact removing it would make. IIRC, it comes in handy when you’ve got some update expressions that read well with case msg of, and others that really need case (msg, model) of, and you want to glue them all together. But if that’s the only pro, the simplicity of not having it at all might be worth it.

Regarding the inability to return an a alongside state and commands, I can truthfully say that every time I’ve thought I needed to do this, I was able to refactor the model/msg types involved into a form that didn’t, and that I preferred that form to the original. Usually this involves moving some state that was in the sub-interaction up into its parent interaction. Sometimes it means moving all the state up, leaving it as one interaction. But I’ve always thought the code ended up better for it.

This is another one of those core assumptions that I’m wanting to test out with this beta release. So, If you have examples you think break it, I’d be grateful if you showed off the code. Then I’ll try my hand at refactoring it, and we can have a discussion about the result. I realize this will take up even more of your time; absolutely no hard feelings if you’ve got better things to do.

I’m using this, in a fairly trivial example, and it is definitely cleaner and means I don’t have to keep returning those pesky tuples with Cmd.none (which I’m always forgetting).

In the documentation, it might be worth emphasising that in the init function, you need to change the update to read:

Browser.element { init = init, view = view, update = asUpdateFunction update, subscriptions = always Sub.none }

… as Browser.element demands the tuple in there.

Hey Sebastian, I’m interested in hearing more about this use case.

Are each of those update functions in your psuedocode operating on an independent “slice” of the state? Or is each one dependent on the updated state from the previous one? I think a more fleshed out code example would help clarify.

I’m not quite sure what you mean by “separating error from Task”, but I agree that a lot of the time you want your error handling states up a level from a given interaction’s logic.

I’ve had success modeling this with Step like so

module Login where

update : Msg -> Model -> Step Model Msg (Result Http.Error User)

...

Then, the caller uses the onExit function as usual, and can decide which of their states to enter based on the Result

Hey Ive been thinking about this topic for the last few days. Before I dig in I just want to say I might have been too possessive about this subject earlier on. And maybe that lead me to under appreciating you experimenting with your own approach and over-valuing consensus on this topic. I dont want to discourage you, sorry if I did.

Possible Counter Example

What do you think about this code snippet @xilnocas ? Here the user needs to be returned, the login Model gets updated to Success, and the app tries to redirect home after a two second delay. How would you handle this?

type Model
    = ReadyModel
    | LoggingIn
    | Success


type Msg
    = LoginFinished (Result Http.Error User)


update : Msg -> Model -> (Model, Cmd Msg, Maybe User)
update msg model =
    case msg of
        LoginFinished (Ok user) ->
            ( Success
            , navigateHomeIn2Seconds
            , Just user
            )


navigateHomeIn2Seconds : Cmd Msg
navigateHomeIn2Seconds =
	-- ..

Fundamental Doubts to our Whole Approach

Your Step package, and my Chadtech/return package are both fundamentally about exporting data from a child scope up into the parent scope. A few months back I started to learn about an entirely different approach to this topic of returning data from sub-components that I think of as the “session” approach. The idea is that instead of exporting data, the child-level should just include the data its meant to change. You can see this approach in Richard’s elm-spa-example, and on the elm package website.

The login page update function in Richard’s spa example returns just an ordinary ( Model, Cmd Msg ). It can get away with this because the login page model contains the session, and its the session that we are trying to update with the User. If that information is just stored on the Page level to begin with, the login page only needs to update itself and it doesnt need to export any information. When the user moves from page to page, the session is just handed over between pages.

Im starting to think that techniques like this are better, and if they really are better then it undermines the purpose of any heavy duty update helper package. More and more in my own code I am finding effective ways to return (Model, Cmd Msg), rather than finding effective ways of handling the extra x in (Model, Cmd Msg, x).

1 Like

No need to worry. It’ll take more than that to discourage me :slight_smile:

Refactored Example

If I’m understanding you right, the interaction flow is something like

  • user types info
  • request is made
  • after success wait for two seconds
  • done

I’d just account for that waiting state in the Login's Model , then exit with the User once its over

type Model
    = ReadyModel
    | LoggingIn
    | WaitingToRedirect User


type Msg
    = LoginFinished (Result Http.Error User)
    | WaitFinished


update : Msg -> Model -> Step Model Msg User
update msg model =
    case (msg, model) of
        (LoginFinished (Ok user), LoggingIn) ->
            Step.to (WaitingToRedirect user)
                |> Step.withCmd navigateHomeIn2Seconds

        (WaitFinished, WaitingToRedirect user) -> 
            Step.exit user
        
        -- other cases go here
        
        _ -> 
            Step.stay


navigateHomeIn2Seconds : Cmd Msg
navigateHomeIn2Seconds =
    fireAfterTwoSeconds WaitFinished

RE: Doubts

I’ll have to study the code of those projects to have an informed opinion on how they set things up, thanks for the pointer. A real test of step would be a totally new implementation of the real world app’s update function, but I doubt I have the fortitude to pull that off in the near future. I do have some rough plans for a smaller scale example that should hopefully give people a better idea of what I’m going for.

But at the end of the day, I understand that this is indeed a heavyweight solution to what may end up not being a problem to a lot of people. I don’t have expectations that it’s going to take over the world or anything. I’ve just found it useful, and thought it’d be worth showing off and iterating on to see if I could make it even better, for myself and whoever else cares.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.