Renderless function components
The introduction of HEEx and function components to Phoenix LiveView brought a lot of improvements for building and maintaining UIs in html to the Phoenix community. A simple example of a function component would be extracting a common chunk of markup to be reused.
defmodule Components do
use Phoenix.Component
def alert(assigns) do
~H"""
<div class="alert">
<h3 class="alert__title"><%= @title %></h3>
<p class="alert__body"><%= @body %></p>
</div>
"""
end
end
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<.alert title="Attention" body="Something went wrong!" />
</body>
</html>
That approach works great for abstracting markup and making templates more expressive. But it’s only useful to a project interested in this specific markup. I think a lot of the frustrations of people with frameworks like bootstrap came from the fact that it sounds nice to share components, but as soon as it comes to customizations it’s easy to get into a big mess fast – especially where functionality and how things look are coupled. It would be nice to not only be able to share implementations of components, but also to be able to create higher level components, which share logic and functionality, but without coupling it to markup yet.
Going renderless
Components are considered “renderless” when they’re not rendering any markup on their own, but delegate rendering of information to how the user of the component considers it useful. This approach has been used in client side systems like react or vue for a long time already, but with heex it’s now usable within Phoenix as well.
As an example consider a pagination component. Usually pagination is constraint
only by a few datapoints, like e.g. current_page
and total_pages
. A function
component could transform those two values to all those many intermediate values
needed to actually render a pagination. Sometimes it just makes sense
to abstract (complex) functionality without coupling to a specific and fixed way
to render the data and having a gazillion options to pass around won’t
make anybody happy. That’s where a renderless component can help out.
defmodule Components do
use Phoenix.Component
use Phoenix.HTML
def pagination(assigns) do
%{
current_page: current_page,
total_pages: total_pages
} = assigns
lower_bound = max(1, current_page - 3)
upper_bound = min(total_pages, current_page + 3)
pages = lower_bound..upper_bound//1
setup = %{
is_first: current_page == 1,
is_last: current_page == total_pages,
pages: pages,
current_page: current_page,
total_pages: total_pages
}
assigns = assign(assigns, :setup, setup)
~H"""
<%= render_slot(@inner_block, @setup) %>
"""
end
end
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<.pagination current_page={7} total_pages={20} let={setup}>
<ul class="pagination">
<%= unless setup.is_first do %>
<li class="page-item">
<%= link "«", to: Routes.some_index_path(@socket, :index, %{page: 1}), title: "Go to first" %>
</li>
<% end %>
<%= for page <- setup.pages do %>
<li class="page-item">
<%= link page, to: Routes.some_index_path(@socket, :index, %{page: page}) %>
</li>
<% end %>
<%= unless setup.is_last do %>
<li class="page-item">
<%= link "»", to: Routes.some_index_path(@socket, :index, %{page: setup.total_pages}), title: "Go to last" %>
</li>
<% end %>
</ul>
</.pagination>
</body>
</html>
This works great – though a critic might still say this can be done by precomputing
assigns
.
There’s however an interesting case where those renderless components really shine.
Aria & Accessibility
For react there exists a quite interesting library called downshift, which implements a autocomplete/combobox/searchable select as a renderless component, while taking care of the necessary accessibility requirements to such a component, supplying necessary aria events to users to add to their markup.
I don’t yet see something like downshift coming to HEEx soon. It also deals with event listeners and many of them should be handled on the client side with no server involved.
What could be interesting however is having less interactive components be supplied with proper aria markup, e.g. for validation errors on forms or open/closed state on toggleable content.
I quickly toyed with something like this today and it’s for sure an interesting idea for making it easy to do the correct thing:
<.form for={@changeset} let={f}>
<.field_context form={f} field={:name} let={field}>
<label for={field.id}><%= field.label %></label>
<input
type="text"
id={field.id}
name={field.name}
class={["input", if(field.errors, do: "has-error")]}
{field.input.aria} />
<%= if field.errors do %>
<p class="errors" {field.validation.aria}>
<%= for error <- field.errors %>
<span><%= error %></span>
<% end %>
</p>
<% end %>
</.field_context>
</.form>
As far as I know there’s some work happening on form handling with live view by
the Phoenix team at this time. Maybe this can be a time to think not just about
how to update phoenix_html
helpers to HEEx, but also go beyond that.