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
doWe 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
usemacro is in all LiveViews useexecutes code at compile time:live_viewindicates 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!
"""
endSuper 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
LiveViewdata is stored in thesocketdata struct - Your own data is stored under the
assignskey of said socket - (i.e,
assignsis 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}
endThe arguments being passed in are:
paramsare parameters coming from the URL (e.g./users/:idwhere:idwould be one of the params)sessionis data from the current browsing session, useful for authenticationsocketis our socket data struct that we just spoke about, it contains data from the current session and holds theassignsmap
Note: as
paramsorsessionare both unused in the code above we letPhoenixknow this with the underscore;_params&_session.
Notice that the mount/3 returns the tuple {:ok, socket} if successful.
- The
:oklets the system know that the mount was successful - The
socketgives 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}
endNotice 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:
nameis the map we added__changed__is a map to explain updates toHTMLrendering engineflashis a map to send info, success and alert messages to the clientlive_actionuse 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 %>
"""
endHere 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/3callbacks run when LiveView is initializing - The
socketstruct contains data about the LiveView assigns/2takes the current socket and the new data- We redeclare the socket due to immutability
render/1has the shortcut@nameforassigns.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/3followers 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
endSo 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
LiveViewis initialized, callingmount/3and passingassignswith our datanameto the socket - The web page displays "Hello R2-D2"
- The person clicks on the button, triggering our
phx-clickwhich generates event with the namereverse - Our
handle_event/3callback is activated, which reverses the string stored in our assign and stores it in a new socket struct - The
handle_event/3returns 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/3callback - The
mount/3callback returns{:ok, socket} - The
handle_event/3returns{: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 nilvalues 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>
"""
endCase 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.putsyou 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 theiftag - The
elseandendtags don't need= - We're getting the
assignwith@ - In
Elixirwe 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
endThis is showcasing a combination of previous topics as well case.
To go over what we've seen before:
- The
assignistaband initialized as "home" and given to the socket withmount/3 - Using
phx-click="show_{tab_name}" - Our
handle_event/3can use pattern matching and string concatenation to change the assigns all in one function - We can use
HTMLdisabledproperty 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
trueorfalse - 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-elseuse<%= if condition do %>and<% else %> - For only
ifuse 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
endBut 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
endLet's break it down:
- We use the
stream/4function to define a stream stream/4recieves 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_nameyou generate a special assign@streams.some_name :fornow loops with two elements inside a tuple. The id is useful when wanting to update / delete elements
- Combining
forcomprehension and special:forattribute 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
endPretty 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}
endOur 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
endWhat are we looking at then?
use OurProjectWeb, :routerimports functions and macros for creating routespipeline :browser doblock defines a set of plugs for routes (like configurations) of type:browser. Here we only define a route that usesHMTLscope "/" dothe routes in this block are rendered in the root of the websitepipe_through :browseractivates the pipeline called browser
And then most importantly:
- Using the
live/4macro we define the home page ("/") thePageLivemodule 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
endNote: The full
router.exfile 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
endHere we have a bare bones LiveView with some new elements.
Let's discuss:
- Components are displayed with
HTMLtags 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
Phoenixapplication has a Router - Define LiveView routes using
live/4macro - HTML tags with
.indicate a component - Use
<.link navigate={~p"/route"}>to efficiently navigate between routes - Using
sigil_pmeansPhoenixwill 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
endNotice 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
endSo 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
endSo 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/3is normally an ignored params argument_params?
- The
live/4macro lets us create parameters in the URL using the:variable_nameformat. - These parameters become a key in
paramsmap 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
endHere 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
paramsvariable receives anything in the query string in key-value format - E.g.
?x=10&y=12 paramsis 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
endNotice 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
endLet'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} LiveViewexecutesmount/3if it exists, thenhandle_params/3if it exists- This means we can remove
mount/3completely
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
LiveViewcan be used on more than one route. - We can take advantage of URLs to persist data in cases such as tabs.
handle_params/3is a callback that is executed right aftermount/3.- One way to optimize page changes for the same
LiveViewis 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
endSo the code above does exactly what was just described with minimal lines.
Lets talk about the component:
- The component function
def buttontakes anassignsand 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/2by 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
endOk, 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: trueThis 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_wcreates 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
defaultvalue 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>
"""
endIn 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
endSimilar 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>
"""
endBreakdown:
- 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
endBy 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!