Hello there,
Whenever I setup a new project, there is a small function I always add: reply/1. What does it do?
In a LiveView mount, handle_event or handle_info
instead of writing {:ok, stream(socket, :posts, Blog.list_posts())}
or {:noreply, stream_insert(socket, :posts, post)}
Therefore, a function called reply/1 could be added into Phoenix. It could be implemented somewhat like that: def reply(socket, reply) when is_atom(reply), do: {reply, socket}
This could increase the readability (it’s faster to “scan” the code of your LiveView).
You can put that little function in your project, but I would suggest to make it a part of LiveView itself.
This has been talked about before a few times. I used to have have separate ok, noreply, reply, cont, halt functions but ultimately I found I preferred just using the plain ol’ bare tuples. I harp about “scannability” a lot and actually found this pattern to hinder it. With my eyes zooming through some code without actually reading any of it, I found it basically impossible to catch nested returns without a { poking out at me as the pipe version just looks like any other ol’ pipeline.
I don’t think this pattern is particularly bad for those who like it, but I don’t think it belongs in LiveView as it’s really easy for anyone to implement themselves. Some people also do this which doesn’t require any extra functions:
socket
|> assign(:foo, foo)
|> then(&{:ok, &1})
Also, you will want to to change it to reply/3mount is also allow to return a 3-tuple!
First of all, I like the idea and appreciate @Aduril sharing a suggestion.
Yes, the same thing can be achieved in many other ways - but I don’t think that’s the point here.
People new to elixir & phoenix learn about the great syntax and readability.
There is real value in being able to scan and understand code quickly. Pipes do help a lot there. So the idea is good!
Think of it this way: A language and framework should be consistent and predictable. The LiveView functions are something of a special case here. A plug or controller don’t return {:halt, conn} or {:ok, conn}, do they? No, you pipe your conn all the way through.
The best solution for me would be if the @impl functions like mount and handle_* would do the “reply magic” in the Phoenix.LiveView macro on their own.
I see no point in typing {:noreply, socket} in every LiveView because it adds no functionality and no value. (therefore it shouldn’t be in every LiveView you write)
It’s just something you have to explain every time you show LiveView to someone coming new to phoenix.
Those tagged tuples exist, because they do distinguish multiple distinct multi value return types.
:noreply is not the only tag you’re able to return from those functions. E.g. for handle_event may return {:noreply, Phoenix.LiveView.Socket.t()} | {:reply, map(), Phoenix.LiveView.Socket.t()}.
And even for the callbacks, which cannot currently allow multiple distinct tagged tuples that’s what allows them to evolve without breaking changes to eventually in the future have additional return tuples. E.g. handle_event didn’t always have the :reply tuple as a valid return type. It was eventually added once the integration with js hooks became a thing. That would’ve been a more complicated change if you would’ve been allowed to just return state without tagged tuple before.
Also this approach doesn’t really exist in isolation as well. The way those return values are structured are quite consistent between many different behaviours of various levels of abstraction backed by a process. You can look at GenServer, :gen_statem, GenStage, Broadway, Phoenix.Channel, Phoenix.LiveView and you’ll find that approach taken between all of them.
So this actually is achiving what you’re calling for:
Then you need to explain to a new team member that the reply/2 call must be the last of the “train”. You are replacing one idiom with another, more obscure one.
Is the “explaining it to newcomers” really such a big deal? This often gets used as an argument for several things and I feel it might be overstated. In this particular case, return tuples are consistent and there are no gotchas. You can go a very long time without understanding exactly why it is that way and still be productive in LiveView. Meanwhile in the world’s most used web framework, you have to worry about pretty insane things like memoizing functions if they are defined in a certain place or remembering not to wrap your state setup in conditionals that are much harder to explain.
Well,
I would not have thought that my suggestion would cause so much discussion. Nice to see that the community is so lively
In general I agree, that this would not be any big change, nor would it make LiveViews essentially better. What I like is, that you can keep the pipeline flow a little bit better. Yes, I know that |> then(fn socket -> {:no_reply, socket}) does the codewise the same, but when I recently introduced some of our junior devs to our phoenix project, I saw that these helpers at least caused some cluttering in their minds and it did not help them to focus on the relevant parts.
I think my core issue is, that for a beginner it seems like an arbitrary rule at the beginning, that you have to learn without seeing the benefit.
I’ve been working on a project for a while that was first built by a lot of Elixir “newbies” sort of speak (at least that’s what I heard) and this is one of the first things that immediately caught my attention when I started browsing some of the project’s LiveViews.
I think this is mainly because a lot of people who first learn Elixir get mesmerized by how beautiful pipelines are and try to shoehorn everything in there (yeah, it’s cool I know, been there done that). But sometimes I find that this obsession tends to create an extra cost of overly abstracting things at the expense of readability.
I’ve been biting my tongue in this thread on that so thanks for saying it
I love the pipe operator and use it often but as you said, a lot of folks go out of their way to make everything a pipeline claiming it’s more readable. I think they are mistaking “readability” with “pretty-looking”. I agree, pipelines are super pretty but they become horrendously unreadable if they change types too often—sometimes even when they change types just once—and especially when they perform side-effects in the middle And “Just stick a |> dbg() at the end” is not a solution—if you have to debug code to understand it, that is the very definition of unreadable.
I’m not against abstraction, but poor abstractions create a level of indirection that makes things harder to follow, so keep that in mind.
So far I think we are on the same side
I think this is mainly because a lot of people who first learn Elixir get mesmerized by how beautiful pipelines are and try to shoehorn everything in there (yeah, it’s cool I know, been there done that). But sometimes I find that this obsession tends to create an extra cost of overly abstracting things at the expense of readability.
I think, I see what you mean, but here the shoehorning is entirely not the case, isn’t it?
The paradigm of LiveView encourages exactly that pattern with a socket being transformed into another socket. This pattern breaks only at the end.
I would necessarily say that the reply function would be a big win regarding this, but for me it feels like a small imperfection within the framework to have a ceremony at the end that serves no real benefit for most of the basic use cases*. Do you see my point there?
Edit:
* given that there are of course use cases, where it serves a purpose. Though relevant, I personally so far encountered just a handful of those cases.
I think this is where the mismatch comes from. Indeed in the case of {:noreply, state} or {:ok, state} it feels like you’re only transforming state. But there are in many places other options as well.
mount/3 can also return {:ok, state, keyword}
handle_call/3 can also return {:reply, term, state}
handle_event/3 can also return {:reply, map, state}
Those callbacks are not just transformations of state, but transformations of state is just one of potentially many things they do and return information about. Sometimes those other things are even the only thing happening with no changes to state.
E.g. for me most simple callbacks look like this:
def handle_event("something", _, socket) do
socket =
socket
|> assign(a: :something)
|> update(:b, fn x -> x + 1 end)
{:noreply, socket}
end
The state transformation is neatly contained in a pipeline, but the return of that state transformation is separate to the transformation itself. It doesn’t belong in the pipeline. This becomes apparent if the code changes and you need to return a reply:
def handle_event("something", _, socket) do
socket =
socket
|> assign(a: :something)
|> update(:b, fn x -> x + 1 end)
{:reply, %{b: socket.assigns.b}, socket}
end
:noreply is literally telling the caller of the callback “there’s no reply to send for this one”. That’s not a state transformation.
Agree with the sentiment of it’s up to personal preference, myself I stick to the tuple as I try to not deviate too much from the standards as it will be harder for someone new joining the project to grok all the “in-house rules”.
What I do personally is to have a shortcut in VSCode. So “nr” + tab becomes the no reply tuple.
I was going to reply the same thing, and I think there’s a huge learning opportunity here about premature abstractions… In my experience, It’s rarely the case where your system’s data shares so many properties that you can just pipe it to infinity, but I think the main point is that you are essentially just trading one idiom for another, which is less expressive, more limited and only hides away something pretty easy to type.
To me at least, when I look at functions like ok(), reply(), or noreply() it looks like a leaky abstraction because it only hides the implementation details (if any) and it doesn’t remove enough cognitive load to justify its usage. For instance, if you are used to working with GenServers you know you can return a lot more on a :noreply result, so how useful is it really to abstract away a tuple by parametrizing its values?
All in all, I think the bigger picture is that you either get a lot of value from an abstraction that justifies its usage or you end up having an unnecessary one.
Just to add one more thing I really disliked like when I used this pattern was that any one-liner error cases (useful for small form that don’t need additional messaging than just the form error) suddenly became three lines, giving them far more visual weight than necessary.
I love chaining pipeline myself; however, I try to limit the chaining to functions that return a value in the same shape. socket and {:noreply, socket} are not the same shape. My idiom for handle_event is:
def handle_event("something", _, %Socket{assigns: %{...}} = socket) do
{:noreply, do_something(socket, ...)}
end
defp do_something(socket, ...) do
socket
|> assign(...)
|> push_event(...)
end
I only use the handle_event to destructure the socket, and all operations on socket is done in do_something call, which ofen contains long chain of piping function calls.
Disclaimer: I don’t think my idiom is the one true idiom that everyone else should follow.
I agree and did something similar to make the code easier to read IMO. Some might have objections because it adds special conventions to the code base but it works well for me. Especially the noreply/1 which is super simple.
I believe this is the wrong way to think about it. We’re certainly transforming a socket, but what we’re returning is a different value altogether that contains the socket but also additional information that is used for branching. If we really wanted to make it about piping a socket all the way through, that additional info would need to be part of the socket, ie, have a socket.reply key or something like that. But of course I don’t think it really belongs there, so we need to return a wrapper value. If Elixir had built-in monads then we could do something closer to what you’re talking about where we shove the wrapped monadic value all the way through from the get-go, but introducing a pipeable function is just masking what is going on and not actually solving what you’re talking about. All it does is give us a visual pipeline.
Yes, IMO this is the right way to see it in the context of a LiveView pipeline. We’re transforming the socket except in the last step when we’re returing a tuple. And as such we should not hide this in a opaque pipe step. But I like the shortcut |> noreply() too much because it is nice visually
More generally, a pipeline step transforms A into B which could be of a different type from A. Since the use of |> noreply() is the last step of the pipeline, it is more forgivable, even if it breaks the LiveView convention.
I just wanted to discuss the matter a little bit but I do not agree with incorporating that idea in Phoenix given the reasons you exposed.
As a side note, I’ve also used basic monadic code based on the idea of Railway Oriented Programming by Scott Wlaschin and a post on Medium. Stuff like:
defmacro left >>> right do
quote do
(fn ->
case unquote(left) do
{:ok, x} -> x |> unquote(right)
{:error, _} = expr -> expr
end
end).()
end
end
That monad thing is neat, though I personally don’t actually care that Elixir doesn’t have codified monads and kind of like that it doesn’t. Elixir hits this really nice mix of elegant and scrappy for me that not only makes it productive but also less gatekeep-y.