LiveView empowers developers to build interactive, single-page web apps with ease by providing a framework that eliminates the need for guesswork.
In this post, we'll take a look at how you can layer simple, single-purpose functional components to wrap up shared presentation logic. We'll also use more sophisticated live components to craft easy-to-maintain single-page flows that handle complex user interactions.
Along the way, you'll gain a solid understanding of working with HEEx — Phoenix and LiveView's new templating engine — and you'll see some of LiveView's out-of-the-box function components in action.
Let's dive in!
The Feature: Compose a User Survey UI for a Phoenix LiveView App
Before we dive into writing any actual code, let's talk about the feature we'll build. Imagine that you're responsible for a Phoenix web app, Arcade, that provides in-browser games to users. A user can log in, select a game to play, and even invite friends to play games with them.
In this post, we'll build out a "user survey" feature that asks the user to fill out some demographic info about themselves and then provide a rating for each of several games. We'll focus on that second part of the survey — the game rating forms.
Let's lay out what we'll build in a bit more detail. We'll begin with a parent live view that lives at the /survey
route, ArcadeWeb.SurveyLive
. This live view will render a child functional component, "ratings index". The ratings index component will iterate over the games and show a game rating if one by the current user exists, or a form for a new rating if not. If users haven't completed a game rating, they will see a list of forms to rate each game 1-5 stars. If they have completed some (or all) ratings, they will see those displayed and forms to submit ratings for the games they have not yet rated.
Here's a look at how it will work:
With our plan in place, we're ready to start writing code.
Define the Parent Live View
We'll build a route first, then mount and render the initial live view.
Define the Survey Route
Our first job is to establish a route. The survey will live at /survey
, and it should only work for authenticated users so we can deliver a survey to single, identifiable users. We'll tie the route to the yet-to-be-written SurveyLive
live view, with the :index
live action, like this:
# router.ex
scope "/", ArcadeWeb do
pipe_through [:browser, :require_authenticated_user]
live "/survey", SurveyLive, :index
end
This code assumes you've used the Phoenix Auth generator to add authentication to your Phoenix app. The details of authentication aren't important for our purposes here. Just know that the survey
route is a protected route that requires an authenticated user. This means that when a logged-in user points their browser at /survey
, the SurveyLive
view will mount with a session
argument that contains a key of "user_token"
pointing to a token we can use to identify the current user. The generator also gives us a function, Accounts.get_user_by_session_token(user_token)
, that we will use to fetch the user for that token.
With our route established, it's time to define the SurveyLive
live view.
Mount the Survey Live View
The mount/3
function builds the initial state for SurveyLive
. Let's think a bit about that initial state. We need to use the current user to build our survey's demographic and rating portions since a demographic belongs to a user and a rating belongs to a game and a user. So we want to store that user in the live view's state. This way, we can make it available to the rating form component later. Now, let's implement a mount/3
function that adds the current user to socket assigns, like this:
# lib/arcade_web/live/survey_live.ex
defmodule ArcadeWeb.SurveyLive do
use ArcadeWeb, :live_view
def mount(_params, %{"user_token" => user_token}, socket) do
{:ok,
socket
|> assign(
:current_user,
Accounts.get_user_by_session_token(user_token))}
end
end
Okay, we're ready to render a simple version of our survey live view.
Render the Survey Live View
We won't provide a render/1
function, instead we'll use a template — lib/arcade_web/live/survey_live.html.heex
. Let's keep it simple for now:
<section class="row">
<h2>Survey</h2>
</section>
Reload your browser, and you'll see the bare-bones template shown here:
We have the basic framework for our survey UI in place. Now, we're ready to build our first function component.
List Ratings in Phoenix LiveView
Before we build our ratings index function component, let's talk about what function components are and how they work. A function component takes in an assigns
argument and returns a HEEx template. Function components are implemented in modules that use the Phoenix.Component
behaviour, which also gives us a convenient syntax for rendering function components.
We're almost ready to define our function component. Let's take a step back and discuss HEEx.
Understanding HEEx Templates
A HEEx template is any file ending in the .heex
extension that is implicitly rendered by a live view, or any markup rendered by a live view or component that is encapsulated in the ~H"""
, """
tags. The HEEx templating engine is an extension of EEx. Just like EEx templates, HEEx will process template replacements within your HTML code. Everything between the <%=
and %>
expressions is a template replacement. HEEx will evaluate the Elixir code within those tags and replace them with the result.
HEEx does more than just templating, though. It also:
- provides compile-time HTML validations
- gives us a convenient component rendering syntax
- optimizes the amount of content sent over the wire, allowing LiveView to render only those portions of the template that need updating when state changes
HEEx is the default templating engine for Phoenix and LiveView. Any generated template files in your Phoenix app will be HEEx templates and end in the .html.heex
extension. When using inline render/1
functions in your live views, or function components, you'll return HEEx templates with the ~H
sigil.
With that basic understanding in place, we're ready to implement the ratings index function component.
Define the Function Component
We'll build a rating index component responsible for orchestrating the state of all the game ratings in our survey. This component will iterate over the games and render the rating details if a user rating exists, or the rating form if it doesn't. The responsibility for rendering rating details will be handled by a "rating show" function component. A live "rating form" component will handle rendering and managing a rating form. More on live components in a bit.
Meanwhile, SurveyLive
will continue to be responsible for managing the overall state and appearance of the survey page. The rating index component will receive the list of game ratings to render from the parent live view. The parent live view is responsible for maintaining and updating that list.
In this way, we keep our code organized and easy to maintain because it adheres to the single responsibility principle — each component has one job to do. By layering these components within the parent SurveyLive
view, we compose a series of small, manageable pieces into one interactive feature — the user survey page.
We'll begin by implementing the RatingLive.Index
function component. Then, we'll move on to the rating show component, followed by the rating form component.
Create a file, lib/arcade_web/live/rating_live/index.ex
, and key in the following component definition:
defmodule ArcadeWeb.RatingLive.Index do
use Phoenix.Component
use Phoenix.HTML
alias ArcadeWeb.RatingLive
end
Our function component uses the Phoenix.Component
behaviour which we need to render HEEx templates. Any module that implements only function components will use this behaviour. You'll use a different behaviour when building live or stateful components, which we'll do later on in this post.
We're also using the Phoenix.HTML
behaviour here to bring in the Phoenix.HTML.raw/1
function that we'll use to render unicode characters — more on that in a bit. Finally, we're aliasing the name of the component module itself so it's easy to ergonomically invoke other function components defined within the module from within our main function component here.
The entry point of our module will be the games/1
function. We'll call on this function component from the parent live view to render the list of games. The function will take in an assigns
argument containing the list of games passed in from the parent SurveyLive
view. It will return a HEEx template that iterates over that list and renders another function component to show the game rating details if a rating exists and the rating form live component if not. Define that function now, as shown here:
def games(assigns) do
~H"""
<div class="survey-component-container">
<.heading games={@games} />
<.list games={@games} current_user={@current_user}/>
</div>
"""
end
We're composing our games/1
function out of two additional function components — heading/1
and list/1
. We call on those function components with the .function_name assigns... />
syntax. This invokes the function component and passes in whatever assigns we provide as the assigns
argument to that function.
It's also worth noting the {}
interpolation syntax here, instead of the <%= %>
EEx tags you might be used to. This is because HEEx, unlike EEx, isn't just responsible for evaluating and templating Elixir expressions into your HTML. It also parses and validates the HTML itself. So you can't use the traditional EEx tags inside HTML tags in a HEEx template. Instead, use curly braces to interpolate values inside HTML tags and function component calls, and use EEx tags when interpolating values in the body, or inner content, of those tags.
Let's build those additional function components now, starting with heading/1
:
def heading(assigns) do
~H"""
<h2>
Ratings
<%= if ratings_complete?(@games), do: raw "✓" %>
</h2>
"""
end
The heading/1
function is pretty small and single-purpose. It renders an <h2>
element that encapsulates some text along with a helper function that checks to see if all of the games have a rating by the current user. If so, we render the unicode to a checkmark and the user can see that all of the ratings forms have been completed.
Before we implement this helper function, let's see how we're going to render the index component with a list of games.
When we render this index component from the SurveyLive
template, we'll use the SurveyLive
view to query for the list of games with ratings by the current preloaded user.
Then, we'll pass that list of games down into the index component. So we can assume that each game in the @games
list has its ratings
list populated only with a rating from the current user. With that in mind, we can implement the ratings_complete?/1
function to iterate over the list of games and return true
if there is a rating for every game. Add in your function now, like this:
defp ratings_complete?(games) do
Enum.all?(games, fn game ->
length(game.ratings) == 1
end)
end
Now if a user has completed all of the game ratings, they'll see the "Ratings" header with a nice checkmark next to it:
With the heading/1
function component out of the way, let's turn our attention to list/1
. Add in this function now:
def list(assigns) do
~H"""
<%= for {game, index} <- Enum.with_index(@games) do %>
<%= if rating = List.first(game.ratings) do %>
<h3>Show rating coming soon!</h3>
<% else %>
<h3>Rating form coming soon!</h3>
<% end %>
<% end %>
"""
end
Here, we use a for
comprehension that maps over all of the games in the system, where each game's ratings
list contains the single preloaded rating by the given user if one exists. Inside that comprehension, the template will render the rating details if a rating exists or a form for that rating if not. Nesting components in this manner lets the reader of the code deal with a tiny bit of complexity at a time.
We'll dig into this logic a bit more when we're ready to implement these final two components. With the index component out of the way, we are ready to weave it into our SurveyLive
template.
Render the Component
The next bit of code we'll write shows how the presentation of our view can change based on the contents of the socket. The SurveyLive
view will use the state of the overall survey to control what is shown to the user. This view holds the list of games, and their ratings by the current user, in state. It will pass this list into the RatingLive.games/1
function component as part of the component assigns. The contents of this list will allow the games/1
function component to determine if it should show rating details or a rating form.
Let's update SurveyLive
now to query for the list of games and their ratings from the current user and add them to socket assigns, like this:
defmodule ArcadeWeb.SurveyLive do
use ArcadeWeb, :live_view
alias Arcade.Survey
def mount(_params, %{"user_token" => token}, socket) do
{:ok,
socket
|> assign(:current_user, Accounts.get_user_by_session_token(token))
|> assign(:games, Catalog.list_games_with_user_rating(user))}
end
end
Assume that the Catalog.list_games_with_user_rating/1
context function returns the list of all games, with only the rating by the given user preloaded, if any.
Now we're ready to render our rating index function component from the SurveyLive
template:
<!-- lib/arcade_web/live/survey_live.html.heex -->
<RatingLive.Index.games games={@games}
current_user={@current_user} />
Here, we're once again using the <.function_component assigns... />
syntax to render our function component and the {}
interpolation syntax for interpolating within tags in a HEEx template.
Now that we're rendering our RatingLive.Index.games/1
function component with the game list, let's build the stateless function component to show the existing rating for a game.
Show a Rating
We're getting closer to the goal of showing ratings, step by step. Remember, we'll show the existing ratings, and forms for ratings, otherwise.
Let's cover the case for ratings that exist first. We'll define a stateless component to show a rating. Then, we'll render that component from within the HEEx template returned by RatingLive.Index.games/1
. Let's get started.
Build the Function Component
Create a file, lib/arcade_web/live/rating_live/show_component.ex
, and key this in:
defmodule ArcadeWeb.RatingLive.Show do
use Phoenix.Component
use Phoenix.HTML
end
We're defining a module that uses the Phoenix.Component
behaviour and the Phoenix.HTML
behaviour, since we'll once again need support for the Phoenix.HTML.raw/1
function to render unicode characters.
Okay, let's move on to the entry point of our function component, the stars/1
function. We'll call this function from within the HEEx template returned by RatingLive.Index.games/1
with an assigns that includes the given game's rating by the current user.
The stars/1
function will operate on this rating and use some helper functions to construct a list of filled and unfilled unicode star characters. We'll construct that list using a simple pipeline, and then render it in a HEEx template, like this:
def stars(assigns) do
stars =
filled_stars(assigns.rating.stars)
|> Enum.concat(unfilled_stars(assigns.rating.stars))
|> Enum.join(" ")
~H"""
<div>
<h4>
<%= @game.name %>:<br/>
<%= raw stars %>
</h4>
</div>
"""
end
The filled_stars/1
and unfilled_stars/1
helper functions are interesting. Take a look at them here:
def filled_stars(stars) do
List.duplicate("★", stars)
end
def unfilled_stars(stars) do
List.duplicate("☆", 5 - stars)
end
Examining our pipeline in the stars/1
function, we can see that we call on filled_stars/1
to produce a list of filled-in, or "checked", star unicode characters corresponding to the number of stars the game rating has. Then, we pipe that into a call to Enum.concat/2
with a second argument of the output from unfilled_stars/1
. This second helper function produces a list of empty, or not checked, star characters for the remaining number of stars.
For example, if the number of stars in the rating is 3, our pipeline of helper functions will create a list of three checked stars and two un-checked stars. Our pipeline concatenates the two lists together and joins them into a string of HTML that we can render in the template.
We have everything we need to display a completed rating, so it's time to roll several components up together.
Render the Component
We're ready to implement the next phase of our plan. The RatingLive.Index.games/1
function component iterates over the list of games in the @games
assigns. If a rating is present, we show it. Add in the call to our new Show.stars/1
component now, like this:
def list(assigns) do
~H"""
<%= for {game, index} <- Enum.with_index(@games) do %>
<%= if rating = List.first(game.ratings) do %>
<Show.stars rating={rating} game={game} />
<% else %>
<h3>Rating form coming soon!</h3>
<% end %>
<% end %>
"""
end
It's a straight for
comprehension with an if
statement. If a rating exists, we render the function component by calling on it with the game
and rating
assigns. If not, we need to render the form. Let's build that form and render it now.
Submit a Rating
Our rating form will display the form and manage its state, validating and saving the rating. We'll need to pass a game and user for our database relationships, and the game's index in the parent LiveView's socket.assigns.games
list. We'll use this index later on to update SurveyLive
state efficiently.
Build the Rating Form Component
The component will be stateful since it needs to manage the state of the rating form and respond to user interactions to validate form changes and handle the form submission.
A stateful, or live, component is any module that uses the :live_component
behaviour and renders a HEEx template. Such modules can implement the live component lifecycle functions, including mount/3
, update/2
, and render/1
, and respond to user events by implementing a handle_event/3
function. Let's define our live component module now. Create a file, lib/arcade_web/live/rating_live/form.ex
, and key this in:
defmodule ArcadeWeb.RatingLive.FormComponent do
use ArcadeWeb, :live_component
alias Arcade.Survey
alias Arcade.Survey.Rating
end
This is simple enough, to begin with. We define our module, use the :live_component
behaviour, and add in some aliases that we'll need later.
We'll use LiveView's .form/1
function (more on that in a bit) to construct the rating form. This function requires a changeset, so we'll need to store one in our component's state. Here's where the component lifecycle comes into play. When we render a live component, LiveView starts the component in the parent view's process and calls these callbacks, in order:
mount/1
: The single argument is the socket, and we use this callback to set the initial state. This callback is invoked only once, when the component is first rendered from the parent live view.update/2
: The two arguments are the assigns argument given tolive_component/3
and the socket. By default, it merges the assigns argument into thesocket.assigns
established inmount/1
. We'll use this callback to add additional content to the socket each timelive_component/3
is called.render/1
: The one argument issocket.assigns
. It works like a render in any other live view.
Stateful components will always follow this process when first mounted and rendered. Then, when the component updates in response to changes in the parent live view, only the update/2
and render/1
callbacks fire. Since these updates skip the mount/1
callback, the update/2
function is the safest place to establish the component's initial state. Let's create our component's update/2
function now, like this:
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_rating()
|> assign_changeset()}
end
def assign_rating(
%{assigns: %{current_user: user, game: game}} = socket) do
assign(socket, :rating, %Rating{user_id: user.id, game_id: game.id})
end
def assign_changeset(%{assigns: %{rating: rating}} = socket) do
assign(socket, :changeset, Survey.change_rating(rating))
end
These reducer functions will add the necessary keys to our socket.assigns
. They'll drop in any assigns
our parent sends, add a new Rating
struct, and finally establish a changeset for the new rating.
There are no surprises here. One reducer builds a new rating, and the other uses the Survey
context to build a changeset for that rating. Now, on to render.
With our socket established, we're ready to render. We'll choose a template to keep our markup code neatly compartmentalized. Create a file, lib/arcade_web/live/rating_live/form.html.heex
. Add the game title markup followed by the game rating form shown here:
<div class="survey-component-container">
<section class="row">
<h4><%= @game.name %></h4>
</section>
<section class="row">
<.form
let={f}
for={@changeset}
phx-change="validate"
phx-submit="save"
phx_target={@myself}
id={@id}>
<%= label f, :stars%>
<%= select f, :stars, Enum.reverse(1..5) %>
<%= error_tag f, :stars %>
<%= hidden_input f, :user_id%>
<%= hidden_input f, :game_id%>
<%= submit "Save", phx_disable_with: "Saving..." %>
</.form>
</section>
</div>
The form template is really just a standard Phoenix form, although the syntax for rendering the form may be new to you. The main function in the template is the form/1
function: a function component made available by LiveView under the hood. The form function component returns a rendered HEEx template containing an HTML form built with the help of Phoenix.HTML.Form.form_for/4
.
If you're feeling adventurous, you can check out the source code for the form function component. For now, all you really need to know is that calling form/1
returns an HTML form for the specified changeset, with the specified LiveView bindings. Let's take a closer look at how our form is rendered.
Since the form/1
function is built on top of the form_for/4
function, it presents a similar API. Here, we're generating a form for the @changeset
assignment that was put in assigns via the update/2
callback.
Then, we bind two events to the form, a phx-change
to send a validate
event and a phx-submit
to send a save
event. We target our form component to receive events by setting phx-target
to @myself
, and we tack on an id
.
Note that we've set a dynamic HTML id of the stateful component id, stored in socket assigns as @id
. This is because the game rating form will appear multiple times on the page, once for each game, and we need to ensure that each form gets a unique id. You'll see how we set the id
assigns for the component when we render it in a bit.
Our form has a stars
field with a label and error tag and a hidden field for each user
and game
relationship. We tie things up with a submit button.
We'll come back to the events a bit later. For now, let's fold our work into the RatingLive.Index.list/1
function component.
Render the Component
The RatingLive.Index.games/1
function component should render the rating form component if no rating for the given game and user exists. Let's do that now.
def list(assigns) do
~H"""
<%= for {game, index} <- Enum.with_index(@games) do %>
<%= if rating = List.first(game.ratings) do %>
<Show.stars rating={rating} game={game} />
<% else %>
<.live_component module={RatingLive.Form}
id={"rating-form-#{game.id}"}
game={game}
game_index={index}
current_user={@current_user } />
<% end %>
<% end %>
"""
end
Here, we call on the component with the live_component/1
function, passing the user and game into the component as assigns, along with the game's index in the @games
assignment. We add an :id
, a requirement of all stateful components. Since we'll only have one rating per component, our id
with an embedded game.id
should be unique.
The live_component/1
function is a function component made available to us by the LiveView framework. It takes in an argument of some assigns and returns a HEEx template that renders the given component within the parent live view. When using live_component/1
to render a live component, you must specify an assigns of module
, pointing to the name of the live component module to mount and render, and an assigns of id
, which LiveView will use to keep track of the component. Also, note the {}
interpolation syntax we're using — this syntax is required when interpolating within HTML or HEEx tags.
It's been a while since we've looked at things in the browser — but now, if you point your browser at /survey
, you should see something like this:
Handle Component Events
We've bound events to save and validate our form, so we should teach our component how to do both. We need one handle_event/2
function head for each of the save
and validate
events. Let's start with validate
:
def handle_event("validate", %{"rating" => rating_params}, socket) do
{:noreply, validate_rating(socket, rating_params)}
end
We need to build the reducer next:
def validate_rating(socket, rating_params) do
changeset =
socket.assigns.rating
|> Survey.change_rating(rating_params)
|> Map.put(:action, :validate)
assign(socket, :changeset, changeset)
end
Our validate_rating/2
reducer function validates the changeset and returns a new socket with the validated changeset (containing any errors) in socket assigns. This will cause the component to re-render the template with the updated changeset, allowing the error_tag
helpers in our form_for
form to render any errors.
Next up, we'll implement a handle_event/2
function that matches the save
event:
def handle_event("save", %{"rating" => rating_params}, socket) do
{:noreply, save_rating(socket, rating_params)}
end
And here's the reducer:
def save_rating(
%{assigns: %{product_index: product_index, product: product}} = socket,
rating_params
) do
case Survey.create_rating(rating_params) do
{:ok, rating} ->
product = %{product | ratings: [rating]}
send(self(), {:created_rating, product, product_index})
socket
{:error, %Ecto.Changeset{} = changeset} ->
assign(socket, changeset: changeset)
end
end
Here, we attempt to save the form. On failure, we assign a new changeset. On success, we send a message to the parent live view to do the heavy lifting for us. Then, as all handlers must do, we return the socket.
Update the Rating Index
What should happen when the game rating successfully saves? The RatingLive.Index.games/1
function should no longer render the form for that game. Instead, the survey should display the saved rating. This kind of state change is squarely the responsibility of SurveyLive
. Our message will serve to notify the parent live view to change.
Here's the interesting bit. All the parent needs to do is update the socket. The RatingLive.Index.games/1
function already renders the right thing based on the content of the assigns that it receives from the parent, SurveyLive
. All we need to do is implement a handler to deal with the "created rating" message.
# lib/arcade_web/live/survey_live.ex
def handle_info({:created_rating, updated_product, product_index}, socket) do
{:noreply, handle_rating_created(socket, updated_product, product_index)}
end
We use handle_info
so that the parent live view process, SurveyLive
, can respond to the message sent by the child component. Now, our reducer can take the appropriate action. Notice that the message we match has a message name, an updated game, and its index in the :games
list. We can use that information to update the game list without going back to the database. We'll implement the reducer below to do this work:
def handle_rating_created(
%{assigns: %{products: products}} = socket,
updated_product,
product_index
) do
socket
|> put_flash(:info, "Rating submitted successfully")
|> assign(
:products,
List.replace_at(products, product_index, updated_product)
)
end
The handle_rating_created/3
reducer adds a flash message and updates the game list with its rating. This causes the template to re-render, passing this updated game list to RatingLive.Index.games/1
. That function component, in turn, knows just what to do with a game containing a rating by the given user — it will render that rating's details instead of a rating form.
Notice the lovely layering. In the parent live view layer, all we need to do is manage the list of games and ratings. All of the form handling and rating or demographic details go elsewhere.
The end result of a submitted rating is an updated game list and a flash message. Submit a rating, and see what happens:
You just witnessed the power of components and LiveView.
Wrap Up: You've Built a Complex LiveView UI with Components
This post covered a lot of ground. You implemented a sophisticated UI composed from simple layers, all thanks to LiveView components. You can wrap up simple markup with function components, while live components allow you to maintain component state and respond to events. Meanwhile, HEEx templates and LiveView provide some nice semantics for rendering markup and both types of components.
LiveView is growing fast — it's responding to the community's needs and providing even more ergonomic solutions for developing complex interactive single-page apps. Function components and HEEx are just some of the latest features that make LiveView even more enjoyable to use.
Happy coding!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!
Top comments (0)