Some of the best use cases I have found for Phoenix LiveView
have been refactoring existing JavaScript code. This will be a fantastic post on how to refactor a comment and reply form. I discuss what JavaScript you don’t need anymore as well as some things to watch out for when refactoring your code.
This post was originally based on a lot of JavaScript concepts. But with recent improvements to LiveView
, we can talk about Elixir a bit more, including components on the server. If you want to see everything that is involved after refactoring to LiveView
, you can see the repo here.
Alright, let’s dive in.
Without LiveView Comment Form
What do we need to get a comment form working if we didn’t have LiveView
?
app.js
file to listen to form submitsTurbolinks
and orStimulus
<%= form_for
@changeset,
Routes.comment_path(@conn, :create),
[id: "comment-form", csrf_token: true],
fn(f) -> %>
<%= textarea(f, :body, rows: 2, required: true, placeholder: "Cool beans....") %>
<button type="submit">Comment</button>
<% end %>
document.addEventListener('turbolinks:load', function() {
LiveComment.commentForm();
});
window.LiveComment = {
commentForm() {
// submit forms with Turbolinks
let form = document.querySelector('form');
if (form instanceof HTMLElement && form.dataset.ajax) {
form.addEventListener('submit', function(event) {
...
let options = {
method: 'POST',
body: new FormData(form),
headers: { 'Turbolinks-Referrer': referrer, 'x-csrf-token': csrf }
};
options.headers['Accept'] = 'application/javascript';
fetch(action, options);
...
response.text().then((body) => {
import(body);
let e = new Event('ajax:load');
document.dispatchEvent(e);
});
});
}
}
}
Notice there are quite a few concepts we need to have figured out. You have to pass the csrf token with your request since we are going to send a network request to to our API. We don’t want unwanted requests to our server. Also, we have to grab our form data, manage our headers and import()
/eval
our response that will contain logic to parse the response and manually append to the DOM. This quickly becomes complicated.
No more please! Let’s simplify things.
LiveView Comment Form
First, we will create a LiveView
to display all of our comments. A few important concepts are encapsulated here:
temporary_assigns
PubSub
subscribe andhandle_info/2
send_update/2
Take a look and we will explain more as we go. The examples have been condensed, but their full version can be found here.
# templates/page/index.html.eex
<%= live_render(@conn, LiveCommentWeb.CommentLive.Index) %>
defmodule LiveCommentWeb.CommentLive.Index do
...
def render(assigns) do
~L"""
...
<div class="comment_list" id="root-comments" phx-update="append">
<%= for comment <- @comments do %>
<%= live_component @socket, CommentLive.Show, id: comment.id, comment: comment, kind: :parent %>
<% end %>
</div>
...
"""
end
def mount(_session, socket) do
comments = Managed.list_root_comments()
changeset = Managed.change_comment()
socket = assign(socket, [changeset: changeset, comments: comments])
if connected?(socket), do: Managed.subscribe("lobby")
{:ok, socket, temporary_assigns: [comments: []]}
end
def handle_event("save", %{"comment" => comment_params}, socket) do
...
end
def handle_info({Managed, :new_comment, comment}, socket) do
if comment.parent_id do
send_update(CommentLive.Show, id: comment.parent_id, children: [comment])
{:noreply, socket}
else
{:noreply, assign(socket, comments: [comment])}
end
end
end
temporary_assigns
is useful to maintain a “stateless” component on the server. If your application is really popular, then your list of comments might be huge. No reason to keep those comments in memory for the life of the application. Note two more things related to temporary_assigns
. First, when we have new assigns, we only assign the new comment to the comments
key. LiveView
will handle merging this comment for you under the hood. Second, we have a phx-append
HTML attribute on the parent. This will tell LiveView
that this new comment will be appended to this list.
Also, we need parts of our application to know when a new comment was created and to react appropriately. Luckily, LiveView
s are built on top of Elixir processes and GenServer. This flow is approximately:
PubSub.subscribe > create_comment > broadcast_from! > handle_info > send_update
You may already be familiar with PubSub. However, send_update/2
is a new API in LiveView
that solves a difficult problem — communication without entanglement between disparate objects. First we broadcast the message after a new comment or reply is created. Once received by handle_info/2
in CommentLive.Index
, send_update/2
will call update/2
, pushing a socket message to the client with specific UI state to update the target component. In our case, we use send_update/2
to notify CommentLive.Show
that it has a new child comment (a reply) and should update its state that it is a new “parent comment”.
Here is the approximate implementation of CommentLive.Show
. Notice we don’t need to react to anything from send_update/2
. It all just works! Lastly, we can hide/show state with :form_visible
.
defmodule LiveCommentWeb.CommentLive.Show do
...
def render(assigns) do
...
end
def mount(socket) do
{:ok, assign(socket, form_visible: false, changeset: Managed.change_comment()),
temporary_assigns: [comment: nil, children: []]}
end
def handle_event("toggle-reply", _, socket) do
{:noreply, update(socket, :form_visible, &(!&1))}
end
def preload(list_of_assigns) do
...
end
def handle_event("save", %{"comment" => comment_params}, socket) do
comment_params
|> Map.put("parent_id", socket.assigns.id)
|> Managed.create_comment()
|> case do
{:ok, new_comment} ->
{:noreply, assign(socket, form_visible: false, children: [new_comment])}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end
With both LiveView
plus a little bit of JavaScript to add some event listeners, we have a working LiveView
comment form. If you are curious about any of the concepts, feel free to dive into the GitHub commit that contains the specific pieces to wire up this comment form.
Some Notes
In the repo, I used JavaScript to attach some listeners to each comment block! How dare I? Well, you will have to decide which parts of your app can be handled by JavaScript or LiveView
. In this case, I want to allow users to submit a comment with their keyboard. This is a valid use case for JavaScript. On the other hand, in showing or hiding the reply form, I could have went with toggling an HTML class directly with JavaScript rather than using state to manage its visibility. But if another user posts a comment, the work to show/hide a reply form would be undone when the server sends an update/2
with new UI state. As you can see, architecting a LiveView
app requires you to carefully understand the tradeoffs involved.
Wrapping Up
LiveView
is still pre 1.0. Features, bugs and major revisions will likely come before any stable release. However, I would encourage you to start migrating parts of your app to LiveView
if it makes sense. For me, this significantly reduced the amount of JavaScript code I needed. However, LiveView
isn’t meant to solve all of your problems. You still need to sprinkle a little JavaScript here and there. Lastly, if you feel like LiveView
is lacking flexibility, let us know what ideas you have in the Slack channel!
Other Helpful Resources
- Converting A Traditional Controller Driven App To Phoenix LiveView - Part 1
DockYard is a digital product agency offering custom software, mobile, and web application development consulting. We provide exceptional professional services in strategy, user experience, design, and full stack engineering using React.js, Ember.js, Ruby, and Elixir. With a nationwide staff, we’ve got consultants in key markets across the U.S., including Seattle, San Francisco, Denver, Chicago, Dallas, Atlanta, and New York.