7 GUIs: Implementing a CRUD App in LiveView

This is the fifth post handling the 7 GUI tasks in LiveView. Here are some highlights of implementing a CRUD application.

The CRUD app

When I thought of CRUD, I thought this would be an easy exercise. After all, I’ve been building CRUD applications since before Elixir was a language. But building the required CRUD UI had some surprising challenges because all the functionality exists on a single page where several forms interact with the same data.

These are the task’s requirements:

  • Build a page where we can create, update, and delete users.
  • It should have two text fields: first name and last name. We’ll use them to create or update a user.
  • We should see a list of all users (by last name, first name).
  • We should be able to select a user from the list to edit or delete. We should only be able to select one user at a time.
  • The page should allow searching for a specific user with the “prefix filter”. The search must filter the names by the last names that start with the prefix. And the search should happen immediately, without hitting enter.
  • The update and delete buttons should only be enabled if we have selected an entry.

A first implementation

Because the form has three potential actions (create, update, and delete), I initially thought I wouldn’t be able to use Phoenix’s form helpers with Ecto’s changesets (since each form can only have one phx-submit event). So, my first inclination was to have something like React’s controlled components for all fields. That meant every field had to have an assign:

socket
|> assign(:users, CRUD.list_users()) # list of existing users
|> assign(:current_user_id, nil) # when we select a user
|> assign(:first_name, "")
|> assign(:last_name, "")

I would then trigger events when any of those changed. For example, I had a text input for the first name field where the value was the @first_name assign:

<input phx-blur="set-first-name" type="text" value="<%= @first_name %>">

Changes to that input would trigger a “set-first-name” event, and a corresponding handle_event/3 function would update the name:

def handle_event("set-first-name", %{"value" => name}, socket) do
  socket
  |> assign(:first_name, name)
  |> noreply()
end

Selecting a user was a little more complicated. When a user is selected, I wanted the UI to have the first and last name fields pre-filled with that user’s data. For the UI to do that, I had to change three assigns at the same time. That seemed like a code smell because we had to keep assigns synchronized:

def handle_event("select-user", %{"value" => user_id}, socket) do
  user_id = String.to_integer(user_id)
  user = find_user(socket.assigns.users, user_id)

  socket
  |> assign(:current_user_id, user_id)
  |> assign(:first_name, user.first_name)
  |> assign(:last_name, user.last_name)
  |> noreply()
end

I also had three main events: one for creating a user, one for updating a user, and one for deleting the user. Because the first and last name fields were kept in different assigns, I had to build the user parameters when creating or updating a user. Here’s what the update handle event function looked like:

def handle_event("update", _, socket) do
  user = find_user(socket.assigns.users, socket.assigns.current_user_id)
  params = user_params(socket)
  {:ok, updated_user} = CRUD.update_user(user, params)

  socket
  |> update(:users, fn users ->
    Enum.map(users, fn
      user when user.id == updated_user.id ->
        updated_user

      user ->
        user
    end)
  end)
  |> noreply()
end

defp user_params(socket) do
  %{"first_name" => socket.assigns.first_name, "last_name" => socket.assigns.last_name}
end

Finally, I added a filter form at the top of the page to filter a list of users. The form triggered a “filter-list” event on change, and the handle event callback did something terrible:

def handle_event("filter-list", %{"filter" => text}, socket) do
  socket
  |> update(:users, fn users ->
    Enum.filter(users, fn user -> String.starts_with?(user.last_name, text) end)
  end)
  |> noreply()
end

Did you spot the terrible part?

The handle event filtered the users assign directly. It worked the first time we searched for someone. But as soon as we made a typo or decided to filter for a different name, we no longer had the full list of users! We had overridden the list of users in memory, and we could only get it back by refreshing the page.

So, as a brute-force remedy, I introduced yet another assign to keep track of the filtered users:

users = CRUD.list_users()

socket
|> assign(:users, users) # canonical list of users
|> assign(:filtered_users, users) # filtered list of users
|> assign(:current_user_id, nil)
|> assign(:first_name, "")
|> assign(:last_name, "")

I rendered the list of @filtered_users:

