JohnnyCurran
The Top 3 LiveView Form Mistakes (And How to Fix Them)
I’ve been writing LiveView since 2020. In that time, I’ve seen the same three form mistakes at multiple companies. Here’s what they are and how to fix them.
1. Slow, laggy forms with scattered logic because form state gets stored in socket assigns and server round-trips get used for dynamic UI (conditional inputs, toggles), instead of keeping that state in hidden form inputs where it belongs.
2. Brittle system where UI and database can’t evolve independently because database schemas get used directly for forms, coupling persistence logic to presentation.
3. Users stuck with valid data but can’t submit because changesets get manually manipulated with Map.put or Map.merge instead of Ecto.Changeset functions, leaving stale errors behind.
The common thread: don’t fight the framework. Keep form state on the client, create embedded schemas for your forms, and use Ecto.Changeset functions to modify changesets.
Most Liked
kevinschweikert
In Mistake #2, where you do:
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
How would you map the errors back to the form schema, when the fields from the DB are different?
tfwright
I don’t have much professional experience with LiveView, just hobby projects. But RE 1, aren’t things a bit more complex than you suggest? You say “don’t fight the framework,” but naively it would seem using JS at all is fighting a framework the express intention of which is presumably to manage state on the server. Your example is a good one for your argument, seems like pure FE state that is relatively easy to handle (with latest JS integration). But in reality isn’t most state more ambiguous?
Take a table with rows that are selectable. On first glance this seems like something that should be handled on the FE. Certainly requiring a server trip to toggle selection creates lag, and arguably breaks a strong user expectation that checkboxes are highly responsive. But what happens when some BE action needs to know which rows are selected? Or even needs to rerender part of the view (e.g. to display some metadata about selected rows)? Obviously, you can solve this with more JS, but now all the defects you mentioned with a BE implementation apply: the logic is more spread out, more place for bugs, and arguably JS bugs are a lot more difficult to test and QA against (at least, that’s why we’re using LV in the first place, no?). Alternatively, one could try to use different tooling to alleviate issues with lag, like debouncing, optimistic UI updates, etc.
To be clear, I don’t think your advice is necessarily bad, in the end I think it is simply one of the challenges of development with LV to find the right balance here. But it is a balance, and a delicate one. The more JS features get added to a LV project the lower the ROI it seems. At a certain point, if you want to add a lot of these features, DX is going to go downhill in comparison with React.
JohnnyCurran
Good question.
In that specific case branch, nothing needs to be done, because that changeset is the form changeset.
If there were an error in Accounts.register_user and you got a changeset back, you’d do something like (psuedo-ish code):
changeset = FormModule.changeset(%FormModule{}, form_params)
changeset
|> Ecto.Changeset.apply_action(:insert)
|> case do
{:ok, form_params} ->
form_params
|> Map.from_struct()
|> Accounts.register_user()
|> case do
{:error, user_changeset} ->
# Figure out which user changeset field had an error
# Place error in changeset, validate, re-assign
socket =
changeset
|> Ecto.Changeset.put_error(:field, "There was an error saving to the database!")
|> Map.put(:action, :validate)
|> to_form()
|> then(&assign(socket, :form, &1))
# ... rest of handler
Is how I’ve done it before
JohnnyCurran
To be clear, I wasn’t and don’t advocate to manage frontend state on the frontend ![]()
Rather, don’t separate related (form, in this case) state in multiple places (regular assigns and the @form assign), and you can use Phoenix.LiveView.JS to provide instant visual feedback to the user to provide for good UI/UX while the server W.S. Round Trip happens ![]()
Thank you for raising the points you did, I think it’s turned into a good discussion! I’ll look it over and see if I can’t make that more clear for future readers
garrison
You are spot on, and in fact this problem has come up on here several times in the past. Particularly when it comes to LiveView’s imperative APIs (stream() and JS). The simpler your app is the easier it is to get away with this. This is why programming in the imperative style is so insidious: when you write code with O(N^2) paths things start off easy (when the app is simple and new) and then you get absolutely destroyed when the curve goes vertical.
The reality is that if you want to do server rendering you need to commit to it and accept the latency. Most of the time this is fine. If in your case the latency is not fine, that is a very good hint that you should not be doing server rendering.
Of course there are always exceptions, edge cases, and compromises that have to made. This is merely a guiding principle.
The main issue is that in practice the JS is usually written in an imperative style. If you were to write your JS declaratively using a proper frontend framework (e.g. React and others) and glue LiveView to that framework properly (passing LV state into props and so on) you could get away with it.
There have been several attempts to make this integration more natural (see live_vue, live_svelte, etc). You will still have consistency issues, but those can be dealt with.







