Subscribe to access all episodes. View plans →
Published January 18, 2021
Phoenix LiveView 0.15.1
Phoenix 1.5.7
Elixir 1.10
LiveComponents are a way to help compartmentalize state and events when using Phoenix LiveView. There are two types of LiveView components: Stateless and Stateful. We’ll explore both types in this episode.
In the docs, there’s an example of a CardComponent
- let’s take this idea and build on it to get a better idea of how components work and how we can use them to compartmentalize functionality inside of LiveView. We’ll build a Movie Trivia app that gives us trivia questions that gives us trivia questions. The trivia card here will be our LiveView component. To start we’ll use this empty Phoenix LiveView application that doesn’t have any contexts or schemas.
Let’s get started.
We’ll go to the command line and use the the Phoenix LiveView generator and since this is a trivia game, we’ll call our context module Game
. We’ll have trivia cards, so Card
will be our schema module with the table name cards
and each card will have a question
and an answer
. Now we have a Card
schema for our trivia cards and a Game
context module.
$ mix phx.gen.live Game Card cards question answer
* creating lib/teacher_web/live/card_live/show.ex
* creating lib/teacher_web/live/card_live/index.ex
* creating lib/teacher_web/live/card_live/form_component.ex
* creating lib/teacher_web/live/card_live/form_component.html.leex
* creating lib/teacher_web/live/card_live/index.html.leex
* creating lib/teacher_web/live/card_live/show.html.leex
* creating test/teacher_web/live/card_live_test.exs
* creating lib/teacher_web/live/modal_component.ex
* creating lib/teacher_web/live/live_helpers.ex
* creating lib/teacher/game/card.ex
* creating priv/repo/migrations/{timestamp}_create_cards.exs
* creating lib/teacher/game.ex
* injecting lib/teacher/game.ex
* creating test/teacher/game_test.exs
* injecting test/teacher/game_test.exs
* injecting lib/teacher_web.ex
Add the live routes to your browser scope in lib/teacher_web/router.ex:
live "/cards", CardLive.Index, :index
live "/cards/new", CardLive.Index, :new
live "/cards/:id/edit", CardLive.Index, :edit
live "/cards/:id", CardLive.Show, :show
live "/cards/:id/show/edit", CardLive.Show, :edit
Remember to update your repository by running migrations:
$ mix ecto.migrate
Let’s grab the routes that were printed and then open our router.ex
and paste in our new routes.
lib/teacher_web/router.ex
...
scope "/", TeacherWeb do
pipe_through :browser
live "/cards", CardLive.Index, :index
live "/cards/new", CardLive.Index, :new
live "/cards/:id/edit", CardLive.Index, :edit
live "/cards/:id", CardLive.Show, :show
live "/cards/:id/show/edit", CardLive.Show, :edit
live "/", PageLive, :index
end
...
Our trivia game wont much fun without any questions so let’s include some seed data for our trivia cards. We’ll open our seeds.exs
and I’ll go ahead and paste in some trivia questions for us to use.
priv/repo/seeds.exs
alias Teacher.Game
Game.create_card(%{"question" => "What are the dying words of Charles Foster Kane in the movie Citizen Kane?", "answer" => "Rosebud"})
Game.create_card(%{"question" => "I could'a been a contender!", "answer" => "On the Waterfront"})
Game.create_card(%{"question" => "️What is the highest-grossing movie of all time?", "answer" => "Avengers: Endgame"})
Game.create_card(%{"question" => "️Here's looking at you, kid.", "answer" => "Casablanca"})
Game.create_card(%{"question" => "What actress has won the most Academy Awards for Best Actress?", "answer" => "Katharine Hepburn"})
Game.create_card(%{"question" => "️What was the first James Bond movie?", "answer" => "Dr. No"})
Game.create_card(%{"question" => "What actor has won the most Academy Awards for Best Actor?", "answer" => "Jack Nicholson"})
Game.create_card(%{"question" => "What's the only movie Martin Scorsese has won an Academy Award for Best Director", "answer" => "The Departed"})
Great now we can go back to the command line and run our database migration.
Then we can run our seeds to populate the database.
$ mix ecto.migrate
...
$ mix run priv/repo/seeds.exs
...
Now that we have some card data, let’s start putting together our game. We want each trivia card to be a component, so let’s create a new card_component.ex
inside of the “card_live” directory.
Let’s define our module and because this app was generated with mix phx.new --live
we’ll use TeacherWeb, :live_component
. Then we can call the render
callback, which takes the assign
. We’ll want our render
callback to return what we want to display on the page. Let’s render a div
that displays a trivia question.
In addition to the render
callback here, both stateless and stateful components include the update
and mount
callbacks. Stateful components have a preload
callback too that we’ll go over later in this episode.
lib/teacher_web/live/card_live/card_component.ex
defmodule TeacherWeb.CardLive.CardComponent do
use TeacherWeb, :live_component
def render(assigns) do
~L"""
<%= @question %>
"""
end
end
Now we need to get a trivia card from the database in order to display on the page. Let’s open the page_live.ex
- this is the LiveView that renders our page.
Inside of the mount
callback let’s use that Game
context module that was generated for us earlier and call Game.list_cards
to get all of our cards from the database. And then we’ll pipe them into Enum.random
to get one random card.
Let’s take the card that’s returned and put it on our socket assigns. We’ll also add an alias for our Teacher.Game
module so we can call it without the prefix.
lib/teacher_web/live/page_live.ex
defmodule TeacherWeb.PageLive do
use TeacherWeb, :live_view
alias Teacher.Game
@impl true
def mount(_params, _session, socket) do
card = Game.list_cards() |> Enum.random()
{:ok, assign(socket, card: card)}
end
end
Then let’s open the page_live.html.leex
template and we can call the live_component
helper to render our CardComponent
.
It will take the @socket
the CardComponent
we just created and then any assigns
we want to give it. Since we’re displaying the question in our component, let’s give it the trivia card’s question.
Template path: lib/teacher_web/live/page_live.html.leex
...
<%= live_component @socket, TeacherWeb.CardLive.CardComponent,
question: @card.question %>
Now let’s go to the command line and start our server.
$ mix phx.server
...
Now if we open our browser. Great - we see our CardComponent
is now being rendered. What we have here is a stateless component. According to the docs, “A stateless component is always mounted, updated, and rendered whenever the parent template changes.” Let’s add an “Answer” link to our component that when click updates our component to display the answer to the trivia question.
Let’s go back to our card_component.ex
and update our render
callback to include the “Answer” link, giving it the phx-click
binding with the “answer” event. We’ll want this to trigger a handle_event
callback in our CardComponent
here so let’s include that now - we’ll ignore the params
here since we wont need them. Let’s start simple and when we handle this event just log that it was triggered. And then we’ll return a :noreply
tuple with our socket.
handle_event
is an implementation of a callback so let’s add @impl true
and it looks like we missed it for our render
callback so let’s add it there too.
lib/teacher_web/live/card_component.ex
...
@impl true
def render(assigns) do
~L"""<%= @question %>
<%= link "Answer", to: "#", phx_click: "answer" %>
"""
end
@impl true
def handle_event("answer", _params, socket) do
IO.puts("CardComponent callback triggered...")
{:noreply, socket}
end
...
Now if we go back to the browser, we’ve got an error: * (RuntimeError) a component TeacherWeb.CardLive.CardComponent that has implemented handle_event/3 or preload/1 requires an :id assign to be given
.
Since our component is now implementing handle_event
it requires an ID to be included in the component assigns
. In other words, we need to make our component Stateful. To do that we need to go to our page_live.html.leex
template and include an id
. And for its value we’ll just use the card’s ID.
It’s worth noting here that including the ID doesn’t necessarily mean it will be used as the DOM ID. That’s up to you and how you render a component.
Template path: lib/teacher_web/live/page_live.html.leex
...
<%= live_component @socket, TeacherWeb.CardLive.CardComponent,
id: @card.id,
question: @card.question %>
Now when we go back to the browser - our component is displayed.
Let’s try our “Answer” link and if we go to our development logs we get an error because our event is being sent to the parent PageLive
, which we don’t want. This is a common gotcha when starting with LiveView components. We need to specify that we want to send this event to our CardComponent
.
To do that we need to open our CardComponet
and include (a phx-target with the myself assign) phx-target: @myself
- this is a reference to this instance of the component.
lib/teacher_web/live/card_live/card_component.ex
...
@impl true
def render(assigns) do
~L"""<%= @question %>
<%= link "Answer", to: "#", phx_target: @myself, phx_click: "answer" %>
"""
end
...
When we go back to the browser and click our “Answer” link. We can see that our message is printed in our development logs. Now that our handle_event
callback is being handled in our CardComponent
let’s update it to actually display the answer when clicked. We’ll create an answered
assigns that will start as false
and then when we click the “Answer” link it will be set to true
and our answer will be displayed.
Let’s go back in our page_live.html.leex
template and instead of passing in the answer into our assigns along with the question, let’s only pass in the component id
.
Template path: lib/teacher_web/live/page_live.html.leex
...
<%= live_component @socket, TeacherWeb.CardLive.CardComponent,
id: @card.id %>
Then we’ll go back to the card_component.ex
. Let’s try implementing the update
callback in order to lookup our question and set it in the assigns - update
takes the assigns and the socket. Inside the function we’ll get the card from the ID.
We’ll want to return an :ok
tuple, but since we’re using the update
callback we need to ensure our existing assigns are added to the socket, so let’s do that. Then we’ll set the answered
assign to false
and we’ll include the card
. Let’s also let’s alias our Teacher.Game
module so we can call it without the prefix.
Now we’re going to need to update the HTML we render for the component and as that grows, it may make sense to include it as it’s own template. Let’s do that for our component. We’ll copy our HTML here and then remove our render
callback.
lib/teacher_web/live/card_live/card_component.ex
defmodule TeacherWeb.CardLive.CardComponent do
use TeacherWeb, :live_component
alias Teacher.Game
@impl true
def update(assigns, socket) do
card = Game.get_card!(assigns.id)
{:ok,
socket
|> assign(assigns)
|> assign(:answered, false)
|> assign(:card, card)}
end
...
end
Then all we need to do is create a template in our “card_live” directory with the same name as our component card_component.html.leex
. We’ll paste in the existing HTML we had and before where we were rendering just the question, we’ll render the card’s answer if the @answered
assign is true. Otherwise we’ll render the card’s question.
Template path: lib/teacher_web/live/card_live/card_component.html.leex
<div class="card">
<h3>
<%= if @answered, do: @card.answer, else: @card.question %>
</h3>
</div>
<%= link "Answer", to: "#", phx_target: @myself, phx_click: "answer" %>
Now that our HTML’s been extracted let’s go back to our card_component.ex
and update our handle_event
“answer” to update the answered
assign to be true.
lib/teacher_web/live/card_live/card_component.ex
...
@impl true
def handle_event("answer", _params, socket) do
{:noreply, assign(socket, answered: true)}
end
...
Let’s test this out. And when we click “Answer” our component’s state is updated and the answer is displayed. This is great. Now let’s update our component so that once we have the answer here, we can get another trivia question.
We’ll go back to our card_component.html.leex
template and let’s add a link that we can click to get another trivia question. Let’s give it the phx-click
event of “new” and let’s display this link only if this question has been answered.
Template path: lib/teacher_web/live/card_live/card_component.html.leex
<div class="card">
<h3>
<%= if @answered, do: @card.answer, else: @card.question %>
</h3>
</div>
<%= link "Answer", to: "#", phx_target: @myself, phx_click: "answer" %>
<%= if @answered do %>
| <%= link "Another", to: "#", phx_target: @myself, phx_click: "new" %>
<% end %>
Then we’ll need to create the callback to handle our “new” event. We’ll open the card_component.ex
and add a handle_event
callback pattern matching on the “new” event. We can ignore the params since we wont need them. And then it will take the socket.
Now inside the function we need put a new trivia card question and answer on the socket. Let’s grab a random card and then let’s return a :noreply
tuple, updating our socket assigns with answered: false
, and our new card.
lib/teacher_web/live/card_live/card_component.ex
...
def handle_event("new", _params, socket) do
card = Teacher.Game.list_cards() |> Enum.random()
{:noreply,
socket
|> assign(:answered, false)
|> assign(:card, card)}
end
...
Now let’s go back to the browser and when we show the answer our “Another” link is displayed. Then when we click that we get a new question. Perfect our card component is working. We can answer trivia questions and get new ones.
Now what if we wanted to render multiple trivia cards on the page - each managing with its own trivia card that’s updated independently - how could we do that?
We’ll open our page_live.ex
and update it to fetch 3 different trivia cards at random. Then we’ll update our socket assign to cards
since we now have more than 1.
lib/teacher_web/live/page_live.ex
...
@impl true
def mount(_params, _session, socket) do
cards = Game.list_cards() |> Enum.shuffle() |> Enum.slice(0..2)
{:ok, assign(socket, cards: cards)}
end
...
Now we need to update our page_live.html.leex
template and instead of just displaying a single CardComponent.ex
we’ll loop over our cards, displaying a CardComponent
for each one.
Template path: lib/teacher_web/live/page_live.html.leex
...
<%= for card <- @cards do %>
<%= live_component @socket, TeacherWeb.CardLive.CardComponent,
id: card.id %>
<% end %>
Now when we go back to the browser - great we see our 3 trivia cards are displayed. And we can click to get the answer for each card without updating any other trivia card. Each CardComponent
is managing its own state.
While this is great, we have one improvement we should make. Let’s open our development logs and when we loaded the page we have an improvement to make. Instead of doing a single query for all the cards the page needs, each component is querying the database for its card. Luckily, LiveView components have the a solution in the preload
callback.
Let’s go back to our card_component.ex
We’ll define the preload
callback, which takes a list of the assigns for each component. And then here we can query the database for each trivia card that we’ll need, then we’ll take those cards and then update each component assigns to include the correct card. Let’s take our list_of_assigns
and pipe it into Enum.map
to return the id for each card.
Now that we have a list of our IDs we can use them to query the database. To do that let’s open our game.ex
context module and we’ll create a public function called get_cards
that will take a list of card IDs. Then we’ll write query to get each card we have an ID for.
lib/teacher/game.ex
...
def get_cards(ids) do
qry = from card in Card,
where: card.id in ^ids
Repo.all(qry)
end
...
With function created we can go back to our card_component.ex
and then pipe our IDs into our new Game.get_cards
function. We’ll want our preload
callback to return the list_of_assigns
marked up with the correct card for each assigns.
To do that let’s map over our list_of_assigns
we’ll lookup the correct card for that assigns, and then merge it into the assigns. Now that each card will be loaded in the assigns
for each CardComponent
we can remove the call to the database we have in the update
callback. We also wont need to put it on the assigns since it will already be there. And it looks like we have a type in our preload
function, I’ll go ahead and include the missing 1
in the function.
lib/teacher_web/live/card_live/card_component.ex
...
@impl true
def preload(list_of_assigns) do
cards =
list_of_assigns
|> Enum.map(&(&1.id))
|> Game.get_cards()
Enum.map(list_of_assigns, fn(assigns) ->
card = Enum.find(cards, fn(card) -> assigns.id == card.id end)
Map.merge(assigns, %{card: card})
end)
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign(:answered, false)}
end
...
Let’s go ahead and reload our Movie Trivia page. And now when we look at the development logs we see that we’re making a single query to the database for all the trivia cards our CardComponents need. We’re now able to play our Movie Trivia game using Phoenix LiveView components.
luka-TU
3 years agoHi!
In this case you are grabbing a new card randomly, and this may result in 2 cards showing the same question at the same time. How to avoid this situation?
Any advice would be appreciated.
Best