Subscribe to access all episodes. View plans →

#129: Phoenix LiveView LiveComponent

Published January 18, 2021

LiveComponent

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.

© 2024 HEXMONSTER LLC