Hi @conradfr!
I will break the answer in two.
For customizing the head, which is pretty much what Phoenix calls the root layout, I would use assigns:
# layouts/root.html.heex
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><%= assigns[:page_title] || "Default title" %></title>
</head>
<body>
<%= @inner_content %>
</body>
</html>
Why assigns? It works for both regular request/response and Phoenix LiveView. For example, if you want to add dynamic scripts, metadata, etc, you can store them in assigns and render them as part of the layout.
But the root layout is pretty much that: the root layout. We still need to address the actual layout as perceived by users: a sidebar, main content, a footer, etc. And maybe you want different pages to have the same sidebar layout but with different items. For those, use function components are great. For example, you could create a function component like this:
def layout(assigns) do
~H"""
<div id="main">
<ul id="sidebar">
<%= render_slot(@sidebar) %>
</ul>
<%= render_slot(@inner_block) %>
</div>
"""
end
Now, on each page, you can do this:
<.layout>
<:sidebar>
<li><a href="/foo">Foo</a></li>
<li><a href="/bar">Bar</a></li>
</:sidebar>
This is the inner block.
</.layout>
This is using a functionality called slots, which in a way are equivalent to blocks above. However, they are quite more powerful than template inheritance because they are actually structured data (and not only strings).
For example, the sidebar above still has a couple issues:
- Each item has a lot of repetition by specifying
li
, a
and perhaps even icons
- We are leaking the implementation detail that sidebars use
ul
internally
We can address this by also converting the sidebar to a component and specifying each sidebar item as a slot:
def layout(assigns) do
~H"""
<div id="main">
<%= render_slot(@inner_block) %>
</div>
"""
end
def sidebar(assigns) do
~H"""
<ul id="sidebar">
<%= for item <- @items do %>
<li><a href={item.href}><%= item.text %></a></li>
<% end %>
</ul>
"""
end
And now I can do:
<.layout>
<.sidebar>
<:item href="/foo" text="Foo" />
<:item href="/bar" text="Bar" />
</.sidebar>
This is the inner block.
</.layout>
Now we have started to encapsulate and compose UI functionality using structured data! Phoenix LiveView v0.18 will even take a step further by allowing you to annotate function components with the exact type of data they expect:
slot :items do
attr :href, :string, required: true
attr :text, :string, required: true
end
def sidebar(assigns) do
And then if you forget any of the attributes, you get a compilation warning.
I think this is the lesson that Surface taught us and ultimately why HEEx is a big deal: it makes us think of templates as rich data, like the JS community has been doing over the last several years, instead of just a series of string interpolations like I did with PHP back in 2004.
I will add two things:
-
This approach plays really well with LiveView because if you render the same function component more than once, LiveView will send its body exactly once to the client, regardless of how many times you render it.
-
This approach also fits nicely with Tailwind because, although your classes declarations tend to get verbose, they get encapsulated with the function components, which play nice with the fact LiveView renders them only once.
The only part that we are missing is also encapsulating the JavaScript bits within function components. I think Surface is already exploring something along these lines.