Written using Phoenix 1.7.2 and Phoenix LiveView 0.18.18
Every now and then in my daily development work, I find something within Phoenix and LiveView that surprises me a little bit, that requires a bit of thought and tinkering to make it work the way I would expect.
Today's topic: flash messages within LiveView components.
Most of us are probably familiar with flash messages - the little (usually coloured) boxes that pop up to let us know pertinent information that we might find interesting, usually related to what we're doing right now.
How Phoenix presents flash messages, with default styling from Phoenix 1.7.
On the web they're usually presented after a state change, page redirect, etc. If you submit a form to edit a blog post, you'll typically be redirected to a different page and then the flash message will display.
But Phoenix, LiveView and its component system for rendering pages make this a little bit different.
In Phoenix, flash messages are typically at the top page level, for the whole page - LiveView makes a @flash
assign available to your liveviews, and for use in your templates. The default application layout in app.html.heex
uses this assign to render nicely formatted messages, as seen above.
But every child component rendered on the page also has its own flash data, and the two don't overlap.
See the code behind this video
Only flash messages set in the top-level LiveView are accessible.
Conceptually, this kind of makes sense. The way you might show a flash after taking some small action such as liking a post, might be totally different than a flash message you show for something big like editing a post. But in practice, a lot of the time you'll want to use the same display method for flashes coming from anywhere. (A flash message from a form submission shouldn't render differently just because you moved the form into a component!) So what can you do about it?
Elixir already has a native way of sending messages around, and we can use it here to send a flash message from a component to its parent liveview for rendering.
In LiveView, only liveviews themselves run in their own server processes - all live components run in the same process as the liveview they're rendered within. We can take advantage of this and use send/2
from the component, and send a message to self()
(which is the LiveView process).
# in a file like /lib/my_app_web/live/flash.ex
# replace `MyAppWeb` with your app name!
defmodule MyAppWeb.Flash do
def put_flash!(socket, type, message) do
send(self(), {:put_flash, type, message})
socket
end
end
To make this new put_flash!/3
function accessible to all components, you can import it into live_component/0
in your web.ex
- this is what gets called when you call use MyAppWeb, :live_component
.
# /lib/my_app_web/web.ex
def live_component do
quote do
use Phoenix.LiveComponent
import MyAppWeb.Flash, only: [put_flash!: 3]
unquote(html_helpers())
end
end
In your components, you can now use put_flash!/3
instead of put_flash/3
, and you'll get the new message-sending behaviour instead of setting the flash message only within the component itself.
Sending messages is all well and good, but they need to be received to be of any use!
To receive a message, you need a handle_info/3
function definition, the first argument being the message received. If you wanted to add this to a single liveview, you could write it like so:
# in any liveview
def handle_info({:put_flash, type, message}, _params, socket) do
{:noreply, put_flash(socket, type, message)}
end
This takes the flash out of the message sent from the component, and copies it into the flash for the liveview so it gets treated like any other flash message. Awesome! But making this function available to all liveviews is a little bit trickier.
handle_info/3
(and other GenServer callbacks) are a bit special in that they get called dynamically via apply/3
internally, so importing them won't behave as expected. Importing a function into a module doesn't actually add the imported functions into the module - it only makes them callable without needing to specify the module name. You can verify this in iex
:
defmodule Source do
def my_function, do: "Hello world!"
end
defmodule Target do
import Source, only: [my_function: 0]
end
iex> apply(Source, :my_function, [])
"Hello world!"
iex> apply(Target, :my_function, [])
** (UndefinedFunctionError) function Target.my_function/0 is undefined or private
Target.my_function()
iex:3: (file)
So adding the handle_info/3
function to our Flash module and importing it in web.ex
, like we did with put_flash!/3
, won't work.
What we can do, however, is hook into LiveView's lifecycle to add extra functions to liveviews on mount. This uses on_mount/1
to add the hook, and then in the hook, use attach_hook/4
to add the handle_info/3
function definition. (It sounds more complicated than it is!)
In code, it looks like this (using the same MyAppWeb.Flash
module to keep everything together):
# /lib/my_app_web/web.ex
def live_view do
quote do
use Phoenix.LiveView,
layout: {MyAppWeb.Layouts, :app}
on_mount MyAppWeb.Flash
unquote(html_helpers())
end
end
# the same MyAppWeb.Flash module as earlier
defmodule MyAppWeb.Flash do
import Phoenix.LiveView
def on_mount(_name, _params, _session, socket) do
{:cont, attach_hook(socket, :flash, :handle_info, &maybe_receive_flash/2)}
end
defp maybe_receive_flash({:put_flash, type, message}, socket) do
{:halt, put_flash(socket, type, message)}
end
defp maybe_receive_flash(_, socket), do: {:cont, socket}
# And the previous `put_flash!/3` definition
end
on_mount/1
function call in live_view/0
will call on_mount/4
in the named module (in our case, MyAppWeb.Flash
)on_mount/4
function in MyAppWeb.Flash
sets up a new handle_info
callback, pointing it to the new maybe_receive_flash/2
functionmaybe_receive_flash/2
will be called every time handle_info
is called! โ ๏ธput_flash
messages - we can accept those and handle them, setting the flash and returning {:halt, updated_socket}
(so further callbacks won't run).And by including this into all liveviews in web.ex
, any component that calls put_flash!/3
will have the flash handled by its parent liveview and handled appropriately.
See the code behind this video
(I've set up a GitHub repository with some proof-of-concept code getting this working - you can check it out here.)
This technique can be used for a lot of interesting things - in the LiveBeats app it gets used for loading data, setting event handlers, and also handling params across all liveviews.
What else will you use it for?
Special thanks to Chris McCord for pointing me in the right direction!
Want to talk tech on Bluesky? You can find me at @sevensea.cat!
Built using 11ty and TailwindCSS.