LV: input value is cleared when unrelated assign changes

I’m trying to build an LV-powered multistep form where the user is presented with a series of questions, with each question having a set of predefined answers (displayed as radio buttons). The user selects an answer, then hits the next button, and the next question is presented.

To guard against netsplits and system restarts, I’ve opted to present this as a single form with each question contained in its own div, and only one div shown at the time. So basically when the user hits the next button, the current_question assign (an integer) is incremented, which will cause the next question to be shown, while all the others will be hidden.

Questions and answers are loaded from the DB, so they are dynamic, and the form can’t be hardcoded. Therefore, I created an LV component called Question, with the following leex:

<div id="<%= @question.id %>" class="<%= unless @visible? do %>hidden<% end %>">
  <h3><%= @question.question %></h3>

  <%= for answer <- @question.answers do %>
    <input type="radio" id="<%= answer.id %>" name="answers[<%=@question.id%>]" value="<%= answer.id %>" />
    ...
  <% end %>

  <button type="submit">Next</button>
</div>

The relevant part of the root leex:

<form phx-submit="next">
  <%= for {question, index} <- Enum.with_index(@questions) do %>
    <%= live_component 
      @socket, 
      Question, 
      question: question, 
      visible?: @current_question == index, 
      id: question.id 
    %>
  <% end %>
</form>

On the LV side, the submit event handler increments the current_question assign and does nothing else.

Unfortunately this doesn’t work quite as expected. When I hit the next button, the checked property of the selected radio button is cleared. Looking at the socket message, I don’t see anything suspicious:

[
  "4",
  "5",
  "lv:phx-FlRcsYGfUEwUFQ0G",
  "phx_reply",
  {
    "response": {
      "diff": {
        "1": {"0": "2"},
        "2": "",
        "3": {"d": [[1], [2], [3]]},
        "4": "",
        "c": {"1": {"1": {"s": ["hidden"]}}, "2": {"1": ""}}
      }
    },
    "status": "ok"
  }
]

I was able to solve this by adding phx-update="ignore" to the radio button. However, I must confess I don’t understand why this is needed, nor any possible negative consequences of adding. Can anyone shed some light? Is this explained somewhere in the docs?

Also, is this in general a good approach to implement an LV-powered multistep form?

4 Likes

From what I understand without seeing the LV @question.answers contains possible answers, not the actual user answers. I don’t see where you store the user selected values from the given code.

phx-update=ignore works in your case because you bypass the LV DOM update when the for answer <- @question.answers do comprehension gets updated. The value stays on screen but you haven’t got it on the server side either, so I don’t think it can solve your problem. phx-update=ignore is usually useful when changes are made to the DOM from outside of LV (eg. plain JS) and you don’t want LV to update such parts of the DOM.

I my opinion, depending on your use case, you either have to use a changeset to bind the values from your form or store the state manually when the form changes (eg. with phx-change).

More information on changesets in forms:

Correct.

I don’t store them. They are shipped back to the server as a question_id => answer_id map on submit, and that’s precisely what I need.

But why is the radio button getting updated? Nothing changed in @question, and as far as I can understand it, the diff doesn’t contain any updates related to that part. Only the class of the parent div should change, nothing else.

Per above, it solves my problem.

Ideally I’d like to avoid changesets because I don’t need them here, at least for the moment. The answers are initially not set, and I only want to pick them up on every next step. Also, there are no validation errors to report.

I actually first started with a changeset, but then gave up, because two-level nesting of inputs_for and some shenanigans with input_value (which I did to get the basics working) seemed quite confusing. Furthermore, it looked like the changeset approach would require adding a virtual field, and this all started to feel like an overkill for my needs.

I wanted to avoid this, b/c I’d like to use client as the source of truth. Moreover, I wanted to avoid roundtrip on every radio button click. That said, it looks like this approach could work, b/c the docs state that client will trigger the same phx-change after a remount, so I guess in this case the state on the server would be restored from the input values on the client. I gotta say though that this feels somewhat hacky, b/c I’m not really interested in change events, and I would only use them to restore the state.

Yeah, that’s basically the case here, since the changes are made when the user clicks. I guess that ignore is acceptable here, though I’m still at loss why the input value is overwritten.

1 Like

You need to store them. The server is the source of truth. Generally speaking, if the client submits data, then you want to apply this data to your template and send it back to the client. Think it as the way the server “acks” the data is valid. If you really don’t want to track this, then phx-update=ignore is the way to go indeed.

4 Likes

OK, thanks. So this sort of works, but I see some strange behaviour on netsplits.

First, let me explain what I’m doing now. I added phx-change to the form, and in the event handler I store the values into assigns. This is used to set the checked property of the radio button. At this point I am able to remove phx-update=ignore and it works as expected.

To test the netsplit behaviour I invoke liveSocket.disconnect() and then liveSocket.connect() from the browser console. I also tried stopping and restarting the server with code_reloader turned off.

In both cases, I lose the state on first reconnect. After that the state is correctly restored.

My understanding from this part of the docs is that I should receive change event on reconnect. I added IO.inspect call, and on first reconnect I don’t receive it. On subsequent behaviour the event is emitted. If I refresh the page, the same behaviour happens (no change on first reconnect, and then it is emitted).

Am I doing something wrong or is this a bug?

It should receive the parameters on first reconnect too. I would try to make a minimal case and open up a bug report.

Thanks. I’ve created the issue.

1 Like