Persisting state between page loads on the backend with Elixir/Phoenix
What's the idea?
Say you have a signup process that's more onboarding than account creation.
The user submits information in multiple steps, where the next step depends on the contents of the previous step, etc.
You also don't have an advanced frontend. It's just a basic server-based application, so each step means navigating to a different page
What if the user gives up half way through? How do you deal with that?
Suddenly, you are left with a partially created account.
Maybe that partial data is useless to you and you want to clean it up? Maybe you want to do something about it? Send an email? Perform some task?
Ideally, you don't save anything into the database yet, because why waste resources. However, you do keep it somewhere where it's cheap and easy to retrieve.
Then, when the user is all done, you save the data. When you detect the user gave up, you do something about it without having to sanitise anything in the database.
GenServer
!
Do it with a Elixir has a GenServers
and they're simple to use for any of those tasks you want to do. In fact, they even make it easy to create a multi-step process in the first place!
How?
Let's generate a basic app called "wizard", where a user creates their account and workspace.
mix phx.new wizard --no-ecto
We don't need a repository for the purposes of demonstrating the concept, thus, --no-ecto
.
Next up, we add some routes for our onboarding flow
get "/step-1", WizardController, :step_1
post "/step-1", WizardController, :next_step
get "/step-2", WizardController, :step_2
post "/step-2", WizardController, :submit_steps
get "/doublecheck", WizardController, :doublecheck
And then we also add our controller.
I could've used a generator for all this, but I didn't, and you can copy paste, so it's fine!
# lib/wizard_web/controllers/wizard_controller.ex
defmodule WizardWeb.WizardController do
use WizardWeb, :controller
alias Wizard.Onboarding
def step_1(conn, _params) do
conn |> render("step_1.html")
end
def submit_step_1(conn, params) do
conn.remote_ip |> Onboarding.submit_account(params)
conn |> redirect(to: conn |> wizard_path(:step_2))
end
def step_2(conn, _params) do
conn |> render("step_2.html")
end
def submit_step_2(conn, params) do
conn.remote_ip |> Onboarding.submit_workspace(params)
conn |> redirect(to: conn |> wizard_path(:doublecheck))
end
def doublecheck(conn, _params) do
{account_params, workspace_params} =
conn.remote_ip |> Onboarding.get_data()
conn
|> render(
"doublecheck.html",
account_params: account_params,
workspace_params: workspace_params
)
end
end
The idea is, the step_1
action renders a form. The user then submits the form, calling the submit_step_1
action.
submit_step_1
sends the submitted parameters to our GenServer
, which stores them.
Similarly, step_2
renders another form, which submits another set of parameters with submit_step_2
, which also stores those parameters to the GenServer
.
Then, we end up on redirected to the doublecheck
action, where we retrieve both sets of parameters and render our template with those as the assigns.
For demo purposes, the template does nothing else other than rendering the parameters.
All 3 calls to the GenServer
send conn.remote_ip
as the first argument. The server uses the IP as a key to stare the data on a per-user basis.
Here's what the GenServer
looks like:
# lib/wizard/onboarding.ex
defmodule Wizard.Onboarding do
@name __MODULE__
use GenServer
# API
def start_link() do
GenServer.start_link(@name, [], name: @name)
end
def submit_account(ip, params) do
GenServer.cast(@name, {:submit_account, ip, params})
end
def submit_workspace(ip, params) do
GenServer.cast(@name, {:submit_workspace, ip, params})
end
def get_data(ip) do
GenServer.call(@name, {:get_data, ip})
end
# Callbacks
@impl true
def init(_) do
{:ok, %{}}
end
@impl true
def handle_cast({:submit_account, ip, params}, %{} = state) do
# keep old workspace params, update account params
new_state =
state
|> Map.update(ip, {nil, nil}, fn {_, workspace_params} ->
{params, workspace_params}
end)
{:noreply, new_state}
end
@impl true
def handle_cast({:submit_workspace, ip, params}, %{} = state) do
# keep old account params, update workspace params
new_state =
state
|> Map.update(ip, {nil, nil}, fn {account_params, _} ->
{account_params, params}
end)
{:noreply, new_state}
end
@impl true
def handle_call({:get_data, ip}, _from, %{} = state) do
{:reply, state[ip], state}
end
end
Again, emphasis on simplicity for this demo.
Out state
is a map and we use the IP as the key for storing individual bits of data.
The value for each IP is a tuple, the first element of which are the account params submitted in step 1 and the second element the workspace params submitted in step 2.
Note that using the IP as the data key probably isn't ideal, but again, this is about the idea, not the real life execution.
For the sake of getting this running as soon as possible, this is our view and respective templates:
# lib/wizard_web/views/wizard_view
defmodule WizardWeb.WizardView do
use WizardWeb, :view
end
Nothing special about that.
# lib/wizard_web/templates/wizard/step_1.html.eex
<h3>Step 1: Your personal info</h3>
<%= form_for @conn, @conn |> wizard_path(:submit_step_1), fn f -> %>
<div class="form-group">
<%= label(f, :email) %>
<%= text_input(f, :email, class: "form-control") %>
</div>
<div class="form-group">
<%= label(f, :full_name) %>
<%= text_input(f, :full_name, class: "form-control") %>
</div>
<div class="form-group">
<%= submit("Next", class: "btn btn-primary pull-right") %>
</div>
<% end %>
# lib/wizard_web/templates/wizard/step_1.html.eex
<h3>Step 2: Your workspace info</h3>
<%= form_for @conn, @conn |> wizard_path(:submit_step_2), fn f -> %>
<div class="form-group">
<%= label(f, :name) %>
<%= text_input(f, :name, class: "form-control") %>
</div>
<div class="form-group">
<% back_path = @conn |> wizard_path(:step_1) %>
<%= link("Back", to: back_path, class: "btn btn-default") %>
<%= submit("Doublecheck", class: "btn btn-success pull-right") %>
</div>
<% end %>
# lib/wizard_web/templates/wizard/doublecheck.html.eex
<h3>This is the data you submitted:</h3>
<h4>Account</h4>
<pre>
<%= @account_params |> Kernel.inspect(pretty: true) %>
</pre>
<h4>Workspace</h4>
<pre>
<%= @workspace_params |> Kernel.inspect(pretty: true) %>
</pre>
<% back_path = @conn |> wizard_path(:step_2) %>
<%= link("Back", to: back_path, class: "btn btn-default") %>
As I said, the steps are forms, while the final step is a basic page that renders the data we have.
Where to from here?
In a real world scenario, each step would do something more. For example, individual submit steps could cast changesets or process the data in some way.
The controller could cast data into changesets and render validation errors, storing valid changesets into the GenServer
, instead of raw parameters.
As a final step, we would also have some sort of call which actually persists the data, but as I said, this is about the idea.
The possibilities should be clear, though. It's trivial to manage state during user interaction and between page navigations using a GenServer
.
Going even further...
LiveViews are soon to become a thing.
In the meantime, you could use the same GenServer
, in conjunction with phoenix channels, to inform the user about any long running tasks in the onboarding process in real time.
Who says you need to use this in a primarily server-based app? Imagine the possibilities!