Pattern for delegating to parent LiveComponent

Hey! I’m integrating an autocomplete search into my LiveView app, which has taken some time without a drop-in solution. I’m referencing this guide:

https://medium.com/mindvalley-technology/autocomplete-search-component-with-phoenix-liveview-and-alpinejs-4a98b7287b9f

Yet, it demands that the LiveView manage all search events, which would complicate my already complex multi-step onboarding view. Is there an alternative pattern to prevent this sort of bloating? I know I could target the desired recipient component by ID, but that still doesn’t seem ideal. If I was to author a package like react-search-autocomplete for Phoenix, what would be the best design?

I recently wrote about it in our docs (not yet published), so here is a direct link to main: https://github.com/phoenixframework/phoenix_live_view/blob/cde18fd8d961c3cdc1dbfaefba3f9d9169578466/lib/phoenix_live_component.ex#L353 (the idea came from Chris).

7 Likes

Thanks @josevalim!

The example function call format in the unpublished docs:

socket.assigns.function(args)

was causing an error for me. Am I missing something? This was the (eventual) AI diagnosis:

The error message you’re seeing is because the on_search function is being called on the socket.assigns map, not on the on_search function itself

In Elixir, when you call a function from a map, you need to first get the function from the map and then call it.

Here’s how you can do it:

on_search_function = Map.get(socket.assigns, :on_search)
on_search_function.("123")

The compiler error was extremely confusing:

error] GenServer #PID<0.22577.0> terminating
** (ArgumentError) errors were found at the given arguments:

  * 1st argument: not an atom

    :erlang.apply( [massive spew of text which I later realized was the socket assigns... ] )

Close: socket.assigns.function.(args) (note the extra period)

More details: Anonymous functions — Elixir v1.16.0-rc.0

We can invoke anonymous functions by passing arguments to it. Note that a dot (. ) between the variable and parentheses is required to invoke an anonymous function. The dot ensures there is no ambiguity between calling the anonymous function matched to a variable add and a named function add/2 .

2 Likes

Hi @jakespracher - maybe put up the code that you are trying. It’s a bit hard to see what you’re missing when we can’t see what you’ve got.

The code fragments should include the instantiation of the component where you pass the function to the component from its parent, and the event handler in the component.

1 Like

For my project what I did was create a helper struct called Target:

defmodule CoreWeb.Components.Helpers.Target do
  @moduledoc """
  A struct to be used when passing `LiveComponents` targets to other components.
  """

  alias Phoenix.LiveView

  use TypedStruct

  typedstruct enforce: true do
    field :id, String.t()
    field :module, Module.t()
  end

  @spec new(String.t(), Module.t()) :: t
  def new(id, module), do: struct!(__MODULE__, id: id, module: module)

  @spec send_message(map, t() | nil, pid) :: any
  def send_message(message, target, pid \\ self())
  def send_message(message, nil, pid), do: send(pid, message)

  def send_message(message, %__MODULE__{id: id, module: module}, pid),
    do: LiveView.send_update(pid, module, Map.put(message, :id, id))
end

The way it works is that every component that would send messages to another component or liveview receives it as an attr:

  attr :target, Target, default: nil

Then, when I use that component, I can pass the target attr with something like this Target.new(@id, __MODULE__) if I want to send to a component, and just not pass anything when I want to send to liveview.

Then, inside my component, I just use the send_message function like this:

Target.send_message(%{operation: :clear_filter, filter_id: filter_id}, target)

In this case, if target is nil, it will send to liveview and you can get it using:

def handle_info(%{operation: :clear_filter} = message, socket) do
...
end

Or to the component with:

def update(%{operation: :clear_filter} = message, socket) do
...
end

Thanks everyone! To be clear, the unpublished docs linked by Jose Valim do clear up my original confusion. I will mark that as a solution.

For the follow-up: assuming I’m not missing something else and JEG2 is correct, they do seem to provide an invalid example:

socket.assigns.on_card_update(%{socket.assigns.card | title: title})

should be

socket.assigns.on_card_update.(%{socket.assigns.card | title: title})

It would probably be more appropriate for me to open an issue on github, rather than sidetracking this disucssion, though

Fixed on main, thank you!

Error messages also improved in Elixir itself.

3 Likes

I still think the ergonomics here are a bit lacking.

I wonder if a unifying approach, something like phx_send(id, message, socket) is not possible? Whereby if the target is a liveview it does a send() and otherwise a send_update().

2 Likes

We tried to unify it for quite a while. The problem is how they are identified, the type of messages each of them receive, and the callback it is invoked require different types of arguments. So whatever we picked, ended-up feeling too awkward in one, the other, or both. I really liked the function approach when suggested because you allow the format of the message to be specified by the receiver. It also simplifies testing too if you assume less about the parent.

1 Like

Wanted to share exactly what this looked like for me as a more complete example if it’s helpful to anyone. Even following the docs, it was a bit difficult to keep track of the various functions calling each other.

Parent component:

 @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.live_component
        module={MyAppWeb.SearchComponent}
        items={@parent_items}
        on_search={fn value -> send_update(@myself, search: value) end}
        on_select={fn item -> send_update(@myself, item_selected: item) end}
      />
      <div class="flex flex-row p-4 justify-center">
        <%= for item <- @selected_items do %>
          <div
            phx-click="deselect-item"
            phx-target={@myself}
            phx-value-item={item}
          >
            <%= item %>
          </div>
        <% end %>
      </div>
    </div>
    """
  end

  @impl true
  def update(%{search: value}, socket) do
    # do search
    parent_items = ["result 1", "result 2"]
    {:ok, assign(socket, :parent_items, parent_items)}
  end

  @impl true
  def update(%{item_selected: item}, socket) do
    socket =
      socket
      |> assign(:selected_items, socket.assigns.selected_items ++ [item])
      |> assign(:parent_items, socket.assigns.parent_items -- [item])

    {:ok, socket}
  end

  @impl true
  def update(_, socket), do: {:ok, socket}

  @impl true
  def handle_event("deselect-item", %{"item" => item}, socket) do
    socket =
      socket
      |> assign(:selected_items, socket.assigns.selected_items -- [item])
      |> assign(:parent_items, socket.assigns.parent_items ++ [item])

    {:noreply, socket}
  end

Child (search) component:

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <input
        autocomplete="off"
        phx-keyup="search"
        phx-target={@myself}
      />
      <div>
        <%= for item <- @items do %>
          <div
            phx-click="select-item"
            phx-value-item={item}
            phx-target={@myself}
          >
            <%= item %>
          </div>
        <% end %>
      </div>
    </div>
    """
  end

  @impl true
  def handle_event("select-item", %{"item" => item}, socket) do
    socket.assigns.on_select.(item)
    {:noreply, socket}
  end

  @impl true
  def handle_event("search", %{"value" => value}, socket) do
    socket.assigns.on_search.(value)
    {:noreply, socket}
  end

I also noticed some of the core components seem to be able to use this paradigm in stateless components. For example, the table accepts a row_click callback

attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"

but I wasn’t able to get that to work with my search component.

2 Likes