A new feature in LiveView called “slots” can help make your components more composable and reusable. This post is about getting started with slots to build a simple component.
Problem
You have a design element that is used repeatedly in your site. Rather than copy and paste the markup everywhere, you want to make a component that handles the layout part for you. With the contents removed, it looks like this:
How can we make a component that supports inserting content in multiple places? Something like this:
And what if the inserted content is complex, like HTML markup and not just simple strings?
Solution
A feature was introduced in LiveView v0.17 called “slots”. The idea and inspiration for this comes from Javascript front-end frameworks like Vue.js.
The idea is pretty simple. We want to “slot in” content at different places in a component. If we have multiple places that take content, then we need to name the slot so we can specify which content is destined for which slot. This becomes clearer as we work through an example.
Let’s start with the basic default slot that we’ve had for some time. Here we have a custom component called basic_card
and pass content into it.
<.basic_card>
Default content.
</.basic_card>
For this example, it should render like this:
Let’s see what the basic_card
function looks like that makes this work. The CSS styling comes from TailwindCSS.
def basic_card(assigns) do
~H"""
<div class="rounded-lg border border-gray-300 w-full shadow-sm">
<div class="px-3 py-2">
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
The key part here is the render_slot(@inner_block)
function call. This renders the string "Default content."
because it’s passed in as the @inner_block
. Meaning it’s the blob-of-stuff-we-included-but-didn’t-specify-a-slot-name that ends up as the default @inner_block
.
But our design element includes a header, what should the template look like to pass in content for the header?
<.basic_card>
<:header>
Header Content
</:header>
Default content.
</.basic_card>
Here we use a colon (:
) specify a “named slot” called header
. Writing that in a template looks like this <:header></:header>
Whatever content we put between those tags is available in the function assigns as @header
. Let’s look at how the basic_card
function changes to render the header.
def basic_card(assigns) do
~H"""
<div class="rounded-lg border border-gray-300 w-full shadow-sm">
<div class="rounded-t-lg bg-gray-100 px-3 py-2 font-medium border-b border-gray-300">
<%= render_slot(@header) %>
</div>
<div class="px-3 py-2">
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
This now renders like this:
Yay! Looking good!
But if we play around with it, we’ll see that if we don’t include a <:header></:header>
slot in the template, it blows up!
We can fix this! We can make the <:header></:header>
slot optional. Doing this also reveals an interesting thing… a slot’s content is actually a list.
We’ll make a couple changes and talk through it next.
def basic_card(assigns) do
assigns = assign_new(assigns, :header, fn -> [] end)
~H"""
<div class="rounded-lg border border-gray-300 w-full shadow-sm ">
<%= for header <- @header do %>
<div class="rounded-t-lg bg-gray-100 px-3 py-2 font-medium border-b border-gray-300">
<%= render_slot(header) %>
</div>
<% end %>
<div class="px-3 py-2">
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
We made the following two changes:
1) We used assign_new/3
to add :header
to the assigns if it’s not already there. If the key is missing, the 3rd argument, an anonymous function, is executed and the result is used for the value. In this case, it gets set to an empty list []
.
This means our assigns will always have a @header
and it is a list.
2) We use a for
comprehension to render the header: for header <- @header do
. If the :header
list is empty, nothing gets rendered!
Setting a default empty list for the header and using a for
comprehension to render it effectively makes our header
slot optional!
Our simple header example could have just passed the contents as an attribute. The template would look like this:
<.basic_card header="Header Content">
Default content.
</.basic_card>
Why is it better to use a slot? Because we can pass in complex markup instead of simple strings. Here’s an example of more complex header content:
<.basic_card>
<:header>
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<i class="fas fa-star text-2xl text-center text-gray-600 h-8 w-8" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">
Header Text
</p>
</div>
</div>
</:header>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
</.basic_card>
This is using FontAwesome for the icon. It looks like this:
All that extra header markup starts to look messy, but if we find we keep using that complex header pattern in our app, then it can become a component as well. Even still, notice that all the markup noise is the exception instead of the norm.
This is where slots become really powerful. Slots let us insert complex content into multiple places inside of a component.
Discussion
Slots works really well when creating common components for our application. The components don’t have to be 100% configurable, they just need to meet our needs today. That’s a great place to start and we can grow from there.
Slots can do even more than we looked at here. This was an example of how slots make common UI patterns easy to reuse in our applications. Check out this more advanced post that even covers passing arguments into a named slot as well.