<select class="appearance-none" name="selected_user" size="<%= length(@users) %>">
  <%= for user <- @filtered_users do %>
    <option phx-click="select-user" id="user-<%= user.id %>" value="<%= user.id %>"><%= user.last_name %>, <%= user.first_name %></option>
  <% end %>
</select>

And I kept having to repopulate the filtered_users with the “canonical” users assigns every time we handled a filter event.

That implementation of the CRUD task worked… sometimes 😅, but it had many edge cases, and it felt very wrong. I had to keep assigns in sync, I had two copies of user data, and I couldn’t use the ergonomics of Phoenix forms and Ecto changesets, which made showing validation errors difficult. So, having learned about my domain, I went back to the drawing board.

Removing filtered_users assigns

I first removed the filtered_users assign. Like I mentioned above, filtering the users assign would make us lose data, so we had to keep resetting the filtered_users with the “canonical” users assign. I didn’t like keeping two copies of users data in memory, and it seemed like a code smell.

When I stopped to think about the problem, I realized that filtering users is strictly a display concern and need not have an in-memory representation backing it up. So, instead of filtering through our users when we handled the “filter-list” event, I just kept track of the filter text in a filter assign:

def handle_event("filter-list", %{"filter" => text}, socket) do
  socket
  |> assign(:filter, text)
  |> noreply()
end

We can now filter our @users when we render them:

<select class="appearance-none" name="selected_user" size="<%= length(@users) %>">
  <%= for user <- filter_users(@users, @filter) do %>
    <option phx-click="select-user" id="user-<%= user.id %>" value="<%= user.id %>"><%= user.last_name %>, <%= user.first_name %></option>
  <% end %>
</select>

Where filter_users/2 looks like this:

defp filter_users(users, filter) do
  Enum.filter(users, fn user -> String.starts_with?(user.last_name, filter) end)
end

That was much simpler, and it removed the need for a @filtered_users assign. ✅

Separating new users from existing users

I next wanted to distinguish between working with a new user to create it and working with an existing user to update or delete it. If I could do that, I knew I’d be able to render two different forms (depending on whether we’re creating a new user or updating an existing one). And that would let us use all the powers and ergonomics that come from combining Phoenix forms with Ecto changesets.

So, I updated my mental domain model of user changes to look something like this:

@type t ::
        {:new_user, Ecto.Changeset.t()}
        | {:selected_user, User.t(), Ecto.Changeset.t()}

We either have a :new_user and a changeset for a brand new user. Or we have a :selected_user, the user that was selected, and a changeset to update that user.

To make that domain model a reality, I exposed a couple of helper functions in my CRUD module to help encapsulate those concepts:

def new_user_changes, do: {:new_user, User.changeset(%User{})}

def selected_user_changes(%User{} = user), do: {:selected_user, user, User.changeset(user)}

That meant the assigns in mount/3 could be simplified to only include the list of users, the filter text, and a set of user changes:

def mount(_, _, socket) do
  users = CRUD.list_users()

  {:ok,
    assign(socket,
      users: users,
      filter: "",
      user_changes: CRUD.new_user_changes()
    )}
end

Now, when we select a user in the UI, we stop dealing with a new user and start dealing with an existing user. The transition between those two states falls naturally in our “select-user” handle_event/3 callback:

def handle_event("select-user", %{"value" => user_id}, socket) do
  user_id = String.to_integer(user_id)
  user = find_user(socket.assigns.users, user_id)

  socket
  |> assign(:user_changes, CRUD.selected_user_changes(user))
  |> noreply()
end

Our template becomes a little bit more complicated, but it’s very declarative, and I like that.

We add a case statement that exhaustively handles all possible @user_changes. Since each of the cases is a tagged tuple (like {:new_user, changeset}), we use pattern matching to get the changesets that we’ll use in our forms:

