I’m sure some of you have experienced this as well, in a growing LiveView application it can become difficult to trace flow of data between a LiveView and child components that communicate via send/2
/ handle_info/2
.
Typically, it looks like this:
# In AppWeb.ParentLive
def render(assigns) do
~H"""
<.live_component id="child" module={AppWeb.ChildComponent} />
"""
end
def handle_info({:from_child, data}, socket) do
# Handle message from child
end
# In AppWeb.ChildComponent
def handle_event("some_event", _params, socket) do
send(self(), {:from_child, %{some: :data}})
{:noreply, socket}
end
With many children each sending multiple messages to a parent, you can see how this might make navigating code confusing.
Now, what if we could simply pass functions defined in the parent LiveView to children, like how is typically done in React? The reason we “can’t” do this is because we need access to socket
… but there’s nothing stopping us from hiding implementation details with a macro.
Take this counter example using a defcall
macro (shown at the end of the post):
defmodule AppWeb.CounterLive do
use AppWeb, :live_view
import Defcall
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
def render(assigns) do
~H"""
<.live_component
id="counter"
module={__MODULE__.Counter}
count={@count}
on_inc={&inc/1}
on_dec={&dec/1}
/>
"""
end
defcall inc(by_value, socket) do
count = socket.assigns.count
{:noreply, assign(socket, :count, count + by_value)}
end
defcall dec(by_value, socket) do
count = socket.assigns.count
{:noreply, assign(socket, :count, count - by_value)}
end
defmodule Counter do
use AppWeb, :live_component
def render(assigns) do
~H"""
<div>
Count: <%= @count %>
<button phx-click="inc" phx-target={@myself}>Increment</button>
<button phx-click="dec" phx-target={@myself}>Decrement</button>
</div>
"""
end
def handle_event("inc", _params, socket) do
socket.assigns.on_inc.(1)
{:noreply, socket}
end
def handle_event("dec", _params, socket) do
socket.assigns.on_dec.(1)
{:noreply, socket}
end
end
end
Neat! Now, we can define functions in our parent and pass them to child components. It looks like they’re being called directly, but in reality we’re using the same message passing technique as before behind the scenes.
Here’s the full macro code for reference:
defmodule Defcall do
@doc """
This:
defcall inc(by_value, socket) do
count = socket.assigns.count
{:noreply, assign(socket, :count, count + by_value)}
end
Becomes this:
def inc(by_value) do
send(self(), {:__prop_callback__, :inc, [by_value]})
end
def handle_info({:__prop_callback__, :inc, [by_value]}, socket) do
count = socket.assigns.count
{:noreply, assign(socket, :count, count + by_value)}
end
"""
defmacro defcall(fn_spec, do: block) do
{fn_name, _, args} = fn_spec
# Drop the socket arg, should add some error handling here
args = Enum.drop(args, -1)
quote location: :keep do
def unquote(fn_name)(unquote_splicing(args)) do
send(self(), {:__prop_callback__, unquote(fn_name), [unquote_splicing(args)]})
end
def handle_info(
{:__prop_callback__, unquote(fn_name), [unquote_splicing(args)]},
var!(socket)
) do
unquote(block)
end
end
end
end
Anyway, just wanted to share and hear if anyone had thoughts on a system like this. And even if it’s flawed somehow, it’s amazing how easy it is to expand our tools using the language constructs available to us.
How else have you organized messaging between components?