These notes are an expansion on the extremely useful
Adopt Liveview
course by Lubien Dev.
Our is to capture our own understanding
of some core components of LiveView
.
To begin, all the code for our LiveView
must be contained inside a module:
defmodule PageLive do
use OurProjectWeb, :live_view
do
We can name PageLive
anything,
but we normally add "Live" to the end of the
module name to indicate that this module is a LiveView.
Next, we see the line use OurProjectWeb, :live_view
.
- The
use
macro is in all LiveViews use
executes code at compile time:live_view
indicates that this module is aLiveView
(will see more of how this works in routers)
The most basic LiveView
contains a render/1
function,
which renders HTML
like code called HEEx
(more on that later).
It looks a little something like this:
def render(assigns) do
~H"""
Hello World!
"""
end
Super basic! Here we're passing in an assigns
(which we're about to examine),
and we contain our HEEx
code inside the ~H
sigil_H/2
.
In Elixir, sigils are binary functions that essentially transform text into something else
So why are we passing something called assign
into our function?
Most of the time we manage state in our LiveView
.
This requires us to store state somewhere,
which is done via the assigns
map.
Note: the render function must always be passed
assigns
.
In frontend frameworks we need a way to store state
(e.g in hooks
in React
).
In LiveView
, we use
assigns
:
- All
LiveView
data is stored in thesocket
data struct - Your own data is stored under the
assigns
key of said socket - (i.e,
assigns
is an elixir map) - In assigns, you can store any variable (lists, maps, structs, etc..)
To make use of assigns
, we use
the mount/3
callback function.
LiveView
sends information via callbacks
(functions that run when an event occurs).
The mount/3
callback runs when the LiveView
is initialized.
It takes three arguments:
def mount(_params, _session, socket) do
socket = assign(socket, name: "R2-D2")
{:ok, socket}
end
The arguments being passed in are:
params
are parameters coming from the URL (e.g./users/:id
where:id
would be one of the params)session
is data from the current browsing session, useful for authenticationsocket
is our socket data struct that we just spoke about, it contains data from the current session and holds theassigns
map
Note: as
params
orsession
are both unused in the code above we letPhoenix
know this with the underscore;_params
&_session
.
Notice that the mount/3
returns the tuple {:ok, socket}
if successful.
- The
:ok
lets the system know that the mount was successful - The
socket
gives us access to the new data
State management revolves around modifying state of the socket
.
Let's use the .dbg/2
macro to examine what happens to our assigns map when we modify the socket:
def mount(_params, _session, socket) do
socket.assigns |> dbg
socket = assign(socket, name: "R2-D2")
socket.assigns |> dbg
{:ok, socket}
end
Notice that we have to declare a new socket to store the new data? This is because data in Elixir is immutable.
The first debug message returns the unaltered assigns
:
socket.assigns => %{__changed__: %{}, flash: %{}, live_action: :index}
And the next returns the modified socket with the new assigns
:
socket.assigns => %{name: "R2-D2", __changed__: %{name: true},
flash: %{}, live_action: :index}
assigns
is just a map with some data about the LiveView
.
In this case it contains:
name
is the map we added__changed__
is a map to explain updates toHTML
rendering engineflash
is a map to send info, success and alert messages to the clientlive_action
use this data to know where we are in the application
(we will see more of this when covering routers)
Ok, so we've seen how data is stored and what its stored in.
Let's look at how we were render that data.
We render assigns in LiveView
using tags: <%= %>
.
def render(assigns) do
~H"""
Hello <%= @name %>
"""
end
Here we're accessing the name
key from the assigns map using @name
.
This is exactly the same as assigns.name
.
If we stored an name in assigns when we used the mount/3
function to
declare a socket, then the code above would render the name, e.g.
"Hello R2-D2".
We now understand the following:
defmodule PageLive do
use OurProjectWeb, :live_view
def mount(_params, _session, socket) do
socket = assign(socket, name: "R2-D2")
{:ok, socket}
end
def render(assigns) do
~H"""
Hello <%= @name %>
"""
end
end
- The
mount/3
callbacks run when LiveView is initializing - The
socket
struct contains data about the LiveView assigns/2
takes the current socket and the new data- We redeclare the socket due to immutability
render/1
has the shortcut@name
forassigns.name
For each event in your HEEx, there is a corresponding event handler function:
def handle_event("your_event", _params, socket)
It takes:
- The name of the event you wish to handle
- The event parameters
- The state of the Socket of the current user
It expects the return of {:noreply, socket}
, which is basically saying
"Everything is ok! Here is the initial socket"
(Similar to
mount/3
's{:ok, socket}
, butmount/3
followers the Elixir pattern)
Let's see it in action.
Following the pattern of events in the HEEx code, and handling the event in a corresponding function we have:
defmodule PageLive do
use OurProjectWebWeb, :live_view
def mount(_params, _session, socket) do
socket = assign(socket, name: "R2-D2")
{:ok, socket}
end
def render(assigns) do
~H"""
<div>
Hello <%= @name %>
<input type="button" value="Reverse" phx-click="reverse" >
</div>
"""
end
def handle_event("reverse", _params, socket) do
socket = assign(socket, name: String.reverse(socket.assigns.name))
{:noreply, socket}
end
end
So whats going on here?
In Phoenix
, phx-click
generates an event of your choosing when the element
is clicked.
Let's break down a use case of the code above:
- The
LiveView
is initialized, callingmount/3
and passingassigns
with our dataname
to the socket - The web page displays "Hello R2-D2"
- The person clicks on the button, triggering our
phx-click
which generates event with the namereverse
- Our
handle_event/3
callback is activated, which reverses the string stored in our assign and stores it in a new socket struct - The
handle_event/3
returns the new socket - The webpage displays "Hello 2D-2R"
With barely any code, we've triggered and handle an event, and re-rendered the page. Elegant right?
- Adding
phx-click="event_name"
triggers event when clicked - For each event on HEEx, you need a corresponding
handle_event/3
callback - The
mount/3
callback returns{:ok, socket}
- The
handle_event/3
returns{:noreply, socket}
The sigil_H
in the render
function returns a data struct called HEEx
.
It is the Phoenix
template language, HTML + EEX
,
where EEx
is Embedded Elixir
,
an Elixir
template engine.
Optimized to know when something has been modified based on its assigns and sends the minimum amount of data form server to client
assigns
is just one of its superpowers.
Some basic rules of HEEx are as follows:
- Using the
<%= %>
tag renders Elixir code that is Phoenix.HTML.safe - Using the
<% %>
tag executes elixir code but does not render anything nil
values do not render
Seeing this in action:
def render(assigns) do
~H"""
<h2>Hello <%= "R2-D2" %></h2>
<h2>Hello <%= 1 + 1 %></h2>
<h2>Hello <%= "C-3PO," <> " " <> "Human cyborg relations" %></h2>
<h2>Hello <% "Not the droids you're looking for" |> IO.puts() %></h2>
"""
end
Case by case renders:
- The string "R2-D2"
- The integer 2
- The third case just uses the string concatenation operator
<>
whose result is "C-3PO, Human cyborg relations". - Nothing! Because of the tags do not include the
=
sign, the code is executed and because ofIO.puts
you can see the result in the terminal.
~H"""
<div>
<%= if @need_id? do %>
<p>Let's see some identification</p>
<% else %>
<p>Move along!</p>
<% end %>
</div>
"""
Notice:
- Need the
=
for theif
tag - The
else
andend
tags don't need=
- We're getting the
assign
with@
- In
Elixir
we display booleans withboolean?
If no else
block is needed, can simply omit it.
But there is a better way ...
When you only need an if
, you can place the special :if
attribute
directly inside a HTML tag:
~H"""
<p :if={@need_id?}>Let's see some identification</p>
"""
Ref: https://adopt-liveview.lubien.dev/guides/conditional-rendering/en#the-special-attribute-if
Elixir doesn't have else-if
, so instead we use case
.
Observe a full LiveView
module to see this in action:
defmodule PageLive do
use OurProjectWeb, :live_view
def mount(_params, _session, socket) do
socket = assign(socket, tab: "home")
{:ok, socket}
end
def render(assigns) do
~H"""
<div>
<%= case @tab do %>
<% "home" -> %>
<p>You're on my personal page!</p>
<% "about" -> %>
<p>Hi, I'm a LiveView developer!</p>
<% "contact" -> %>
<p>Mail me to bot [at] company [dot] com</p>
<% end %>
</div>
<input disabled={@tab == "home"}
type="button" value="Open Home" phx-click="show_home" />
<input disabled={@tab == "about"}
type="button" value="Open About" phx-click="show_about" />
<input disabled={@tab == "contact"}
type="button" value="Open Contact" phx-click="show_contact" />
"""
end
def handle_event("show_" <> tab, _params, socket) do
socket = assign(socket, tab: tab)
{:noreply, socket}
end
end
This is showcasing a combination of previous topics as well case
.
To go over what we've seen before:
- The
assign
istab
and initialized as "home" and given to the socket withmount/3
- Using
phx-click="show_{tab_name}"
- Our
handle_event/3
can use pattern matching and string concatenation to change the assigns all in one function - We can use
HTML
disabled
property to disable a button if we're on the correct tab
Let's now talk about the case
:
- Start with the
=
tag just likeif
- Each condition is checking
@tab == value
- Each condition we do
<% "expected value" -> %>
- Note: We can add a default clause with
<% _ -> %>
When rendering something based on a condition that is not about
equality we use cond
which follows the logic:
- Each clause returns
true
orfalse
- First condition that returns true ends the flow and renders the prescribed html
- To add a standard clause add
true ->
at the end
For example:
~H"""
<div>
Current accuracy: <%= @accuracy_percentage %>%
</div>
<div>
<%= cond do %>
<% @accuracy_percentage > 70 -> %>
<p>Clone</p>
<% @accuracy_percentage > 40 -> %>
<p>Rebel</p>
<% @accuracy_percentage > 10 -> %>
<p>Tusken</p>
<% @accuracy_percentage > 0 -> %>
<p>Clankers</p>
<% true -> %>
<p>Stormtrooper</p>
<% end %>
</div>
- For
if-else
use<%= if condition do %>
and<% else %>
- For only
if
use special if-attribute:if={condition}
in html tag - For multiple comparisons of the same variable
use
<%= case value of %>
- For multiple conditions that don't involve comparing equality
use
<%= cond do %>
- In all cases, have the
=
in the first tag
You can use the special attribute :for
to render simple lists,
like so:
defmodule PageLive do
use OurProjectWeb, :live_view
def mount(_params, _session, socket) do
socket = assign(socket, foods: ["apple", "banana", "carrot"])
{:ok, socket}
end
def render(assigns) do
~H"""
<ul>
<li :for={food <- @foods}><%= food %></li>
</ul>
"""
end
end
But there are two main disadvantages for this approach:
- Loop will be executed every time any assign changes
- List of elements will be saved in memory in LiveView while LiveView is alive
These can solved with streams.
Phoenix's efficient way to handle large (or infinite) lists.
Note: In the following code our assign looks clunky since we have to include an id to use streams.
In reality, this is not an issue as if the data is from a database an id will be included.
defmodule PageLive do
use OurProjectWeb, :live_view
def mount(_params, _session, socket) do
socket =
stream(socket, :foods, [
%{id: 1, name: "apple"},
%{id: 2, name: "banana"},
%{id: 3, name: "carrot"}
])
{:ok, socket}
end
def render(assigns) do
~H"""
<ul id="food-stream" phx-update="stream">
<li :for={{dom_id, food} <- @streams.foods} id={dom_id}>
<%= food.name %>
</li>
</ul>
"""
end
end
Let's break it down:
- We use the
stream/4
function to define a stream stream/4
recieves our socket, the name of the stream as an atom and the initial value- Our unordered list (or any parent element of the list) must have
a unique id, in this case
food-stream
- Must add
phx-update="stream"
to parent element (to define that children are part of a stream) - Use special assign
@streams.food
, every time a stream is created with:some_name
you generate a special assign@streams.some_name
:for
now loops with two elements inside a tuple. The id is useful when wanting to update / delete elements
- Combining
for
comprehension and special:for
attribute provides simple, readable rendering - LiveView provides efficient rendering for large or infinite data using streams
In the accuracy example, what if we want to include a <button>
to increment and decrement different by different amounts?
It can be handled very neatly in one function:
defmodule PageLive do
use OurProjectWebWeb, :live_view
def mount(_params, _session, socket) do
socket = assign(socket, accuracy_percentage: 30)
{:ok, socket}
end
def handle_event("add", %{"amount" => amount}, socket) do
amount = String.to_integer(amount)
socket = assign(socket, accuracy_percentage:
socket.assigns.accuracy_percentage + amount)
{:noreply, socket}
end
def render(assigns) do
~H"""
<div>
Current accuracy: <%= @accuracy_percentage %>%
</div>
<div>
<%= cond do %>
<% @accuracy_percentage > 70 -> %>
<p>Clone</p>
<% @accuracy_percentage > 40 -> %>
<p>Rebel</p>
<% @accuracy_percentage > 10 -> %>
<p>Tusken</p>
<% @accuracy_percentage > 0 -> %>
<p>Clankers</p>
<% true -> %>
<p>Stormtrooper</p>
<% end %>
</div>
<input type="button" value="+5" phx-click="add" phx-value-amount={+5} />
<input type="button" value="+10" phx-click="add" phx-value-amount={+10} />
<input type="button" value="-5" phx-click="add" phx-value-amount={-5} />
<input type="button" value="-10" phx-click="add" phx-value-amount={-10} />
"""
end
end
Pretty neat right? The buttons activate a generic event named "add"
that receives a phx-value-amount
which is a number
.
The handle_event/3
is called and receives {"amount" => amount}
as a second parameter, allowing us to increment / decrement the
accuracy_percentage by different amounts.
Note that numbers coming from HTML come in String format, which is why we convert the amount in
handle_event/3
Allows us to push events with a value that is integer.
For the previous example where we used the phx-value
to obtain
a number
but in string
format, we could make the change to:
~H"""
<input
type="button"
value="+5"
phx-click={JS.push("add", value: %{amount: +5})}
/>
"""
Simple!
We are pushing the "add"
event and the value will be %{amount: INTEGER}
.
JS
commands can be combined using the pipe operator.
If the <button>
increases the score for two teams:
~H"""
JS.push("add_points", value: %{team: :blue, amount: +1})
|> JS.push("add_points", value: %{team: :red, amount: +1})
"""
With this approach we can chain as many as is necessary, but that could get quite messy.
The neater approach would look something like the following.
In the render:
~H"""
phx-click={add_points(:red, 1) |> add_points(:blue, 1)}
"""
and then of course handling the events:
defp add_points(js \\ %JS{}, team, amount) do
JS.push(js, "add_points", value: %{team: team, amount: amount})
end
def handle_event("add_points", %{"team" => team, "amount" => amount}, socket) do
team_atom = String.to_existing_atom(team)
current_points = socket.assigns[team_atom]
socket = assign(socket, team_atom, current_points + amount)
{:noreply, socket}
end
Our custom JS
command add_points/3
starts with the default empty JS
struct.
This is just part of how
JS.push
works behind the scenes,
so when we make a custom JS command we have to include this.
The handle_event/3
receives the value
map from our add_points
function, so we can handle the team value and the points accordingly.
Every Phoenix
app requires a router.
They are auto-generated by Phoenix during set-up with the name
YourProject.Router
.
An example of how they've looked so far:
defmodule OurProjectWeb.Router do
use OurProjectWeb, :router
pipeline :browser do
plug :accepts, ["html"]
end
scope "/" do
pipe_through :browser
live "/", PageLive, :index
end
end
What are we looking at then?
use OurProjectWeb, :router
imports functions and macros for creating routespipeline :browser do
block defines a set of plugs for routes (like configurations) of type:browser
. Here we only define a route that usesHMTL
scope "/" do
the routes in this block are rendered in the root of the websitepipe_through :browser
activates the pipeline called browser
And then most importantly:
- Using the
live/4
macro we define the home page ("/"
) thePageLive
module will be rendered with Live Action:index
(more on that later).
What if our web app has multiple pages?
Say, an IndexLive
and
SecondPageLive
. Then our router would have to include the following code:
defmodule OurProjectWeb.Router do
use OurProjectWeb, :router
pipeline :browser do
plug :accepts, ["html"]
end
scope "/" do
pipe_through :browser
live "/", IndexLive, :index
live "/other", SecondPageLive, :index
end
end
Note: The full
router.ex
file may contain more auto-generated code
So each LiveView
would have their own .ex
file and defmodule
with
the corresponding render/1
(at a minimum) and maybe their own mount/3
and event handling functions etc.
Note: The LiveViews could be named anything we want
But how would we change between LiveViews
in the application?
Our IndexLive
could navigate to the SecondPageLive
like so:
defmodule IndexLive do
use OurProjectWeb, :live_view
def render(assigns) do
~H"""
<h1>IndexLive</h1>
<.link navigate={~p"/other"}>Go to second page</.link>
"""
end
end
Here we have a bare bones LiveView
with some new elements.
Let's discuss:
- Components are displayed with
HTML
tags with a.
in the opening tag, more on those later - The
<.link>
component is specialized in navigating between pages usingnavigate={~p"/destination"}
But what's that ~p
?
Using
sigil_p
when specifying routes means Phoenix
will warn us if we try to use
a route that does not exist.
A super handy quality of life feature!
- Every
Phoenix
application has a Router - Define LiveView routes using
live/4
macro - HTML tags with
.
indicate a component - Use
<.link navigate={~p"/route"}>
to efficiently navigate between routes - Using
sigil_p
meansPhoenix
will warn us if we try to use a route that does not exist
It will be quite common that in a route you need to handle variables coming from the URL (parameters).
Let us first see what that looks like in the router:
defmodule OurProjectWeb.Router do
use OurProjectWeb, :router
pipeline :browser do
plug :accepts, ["html"]
end
scope "/" do
pipe_through :browser
live "/", IndexLive, :index
live "/blog/:slug", BlogLive, :index
end
end
Notice the path includes /:slug
variable. We can now have access to it
in our LiveView
which allows us to do something like the following:
Our first LiveView
:
defmodule IndexLive do
use OurProjectWebWeb, :live_view
def render(assigns) do
~H"""
<h1>Welcome to my Website!</h1>
<ul>
<li><.link navigate={~p"/blog/goblins"}>Read about goblins</.link></li>
<li><.link navigate={~p"/blog/elves"}>Read about elves</.link></li>
</ul>
"""
end
end
So from our IndexLive
we are navigating to the paths /blog/goblins
and /blog/elves
, i.e we're using our slug variable to add interactivity.
We can use the parameter as follows:
defmodule BlogLive do
use OurProjectWeb, :live_view
def mount(%{"slug" => slug}, _session, socket) do
socket = assign(socket, :slug, slug)
{:ok, socket}
end
def render(assigns) do
~H"""
<h1>Reading about <%= @slug %></h1>
"""
end
end
So BlogLive
receives in its first argument the map %{"slug" => slug}
,
which we can the use to create an assign.
Remember how the first argument of
mount/3
is normally an ignored params argument_params
?
- The
live/4
macro lets us create parameters in the URL using the:variable_name
format. - These parameters become a key in
params
map in ourLiveView
Can also pass data from query string to params
.
E.g. if a user passes the query string ?admin_mode=secret123
we could
set up our LiveView
to display content only for admins.
Like so:
defmodule PageLive do
use OurProjectWeb, :live_view
def mount(params, _session, socket) do
admin? = params["admin_mode"] == "secret123"
socket = assign(socket, :admin?, admin?)
{:ok, socket}
end
def render(assigns) do
~H"""
<h1>Welcome to my Website!</h1>
<.link :if={@admin?} navigate={~p"/admin"}>Go to admin panel</.link>
"""
end
end
Here the <.link>
component is only rendered if admin?
is true,
which is only the case if the path contained the query string
?admin_mode=secret123
.
Note: we're accessing the value for the "admin_mode" using Elixir map notation.
- The
params
variable receives anything in the query string in key-value format - E.g.
?x=10&y=12
params
is a map, so we can access the value withparams[key]
Let's improve upon our previous tab example.
Last time, we used a variable called tab
to determine what we should render.
This time, we will be using URL parameters and a special patch
command to
navigate onto the same page with different rendering being determined by
the parameter.
Router to start:
defmodule OurProjectWeb.Router do
use OurProjectWeb, :router
pipeline :browser do
plug :accepts, ["html"]
end
scope "/" do
pipe_through :browser
live "/", TabLive, :show
live "/tab/:tab", TabLive, :show
end
end
Notice that our parameter is :tab
,
and that we have the same
LiveView
in both routes.
Whaaat?
Our LiveView
in question:
defmodule TabLive do
use OurProjectWebWeb, :live_view
def handle_params(params, _uri, socket) do
tab = params["tab"] || "home"
socket = assign(socket, tab: tab)
{:noreply, socket}
end
def render(assigns) do
~H"""
<div>
<%= case @tab do %>
<% "home" -> %>
<p>You're on my personal page!</p>
<% "about" -> %>
<p>Hi, I'm a LiveView developer!</p>
<% "contact" -> %>
<p>Mail me to bot [at] company [dot] com</p>
<% end %>
</div>
<.link :if={@tab != "home"} patch={~p"/"}>Go to home</.link>
<.link :if={@tab != "about"} patch={~p"/tab/about"}>Go to about</.link>
<.link :if={@tab != "contact"} patch={~p"/tab/contact"}>Go to contact</.link>
"""
end
end
Let's first address the elephant in the room: No mount/3
!?
That's because we're using
handle_params/3
:
- Callback very similar to
mount/3
- Second argument contains URI of current page
- Must return
{:noreply, socket}
LiveView
executesmount/3
if it exists, thenhandle_params/3
if it exists- This means we can remove
mount/3
completely
Cool! Now addressing the code inside the function, the line
tab = params["tab"] || "home"
saves the tab from the parameter as
a variable, with home being the default if there is no parameter passed
(like in our first <.link>
).
Ok, now the main point.
Why are we using handle_params/3
?
Well, notice that instead of using navigate={..}
inside our
link component we use patch={...}
.
This approach is optimized for a transition going to the
same LiveView
(hence why we had two routes with the same LiveView
).
What does all this mean? We now have the URL parameter
determining what we should render on our singular LiveView
,
which on top of being less code also has it's own optimized transition.
Cool!
- A
LiveView
can be used on more than one route. - We can take advantage of URLs to persist data in cases such as tabs.
handle_params/3
is a callback that is executed right aftermount/3.
- One way to optimize page changes for the same
LiveView
is to use patch in the<.link>
components. - Using patch we execute
handle_params/3.
Components offer a way of reusing HEEx
,
avoiding duplication keeping
the code base clean and efficient.
In this example we will be making use of a <.button>
component
to eliminate writing the same tailwind classes over and over,
using an attribute to customize each button,
and making use of slots to render different inner content.
Let's see it all in action:
defmodule PageLive do
use OurProjectWeb, :live_view
def render(assigns) do
~H"""
<.button color="blue">Default</.button>
<.button color="green">Green</.button>
<.button color="red">Red</.button>
<.button color="yellow">Yellow</.button>
"""
end
def button(assigns) do
~H"""
<button
type="button"
class={"text-white bg-#{@color}-700 hover:bg-#{@color}-800
focus:ring-4 focus:ring-#{@color}-300 font-medium rounded-lg
text-sm px-5 py-2.5 me-2 mb-2 dark:bg-#{@color}-600
dark:hover:bg-#{@color}-700 focus:outline-none
dark:focus:ring-#{@color}-800"}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
end
So the code above does exactly what was just described with minimal lines.
Lets talk about the component:
- The component function
def button
takes anassigns
and renders HEEx. - We access the color for the tailwind class with
#{@color}
, meaning each button will have an assign of color - We use
render_slot/2
by passing the assign@inner_block
, which is a slot and contains all the HTML inside our<.component>
Now examining how this works in the render/1
:
- Opening tag starts with
.
(like<.link>
) - Inside the
<.button .. >
opening tag we pass the color attribute - In between the tags (the inner block) we have text that each button will display.
Nice!
We can use ExDoc
to add documentation and validation to our components.
See below:
defmodule PageLive do
use OurProjectWeb, :live_view
def render(assigns) do
~H"""
<.button color="blue">Welcome</.button>
"""
end
@doc """
Renders a button
## Examples
<.button>Save data</.button>
<.button color="red">Delete account</.button>
"""
attr :color, :string, required: true
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type="button"
class={"text-white bg-#{@color}-700 hover:bg-#{@color}-800
focus:ring-4 focus:ring-#{@color}-300 font-medium rounded-lg
text-sm px-5 py-2.5 me-2 mb-2 dark:bg-#{@color}-600
dark:hover:bg-#{@color}-700 focus:outline-none
dark:focus:ring-#{@color}-800"}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
end
Ok, so we're using the @doc
tag to explain what the component does,
and add a few examples.
We then use the
attr/3
and
slot/2
macros, defining what the component is expected to receive.
The compiler will validate whether that
is the case, providing an extra layer of validation.
We can add default and required values to our ExDoc
:
@doc """
Renders a button
## Examples
<.button>Save data</.button>
<.button color="red">Delete account</.button>
"""
attr :color, :string, default: "blue"
slot :inner_block, required: true
This way, if a button component is written without the color attribute,
it will have a default of "blue"
and be styled accordingly.
You can also add accepted values to the attr/3
properties as well:
attr :color, :string, default: "blue", values: ~w(blue red yellow green)
This way a warning will be produced if wrong values are being used.
Note:
sigil_w
creates string lists. So["blue", "green"]
can be written as~w(blue green)
If we want to be able to add tailwind classes to our component from
the render/1
function, we can add a class attribute in our ExDoc
as such:
defmodule PageLive do
use OurProjectWebWeb, :live_view
def render(assigns) do
~H"""
<.button class="text-red-500">Default</.button>
"""
end
@doc """
Renders a button
## Examples
<.button>Save data</.button>
<.button class="text-blue-500">Save data</.button>
<.button color="red">Delete account</.button>
"""
attr :color, :string, default: "blue", examples: ~w(blue red yellow green)
attr :class, :string, default: nil
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type="button"
class={[
"text-white bg-#{@color}-700 hover:bg-#{@color}-800
focus:ring-4 focus:ring-#{@color}-300 font-medium rounded-lg
text-sm px-5 py-2.5 me-2 mb-2 dark:bg-#{@color}-600
dark:hover:bg-#{@color}-700 focus:outline-none
dark:focus:ring-#{@color}-800",
@class
]}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
Here we added the class attribute to the ExDoc with a default of nil
(to allow cases when we don't need it),
and then used a @class
assign after our Tailwind
classes in
the <button>
component. This way, any class we pass to assigns in our
render/1
will override any matching previous classes.
You can use @doc
to document your component and show examples.
Using attr/3
you can document and enhance your component:
- You can set a value as
required
. - You can set a
default
value if something is not passed usingdefault
. - You can limit the possible values using
values
.
What if we have components we want to access and use
in multiple locations? For example, multiple LiveViews that use
a <.button>
component?
Phoenix projects auto generates a file called YourWebApp.CoreComponents
.
If we create our button component inside that file, we can simply import
CoreComponents
into any module that needs buttons
and use the component as normal in our render/1
.
Super handy!
In our last example we only used the @inner_block
slot,
but what if we want our component to have multiple sections,
say a title, subtitle and content of a hero section?
This is where customs slots come in.
Lets use custom slots to implement a hero section as detailed above.
First we will create a hero
component in the CoreComponents
file:
slot :title
slot :subtitle
slot :inner_block
def hero(assigns) do
~H"""
<div class="bg-gray-800 text-white py-20">
<div class="container mx-auto text-center">
<h1 class="text-4xl font-bold"><%= render_slot(@title) %></h1>
<p class="mt-4 text-lg"><%= render_slot(@subtitle) %></p>
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
In here we have provided the styling for each of the custom slots, and will be accessing them with assigns:
<%= render_slot(@title) %>
Just like with the inner_block
from before.
Then in our LiveView
we'd use the slots like so:
defmodule IndexLive do
use OurProjectWebWeb, :live_view
import CoreComponents
def render(assigns) do
~H"""
<.hero>
<:title>IndexLive</:title>
<:subtitle>Welcome to my personal website!</:subtitle>
<.link
class="mt-8 bg-blue-500 hover:bg-blue-600
text-white font-bold py-2 px-4 rounded"
navigate={~p"/other"}
>
Get Started
</.link>
</.hero>
<.link navigate={~p"/other"}>Go to other</.link>
"""
end
end
Similar to using a component,
to use the slots we alter the opening
tag but this time with a :
.
The HTML
inside the <:title></:title>
tags will be rendered in the title
slot (and the same for subtitle).
Any HTML
not inside a named slot will be rendered into the @inner_block
.
Notice: that we imported the CoreComponents to be able to use our
<.hero>
component
Slots are just a special assigns (which is why we access them with
@slot_name
), which means every slot is a list of maps.
The point of understanding what slots are is because if slots are lists then we can loop through them, and if they contain maps then we can access properties from them.
We'll work through an example.
Starting within CoreComponents
:
attr :terms, :list, required: true
slot :dt, required: true
slot :dd, required: true
def dl(assigns) do
~H"""
<dl class="max-w-xs mx-auto">
<div class="grid grid-cols-1 gap-y-2">
<div :for={item <- @terms} class="border-b border-gray-300">
<dt class="text-lg font-semibold"><%= render_slot(@dt, item) %></dt>
<dd class="text-gray-600"><%= render_slot(@dd, item) %></dd>
</div>
</div>
</dl>
"""
end
Breakdown:
- Customized version of the
<dl>
tag - Slots names mimic the
HTML
(description title and description detail) - Loop the assigns with
:for
- Key difference: passed a second argument to
render_slot/2
So why the second argument? Notice that the second argument passed is the current item in the loop.
That allows us to do the following:
defmodule PageLive do
use OurProjectWeb, :live_view
import CoreComponents
def mount(_params, _session, socket) do
boxing_terms = [
%{term: "Jab",
definition: "A quick, straight punch thrown with the lead hand."},
%{
term: "Hook",
definition:
"A punch thrown in a circular motion targeting the side of
the opponent's head or body."
},
%{
term: "Cross",
definition:
"A powerful punch thrown with the rear hand across the body,
traveling straight toward the opponent."
}
]
socket = assign(socket, boxing_terms: boxing_terms)
{:ok, socket}
end
def render(assigns) do
~H"""
<.dl terms={@boxing_terms}>
<:dt :let={item}><%= item.term %></:dt>
<:dd :let={item}><%= item.definition %></:dd>
</.dl>
"""
end
end
By using the looped item into the render_slot/2
we can utilize
the special attribute :let={item}
and store the current looped
element item
.
This way, we can access the data specific to each item
,
in this case the term
and definition
from each item in
our boxing_terms
list which we stored in assigns.
This makes our render/1
function super clean!