How to (Part 2): Swap Registration Flow to a Live View With phx_gen_auth — Multi-step form

The Startup
Published in
5 min readFeb 4, 2021


Sneak peek at Metamorphic’s multi-step form courtesy Phoenix Live View. © 2021 Moss Piglet


I’m currently developing a social connection web application, called Metamorphic, and I decided that I didn’t want a “never-ending” form on the registration page — or at least, that’s how it was beginning to feel as I would decide to add more fields to the signup.

This meant that I needed to implement what is commonly known as a multi-step (or multi-part) form. Typically, this would be implemented using JavaScript, but I had a feeling that Phoenix Live View could do the trick.

As always, reading the Phoenix Live View docs proved critical to navigating a few unexpected behaviors after following this wonderful guide from Markus.


To make following along easier, we will assume you have read Part 1, where we swapped the “dead view” over to a Live View for registration.

That being said, the same basic prerequisites as Part 1 apply:

  • You have a working Elixir/Phoenix app setup with the live generator (or add the appropriate code — see the docs).
  • You have installed and setup phx_gen_auth (version 0.6.0) using mix phx.gen.auth Accounts Person people (you can substitute other names).
  • You are using phoenix_live_view 0.15.4.
  • You are using tailwindcss or understand your own CSS tooling.
  • Optional: You have setup email confirmation with Bamboo.

Let’s Begin

So, at this point, we’ve got a working registration Live View page (per Part 1) and we need to transition the fields into a multi-step form.

This means that we are going to be working in two files within our Phoenix application: app/lib/app_web/live/person_registration_live/new.ex and app/lib/app_web/templates/person_registration/new.html.leex .

If you’re working in an umbrella project, then you just have a few additional directories to work through on your file path before you get to your app_web section.

Step 1

Let’s tackle person_registration_live/new.ex first.

This is essentially following Markus’ guide exactly with a few differences that relate specifically to our own setup (including the optional usage of Bamboo).

The only changes we made from Part 1 are the two new handle_event/3 functions after our render/1 function, and mount/3. Let’s take a closer look at each:

The first handle_event/3

def handle_event("prev-step", _value, socket) do  new_step = max(socket.assigns.current_step - 1, 1)  {:noreply, assign(socket, :current_step, new_step)}end

Our first handle_event/3 takes a "prev-step" event that we will pass through our upcoming new.html.leex template. We don’t need to worry about passing a value or other unsigned_params so we prefix it with the “_” character. Lastly, we need to pass the socket, which is a struct that holds our state and is used in place of conn in Live Views.

Then, we bind the new_step variable to the largest of the two given terms, in this case, either socket.assigns.current_step - 1 or 1.

Lastly, we send a :noreply signal with the :current_step assigned to the new_step in the socket.

The second handle_event/3

def handle_event("next-step", _value, socket) do  current_step = socket.assigns.current_step  changeset = socket.assigns.changeset  step_invalid =    case current_step do      1 -> Enum.any?(Keyword.keys(changeset.errors), 
fn k -> k in [:name] end)
2 -> Enum.any?(Keyword.keys(changeset.errors),
fn k -> k in [:email] end)
3 -> Enum.any?(Keyword.keys(changeset.errors),
fn k -> k in [:password] end)
4 -> Enum.any?(Keyword.keys(changeset.errors),
fn k -> k in [:terms_of_use] end)
_ -> true end new_step =
if step_invalid, do: current_step, else: current_step + 1
{:noreply, assign(socket, :current_step, new_step)}end

Our second handle_event/3 takes a "next-step" event and the rest of the parameters remain the same as before.

Then, we bind the current_step variable to the current_step we have assigned in the socket. Then, we implement a bit of logic to decide whether or not we have an invalid step, by binding invalid_step to the return of the following case statement.

You may well be best served to break this logic out into a separate file (context) or private function call, but it’s up to you. The important thing to understand, is that you are essentially declaring which field validations are being checked on each respective step.

You could add as many or as few fields and steps as you want here.

After our case statement, we bind new_step to the current_step's state depending on whether or not the step is invalid.

Lastly, we assign our new_step to the current_step in the socket.

The mount/3

def mount(_params, _session, socket) do  changeset = Accounts.change_person_registration(%Person{})  {:ok,    socket    |> assign(:current_step, 1)    |> assign(:changeset, changeset)}end

What’s worth noting here, is that we additionally assign the :current_step to the socket and give it an initial value of 1. This essentially means that when a person lands on our registration form page, they will be starting at step #1.

Perfect, let’s look at our last file to update.

Step 2

Let’s tackle our person_registration/new.html.leex now.

I’ve stripped out all of the CSS styling to illustrate the key takeaways:

  • <%= unless @current_step === 1, do: "hidden" %> We need to wrap our fields for each step in a similar div logic statement like this to ensure the fields (a) preserve our input throughout each step (forwards and backwards) and (b) work with our validations appropriately. However, this is not enough alone, we must do one more thing to ensure our validations don’t validate before we’ve reached the respective field.
  • type="button" This is the second crucial piece to the validation pie. We need to explicitly set this in our CSS otherwise the “Back” and “Continue” buttons are treated as submit events on the server which can cause undesired behavior.
  • phx-click This allows us to send back the proper event message to our Live View and proceed accordingly.

We ultimately pass our events "prev-step" and "next-step" to our respective “Back” and “Continue” buttons. This is how Phoenix Live View triggers the respective actions based on our handle_event/3 functions and navigates us through our form.


As your multi-step form takes on more complexity, you may begin to encounter edge-cases.

One such example that I encountered, involved a solution where I needed to update the “Back” button in the person_registration/new.html.leex template to not be rendered on the final form step:

...<%= f = form_for @changeset, "#", [phx_change: :validate, phx_submit: :save] %>  ...  # Where final_step is your form's final step.
<%= if @current_step > 1 and @current_step < final_step do %>
<button type="button" phx-click="prev-step">Back</button>
<% end %>

It will ultimately depend on the specific intricacies and needs of your application, but I wanted to share a bit more on what I’ve begun to discover utilizing this approach to a multi-step form with Phoenix Live View.


That’s it! We’ve got a working multi-step form using Phoenix Live View and successfully extended our registration flow from Part 1. You can easily add more fields following the logic above and build out as many, or as few, steps as you’d like.

Hope this helps anyone looking to implement a multi-step form using Phoenix Live View.

Totally open to improvements and thoughts.

Special thanks to Markus and the community at Elixir Forum.

❤ Mark



The Startup

Creator @ Metamorphic | Co-founder @ Moss Piglet