Async validation for a form in a livecomponent seems harder than it should be

I have an input in a form that lives in a livecomponent that requires a couple database calls to properly validate. I should do this async, and elixir/liveview should be the perfect tools, but the pattern isn’t jumping out at me.

send_update() unfortunately calls self(). Looking at the source I can get around that with
send(lv_proc, {:phoenix, :send_update, {mod, id, args}}), but that calls update/2 callback, overloading update/2 for a much of different validation messages is not what it was intended for.

on the client side there is now a beautiful pushEventTo(), that’s really what I want on the server side.

Am I missing something?

Why you say unfortunately :slight_smile: ? The LiveComponent lives in the same LiveView process. If you need to make async db calls, you could make the db call from the component and then handle the async response in the LiveView module (since it’s the same process), which then updates the data passed to the component (or just calls send_update with the new data).
Something like

defmodule SampleWeb.FormComponent do
  use SampleWeb, :live_component
 
  def update ...
  def render...

  def handle_event("submit", _, socket) do
    Sample.async_db_call( self() ) #makes an async call which will send back the data to self()

    socket = assign(socket, :status, :validating)
    {:noreply, socket}
  end

end

defmodule SampleWeb.PageLive do
  use SampleWeb, :live_view

  ...

  def handle_info({:async_db_call_response, data}, socket) do
    # do something with the data
    send_update SampleWeb.FormComponent, id: "form", ...
    {:noreply, socket}
  end
end

Another way can be that the component sends a message to the LiveView process (self()) just asking to make the async db call. The LiveView process then makes the call, receives the result and updates the data passed to the component. Something like this:

defmodule SampleWeb.FormComponent do
  use SampleWeb, :live_component
 
  def handle_event("submit", event, socket) do
    send(self(), {:form_submit, event})
    socket = assign(socket, :status, :validating)
    {:noreply, socket}
  end

end

defmodule SampleWeb.PageLive do
  use SampleWeb, :live_view

  def handle_info({:form_submit,  event}) do
    Sample.async_db_call() 
    {:noreply, socket}
  end

  def handle_info({:async_db_call_response, data}, socket) do
    # do something with the data
    send_update SampleWeb.FormComponent, id: "form", data: data
    {:noreply, socket}
  end
end

In general, the async messages (like async db response etc.) are handled by the LiveView module with the handle_info callback.

Do you really need a component? You could start by making it work with a single LiveView, and then move some parts to a component.

Can you please post the LV and component code you are working on?

1 Like

@alvises,
Thankyou for your response and code code samples. The first example is roughly what I"m doing, except I’m sidestepping having to trampoline off the main live view module. I’ve found using an lv component for each form is a good way to organize for me. Forms just end up being messy with lots of def handle_event(), and ephemeral state. It’s not necessary, but it’s nice to put them in their own little namespace box.

I think what would make my pattern is a push_event_to(pid, mod, id, event, params).

Where would you call this function and to do what exactly?

I would put it in Phoenix.LiveView, it would trigger a handle_event(event,params,socket) in the livecomponent pointed to by id.

How would you use it? can you write a quick example?

@alvises, thanks for engaging on this, here’s a quick mock, I’m sure there is typos, hopefully you get the idea.

defmodule XWeb.FormComponent do
  use XWeb, :live_component

  def render() do
    ~L"""
    <input class="input"
        name="username"
        phx-blur="blur",
        phx-target="<%= @myself %>" >
    <p class="help is-danger"> <%= @username_error %> </p>
    """
  end

  def handle_event("blur", params, socket) do
    Helper.aync_validate(params)

    {:noreply,
     assign(socket,
       username_value: params["value"],
       username_error: ""
     )}
  end

  def handel_event(:username_invalid, params, socket) do
    # make sure value hasn't changed
    socket =
      cond do
        params.value == socket.assigns[:username_value] ->
          assign(socket, :username_error, params.msg)

        true ->
          socket
      end

    {:noreply, socket}
  end
end

defmodule XWeb.AppLive do
  def render(assigns) do
    ~L"""
    <%= live_component XWeb.FormComponent, @socket, id: :form %>
    """
  end

  def mount(socket) do
    {:ok, assign(socket, username_error: "")}
  end
end

defmodule Helper do
  def async_validate(params) do
    lv_pid = self()

    spawn(fn ->
      # do database something that might take a second
      if found_in_db(params["value"]) do
        Phoenix.LiveView.push_event_to(lv_pid, XWeb.FormComponent, :form, :username_invalid, %{
          field: :username,
          msg: "User name is already in use blah blah",
          value: params["value"]
        })
      end
    end)
  end
end

I don’t think you need to add a new function to LV… the problem of this solution is that the different parts are tightly coupled.

In the documentation they show the two patterns:

  • LiveView as the source of truth, in which the component sends a message to the LiveView process, which handles the message with handle_info/2 and updates the data. They also show how you can use a PubSub topic to communicate in different parts of you application.
  • LiveComponent as the source of truth, which seems to be your case… but still:

However, note that components do not have a handle_info/2 callback. Therefore, if you want to track distributed changes on a card, you must have the parent LiveView receive those events and redirect them to the appropriate card. For example, assuming card updates are sent to the “board:ID” topic, and that the board LiveView is subscribed to said topic
from: Phoenix.LiveComponent — Phoenix LiveView v0.20.2

Which I think it’s the best pattern. You don’t have to spawn any new process, you don’t have to implement new LiveView functionalities. (In general, remember to spawn_link, so the process is stopped if the LiveView process crashes/terminates/user disconnects. Or even better, use Tasks).

Or, if you want to keep all the logic in the same module, you can use a child LiveView instead of a component.