Keep modal dialog open during form validation

Is there a simple solution for the following problem, which I’m unable to figure out:

  • On initial page render the modal dialog is hidden

  • On button click the modal dialog is shown using:

       |> assign(:meal_in_editor, content)
       |> assign(:changeset_meal, Meal.changeset_quick_edit(content, %{})),
       %{id: "modal-edit"}
  • showModal is just a few lines of javascript:
window.addEventListener("phx:showModal", (e) => {
  var element = document.getElementById(;
  var elementBG = document.getElementById( + "-bg");
  var elementContainer = document.getElementById( + "-container"); = "block"; = "block"; = "block";
  • form validation is enabled using phx-change="validate-edit"

  • handling the event like this:

  def handle_event("validate-edit", %{"form" => params}, %{assigns: %{meal_in_editor: meal}} = socket) do
    form =
      |> Meal.changeset_edit(params)
      |> to_form()
    {:noreply, assign(socket, changeset: form)}
  • as soon as the changeset is updated the modal dialog gets rerendered (in its initial hidden state) thus its getting closed

How to keep the modal dialog open during validation?

I don’t have the answer but I’m responding since you got no bites.

When it comes to forms, I always use a URL even if it’s in a modal. It’s always handy to be able to navigate directly to create/edit forms pages or even things like newsletter signups or whathaveyou and personally feel is good practice. I only use JS to show modals when they are static dialogues. Again, it doesn’t answer your question, but it does sidestep your issue.

1 Like

Can do you share the template where the modal is in?

But supposing that you have this:

defmodule ModalForm do
	use Phoenix.Component/LiveComponent
	def render(assings) do
		# your modal stuffs

defmodule LiveviewModalParent do
  def mount(_params, _session, socket) do
    {:ok, assign(socket, changeset: ...)}

  def handle_event(
        %{"form" => params},
        %{assigns: %{meal_in_editor: meal}} = socket
      ) do
    form =
      |> Meal.changeset_edit(params)
      |> to_form()

    {:noreply, assign(socket, changeset: form)}
# ...
<ModalForm.render changeset={@changset} />

when you do this {:noreply, assign(socket, changeset: form)} on handle_event the Liveview will re-render ModalForm because @changeset had its value changed.

@sodapopcan Thank you for the recommendation. I might consider this

@NetonD I did some further investigation and I think you are spot on. If needed I can share part of the template but the problem is the rerendering of the form while the modal dialog is open

The problem (as I understand) is my way to show the modal using an EventListener. That way the state of the modal dialog is not persisted to the server side state of the DOM.

A solution I don’t like is to use instead of this:

{:noreply, assign(socket, changeset: form)}

using a push_event:

{:noreply, push_event(socket |> assign(:changeset, form), "showModal", %{id: "modal-edit"})}
  • How can I fix this in a proper way?
  • Might JS hooks help?

And yes: I admit this might be a XY problem

While this is working:

phx-click={JS.push("setup-modal-data") |> show_modal("modal-edit")}

it gives a not so good user experience if the connection is slow…
Client side the modal popups very fast while the server is still preparing (and transmitting) the form data. So the user is looking at an empty form

Thats why I went to open the modal dialog server sided

Any help and ideas appreciated.

Considering that you are dealing manually with the opening/close behavior of the modal, this can be the only solution to communicates your liveview handle_* with your front-end without re-rendering your template.

Maybe this can be useful, even for other possible problems like load bigger amount of data:

Still trying to solve this problem, because having flickering popups or popups showing a loading state during a presentation of an application implemented with phoenix framework is not the best outcome.
(Though a loading state is the way to go for slower connections or time expensive DB request for sure)

  • I’m still looking for a solution (or hints/ideas): How to show a modal dialog after all rendering is done and keep it open during rerender

With regards to JavaScript interoperability — Phoenix LiveView v0.18.18 I have something like this:

window.addEventListener("phx:showModal", (e) => {
  document.querySelectorAll(`[on_show]`).forEach(el => {
    if( =={
      liveSocket.execJS(el, el.getAttribute("on_show"))

And adding on_show={show_modal("modal-edit")} to a modal:


I am able to persist the change to the DOM at least but the popup modal is still flickering and showing up way to early with an empty content for like 200-400ms.

Perhaps a JS hook using the updated() callback might give the best UX but I’m running out of time so I have to leave it like this for the moment.

Thank you all for the help :+1:

I did a screen recording to clarify the user experience.
(edited: on the client liveSocket.enableLatencySim(1000) was set
On click the following happens:

  • An (empty) modal dialog is shown
  • The empty dialog flickers (might have something to do with the way css animation are done by Phoenix LiveView)
  • The modal dialog is shown with content


Is it possible any on-page elements have non-unique DOM IDs? I’ve seen similar issues when that happens, especially when combined with transitions. Is there anything in the browser console log? (If there are clashing DOM IDs, an error will be logged in devtools.)

Alternatively, is there any chance you modified the built-in show or show_modal functions bundled with CoreComponents (or are using custom behavior)? Specifically the transition: {"", "", ""} tuples? It’s easy to mess those up by e.g. putting a transition class in the wrong position or by trying to “reuse” transition logic between show/hide.

Another thing I’ve seen cause subtle problems is updating one of the duration- Tailwind classes but forgetting the corresponding time option, e.g. 200, transition: {"... duration-1000", "...", "..."}). Notice how duration-1000 doesn’t match the given time of 200 (the default if unspecified).

I saw nearly-identical behavior to your screencast when I had duplicate DOM IDs and improperly-applied transitions. So it could be worth a look.

For debugging, it might be helpful to log all and JS.hide invocations so we can see what’s happening. Luckily LiveView dispatches events for each phase of the transition. Paste this in the browser console and see if anything looks awry:

function logEvent(e) {
    console.log(e.timeStamp, e.type,,;

["phx:show-start", "phx:show-end", "phx:hide-start", "phx:hide-end"].forEach(type => {
  window.addEventListener(type, logEvent, true);

Upon opening a modal thereafter, you should see 3 show-start events followed by 3 show-end events for each “piece” of the modal (bg, container, and the modal itself). So, 6 events in total. If you see a bunch more than that, there’s probably something going on (such as duplicate DOM IDs). It’s also worth paying attention to the className applied for each phase to ensure the expected transition classes are being applied.