<%= case @user_changes do %>
  <% {:new_user, changeset} -> %>
    <%= f = form_for changeset, "#", [id: "new-user", phx_change: :update_params, phx_submit: "create-user"] %>
      <%= text_input f, :first_name %>
      <%= error_tag f, :first_name %>

      <%= text_input f, :last_name %>
      <%= error_tag f, :last_name %>

      <div class="mt-10 space-x-2">
        <%= submit "Create" %>
        <button id="update" type="button" disabled>Update</button>
        <button id="delete" type="button" disabled>Delete</button>
      </div>
    </form>


  <% {:selected_user, _user, changeset} -> %>
    <%= f = form_for changeset, "#", [id: "update-user", phx_change: :update_params, phx_submit: "update-user"] %>
      <%= text_input f, :first_name %>
      <%= error_tag f, :first_name %>

      <%= text_input f, :last_name %>
      <%= error_tag f, :last_name %>

      <div class="mt-10 space-x-2">
        <button id="create" type="button" disabled>Create</button>
        <%= submit "Update" %>
        <button id="delete" type="button" phx-click="delete-user">Delete</button>
      </div>
    </form>

<% end %>

But though the whole is more complicated, our forms gain a lot of power because we know the context in which we render them.

For the {:new_user, changeset} case, we render a form to create a user. The form has a phx_submit: "create-user" event attached to it, which is unambiguous since we know we’re creating a user. And we’re able to disable the “Update” and “Delete” buttons confidently — one of the task’s requirements.

For the {:selected_user, user, changeset} case, we render a form that has a phx_submit: "update-user" event, which is (once again) unambiguous since any updates to the first and last name must deal with updating a user.

Finally, we have a phx-click="delete-user" event on the “Delete” button which can delete the selected user that we keep in memory as the second element of {:selected_user, user, changeset}.

Both forms get validations errors thanks to Phoenix form helpers working with Ecto changesets. And we can now show validation errors on change by adding a phx-change="update_params" event, which gets its own handle_event/3 callback:

def handle_event("update_params", %{"user" => params}, socket) do
  socket
  |> assign(:user_changes, CRUD.user_changes(socket.assigns.user_changes, params))
  |> noreply()
end

CRUD.user_changes/2 keeps our changesets up to date, and it includes the correct repo action we’ll perform so that any validation errors show up on the Phoenix form:

def user_changes({:new_user, _changeset}, params) do
  {:new_user, %User{} |> User.changeset(params) |> Map.put(:action, :insert)}
end

def user_changes({:selected_user, user, _changeset}, params) do
  {:selected_user, user, user |> User.changeset(params) |> Map.put(:action, :update)}
end

Finally, our handle_event/3 callbacks for creating, updating, or deleting a user are now straightforward since it’s clear which form we’re dealing with and since we have a changeset ready in our user_changes assign:

# Create a user
def handle_event("create-user", _, socket) do
  case CRUD.create_user(socket.assigns.user_changes) do
    {:ok, user} ->
      socket
      |> update(:users, fn users -> [user | users] end)
      |> assign(:user_changes, CRUD.new_user_changes())
      |> noreply()

    {:error, user_changes} ->
      socket
      |> assign(:user_changes, user_changes)
      |> noreply()
  end
end

# Update a user
def handle_event("update-user", _, socket) do
  case CRUD.update_user(socket.assigns.user_changes) do
    {:ok, updated_user} ->
      socket
      |> update(:users, &replace_updated_user(&1, updated_user))
      |> assign(:user_changes, CRUD.new_user_changes())
      |> noreply()

    {:error, user_changes} ->
      socket
      |> assign(:user_changes, user_changes)
      |> noreply()
  end
end

# Delete a user
def handle_event("delete-user", _, socket) do
  {:ok, deleted_user} = CRUD.delete_user(socket.assigns.user_changes)

  socket
  |> update(:users, &remove_deleted_user(&1, deleted_user))
  |> assign(:user_changes, CRUD.new_user_changes())
  |> noreply()
end

This second implementation satisfies all the requirements of the task, has a very declarative UI, has no duplicate data in assigns, and uses all the power of Phoenix forms and Ecto changesets. I like it a lot more than the first pass.

Resources

These are links to the repo with all my examples and the commit for the CRUD app:

You can also find my posts for the previous tasks:

And for a full description of all tasks, take a look at the 7 GUIs website.