Phoenix LiveView Comment and Reply

Tags

Man working on computer in front of three computer screens

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 submits
  • Turbolinks and or Stimulus
<%= 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:

  1. temporary_assigns
  2. PubSub subscribe and handle_info/2
  3. 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, LiveViews 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.