Nested forms in live components - causing invalid html

Hi,

I’m running into an issue with a form inside of my rich editor live component. For the editor I have a button that opens a modal with a link insertion form. The problem is that the editor itself is also rendered inside a form of course. This causes issues and is invalid html right?

First I tried not having a form in the modal but saving the fields values on change in the assigns of the editor component. But pressing enter would still submit the parent form. I experimented with javascript to prevent the enter event but this felt too hacky.

Then I tried moving or “portalling” the modal component to the so that it’s form is no longer part of the form the editor is part of. But I could not get this to work, live view could no longer perform any updates and I believe it lost track of the modal since it was no longer in the same place. I also tried teleport with Alpine.js but I had similar issues here.

Hoping anybody knows a proper solution to handling nested form structures. Or proper live view portalling that would solve this.

Thanks!

This is not a Phoenix/LiveView issue; the HTML5 standard prohibits nesting <form> tags. My first choice to address this would be to find another rich text editor that doesn’t generate <form> tags inside modals. If that’s not an option, could you please provide more details about the rich text editor you’re using and the structure of the parent (LiveView) form?

Hi, Thank you for helping out!

I created the rich editor myself, the problem is not necessarily about the editor, I should’ve been clearer about that. The issue is that I have a modal with a form inside a live component and that live component lives in another form. I can’t really lift the modal portion out. That’s why I tried moving or portalling or teleporting the modal to the body element but this caused issues with live view updates.

I could be wrong, but I feel like this is a fundamental framework issue. Other frameworks and even Alpine.js support simple portal or teleport operations and with react this would be even easier. Not to bash on Phoenix, it’s best, I love it, but if you’re deeply nested in a component there should be a way to render stuff elsewhere to prevent collision or invalid HTML while live view keeps working correctly.

I recreated my situation in a very basic manner to outline the problem better:

defmodule MyAppWeb.EmailEditorLive do
  use MyAppWeb, :live_view
  
  def mount(_params, _session, socket) do
    changeset = %{email_subject: "", email_body: ""}
    
    {:ok, 
     socket
     |> assign(:form, to_form(changeset))}
  end

  def render(assigns) do
    ~H"""
    <div class="container mx-auto">
      <h1>Email Editor</h1>
      
      <.form for={@form} phx-submit="save">
        <div class="space-y-4">
          <div>
            <.input field={@form[:email_subject]} label="Subject" />
          </div>
          
          <.live_component
            module={MyAppWeb.RichEditorComponent}
            id="rich-editor"
            field={@form[:email_body]}
          />
          
          <div>
            <.button type="submit">Save Email</.button>
          </div>
        </div>
      </.form>
    </div>
    """
  end
  
  def handle_event("save", %{"email" => params}, socket) do
    # Handle the main form submission
    {:noreply, socket}
  end
end

defmodule MyAppWeb.RichEditorComponent do
  use MyAppWeb, :live_component
  
  def mount(socket) do
    {:ok, 
     socket
     |> assign(:show_link_modal, false)
     |> assign(:link_form, to_form(%{"url" => "", "text" => ""}))}
  end
  
  def render(assigns) do
    ~H"""
    <div>
      <div class="border p-2">
        <div class="flex gap-2">
          <button type="button" class="p-1 border rounded">Bold</button>
          <button type="button" class="p-1 border rounded">Italic</button>
          <button 
            type="button" 
            class="p-1 border rounded"
            phx-click="show_link_modal"
            phx-target={@myself}
          >
            Add Link
          </button>
        </div>
        
        <div class="mt-2">
          <textarea 
            name={@field.name}
            class="w-full h-40 border rounded"
            value={@field.value}
          ></textarea>
        </div>
      </div>

      <.modal :if={@show_link_modal} id="link-modal" show>
        <.form for={@link_form} phx-submit="save_link" phx-target={@myself}>
          <div class="space-y-4">
            <div>
              <.input field={@link_form[:text]} label="Link Text" />
            </div>
            <div>
              <.input field={@link_form[:url]} label="URL" />
            </div>
            <div class="flex justify-end gap-2">
              <.button 
                type="button"
                phx-click="cancel_link"
                phx-target={@myself}
              >
                Cancel
              </.button>
              <.button type="submit">
                Insert Link
              </.button>
            </div>
          </div>
        </.form>
      </.modal>
    </div>
    """
  end
  
  def handle_event("show_link_modal", _, socket) do
    {:noreply, assign(socket, :show_link_modal, true)}
  end
  
  def handle_event("cancel_link", _, socket) do
    {:noreply, assign(socket, :show_link_modal, false)}
  end
  
  def handle_event("save_link", %{"link" => params}, socket) do
    # Handle the link form submission
    {:noreply, 
     socket
     |> assign(:show_link_modal, false)}
  end
end

You can split the modal into its own component and put it after the other form in the HTML.

There is talk of portals in the liveview issue tracker, if you want to give your 2c.

Yes, agreed. Although I would need PubSub or a hook for the modal to talk to the editor. And it does not make sense for the parent component to have a link insertion modal in it, they belong in the editor since they’re tightly coupled.

This is basically the best option for now right? Portals would probably solve this so I’ll take a look at that issue. Thanks for providing your insights!

I don’t see why showing/hiding that modal or adding a link to the text should involve the backend. I would use a hook for the link adder.

You can look into using a self closing form tags for the outer form so that the editor form id not nested inside another.

It’s not a question of server vs client side right? In both cases we would end up with either a nested form or highly controlled input fields inside of the wrong form.

And totally don’t understand what you mean with

You can look into using a self closing form tags for the outer form so that the editor form id not nested inside another.

Forms don’t have self-closing tags according to my knowledge.

I was speaking a little off topic. Every time you send an event to the backend to change the visibility of an element, a latency fairy dies.

Sorry, empty form tags, not self closing ones.