lustre-labs / lustre

A Gleam web framework for building HTML templates, single page applications, and real-time server components.
https://hexdocs.pm/lustre
MIT License
949 stars 65 forks source link

No `effect.flat_map`? #75

Closed ghivert closed 6 months ago

ghivert commented 6 months ago

Hi!

While there's an effect.map, there's no effect.flat_map, which could be useful to handle effect chaining instead of having to rely on the update chain? We could need to cascade HTTP calls for example, and it would respect the usual Monad interface 🙂

I can understand it could be an "advanced" feature, but I think it could be nice to open possibilities for development, do you have any opinion on this?

hayleigh-dot-dev commented 6 months ago

The effect concept is lifted straight from elm, and in both cases you can think of them as a monoid (effect.none is mempty, effect.batch is mconcat) + functor. The thinking behind this is there is no guarantee that an effect ever dispatches anything so providing a way to sequence effects may lead to confusing cases where some reasonable-looking code is never executed because an effect somewhere in the chain doesnt dispatch.

Elm "solves" this by having a separate type called a Task which roughly represents "the set of effects that are monadic".


My general approach to lustre for things where i dont have strong opinions is "do what elm does until theres a reason to do otherwise" and so far no one has needed/wanted effect chaining. I think we have a few options:

1) do nothing and expect folks to chain messages themselves 2) ask the lustre_http author to provide some way of chaining http requests specifically 3) come up with our own task abstraction (and ask lustre_http to add task versions of their effects) 4) make the existing effect type monadic

All of these have some shortcomings in my opinion...

1) may end up in a bunch of intermediary message variants that dont do anything but thread efffects – this could be a good or a bad thing depending on your perspective 2) we'd end up asking each individual package for this, and there would be no way to chain effects from other libraries together 3) this is another concept for folks to learn, and the difference between "task" and "effect" is subtle enough to feel redundant 4) making the current effects monadic would mean having effect.new: fn(a) -> Effect(a) and i fear that might encourage folks to write effects that do nothing but dispatch a message: often a sign of poor message design and a misunderstanding of MVU.

Which is to say I'm inclined to sit on it for now and see if a better solution emerges (or more people complain). It would be trivial enough to make effects monadic but impossible to undo once we go down that path so I'd like to hold off for a while.

ghivert commented 6 months ago

Thanks for the detailed explanations. My last real elm project is a little old, and my brain sometimes forgot about things ha ha. By the way, the issue was mainly there to understand the rationales behind the design choice to avoid doing stuffs completely different (because in this case, it would probably be better to just fork lustre and go my own path, which is not something I want, I prefer to find a nice consensus and consolidate around one codebase and mutualize efforts). Maybe it could be moved to discussions 🙂


My general approach to lustre for things where i dont have strong opinions is "do what elm does until theres a reason to do otherwise" and so far no one has needed/wanted effect chaining.

I deeply understand, and I think it's a good thing to stick with what works, especially since elm is working great and built a strong community around the tech, while lustre fixes almost everything that I personally found frustrating in the experience, so it seems a good thing. That being said, I understand the arguments for the different aspects.

I'm not a fan of 1., specifically because I often ended up having to use a bunch of intermediary messages when a lot of effects are needed, and it's something that I'm not really fan. I completely understand that some find it okay, and I don't want to "force" people to integrate chaining if that's not needed.

I agree with the rest of the arguments. I was more inclined to define something like task too, and I was about to write something like EffectMonad on my own. However, I'm not sure the distinction between Effect and Task are really clear for me in elm though. I feel like it's a somewhat arbitrary frontier between the two, because a Task like process.sleep(10_000_000) will probably never fire from your point of view, leading to a situation where the distinction between an Effect and a Task is somewhat blurry.

i fear that might encourage folks to write effects that do nothing but dispatch a message: often a sign of poor message design and a misunderstanding of MVU.

If I'm not wrong, I think you're talking about doing something like effect.from(fn (dispatch) { dispatch(lustre.dispatch(MyMsg)) })? But in the end, what is the difference between this and having a effect.new function? If that's the case, I think when someone want to do bad message design, that people will be able to ha ha 😅


All in all, I'll follow the Task route at the moment, it's not that much work, and it's plainly compatible with the Effect system. I also think it's the best thing to do right now because it's always possible to merge Task into Effect later while doing the reverse (splitting Task from monadic Effect) would be just impossible once integrated.

I'd be more than happy to share my experiments when I'll have something to show if you're interested in this 😉 (I'm working at the moment to go the full gleam stack route, with lustre as the main frontend framework)

hayleigh-dot-dev commented 6 months ago

I feel like it's a somewhat arbitrary frontier between the two

Yeah I agree, and it's a semi-regular point of discussion in Elm too.

On the point about "if someone really wants to dispatch messages they can already" – it's true that if they really want to they can, but the api is cumbersome enough that you aren't encouraged to. Writing effect.new(MyMsg) would be a much more convenient incantation. What we make easy in the api is what people will do, so it's important the mindful of what things a new function makes easy and consider whether we want those things!


For time time-being you can actually hold off inventing your own task thing because you can technically implement monadic chaining today with effect.perform. This is technically an internal API that is only documented because the @internal attribute didn't exist at the time of publishing, so it'll disappear in the docs in the near future. I don't plan on touching it any time soon though, so you could make use of it and implement your own chaining

fn then(effect: Effect(a), next: fn(a) -> Effect(b)) -> Effect(b) {
  // This is necessary because `perform` needs an implementation for
  // `effect.emit` but we can't handle that. If you're not using components
  // this will never come up.
  //
  let dummy_emit = fn(_, _) { Nil }

  use dispatch <- effect.from
  let run_next = fn(a) {
    effect.perform(next(a), dispatch, dummy_emit)
  }

  effect.perform(effect, run_next, dummy_emit)
}

ghivert commented 6 months ago

Thanks a lot for the help!