Hard time wrapping head around live view, "normal" components, and state... am I holding it wrong?

Hi Everyone,

I’m currently starting my Elixir and Phoenix journey and have a hard time wrapping my head around Phoenix Components and Live View.

When reading the tutorials, everything sounds fine and dandy… but then the real world hits you in your face.

As I understand it, it is encouraged to use “normal” Phoenix Components over Live View Components and call the “normal” Components from your live views. So far, so good.

I’m currently struggling to implement a multiselect autocomplete component like one that can be used to select several from a list of tags and filter the options by typing.

In a classical FE framework, I would have some sort of state in the component that tracks the selected elements and is able to filter them, and is also responsible for rendering everything. So everything is self-contained.

In Phoenix, I try to implement this using a “normal“ Component. Where I pass options, and selected (to be able to prepopulate for edit-operations) lists.

I write the initial markup in the component, and then I use a hook to do the dynamic part. But now I feel that I need to do stupid things in the hook that seem wrong. Like manipulating markup instead of the actual state.

Like filtering down the

  • list of options when typing in the input field. I have to directly filter the markup because the markup is defined in the Phoenix Component, and I don’t want the markup in the hook.

    How are things like that done “correctly“ in Phoenix Components / Live View?

    I have the feeling that when everything is JS/TS and everything is in one place, it is more straightforward. Maybe I’m also just more used to it. So again, how do you manage state between Components and Hooks and such in the correct way?

    Any help would be highly appreciated.

    Thanks a lot
    sulo

  • The short answer is that a multi-select like you describe is a legit use for a LiveComponent. Here’s an example of a library that implements one. Perhaps it’s time that that line in the documentation got some added clarification. I believe it was initially added because people were wanting to make everything a live a component, like even buttons and whatnot.

    2 Likes

    Regarding where to store state: only in one place. Sometimes it is best on the server, sometimes on the client. It depends on the situation.

    1 Like

    Thanks a lot for your answer and the link. But just out of curiosity… what would be the right way if you would like to keep the state on the client until you are ready to send the form?
    So that not every selection of a tag is immediately sent to the server, because I think that in this case it is not required.

    You can use a Hook and phx-submit. Do everything in the hook and then finally submit the form using phx-submit.

    This is the curse of guidelines in general. It feels like for every person you steer onto the right path you end up steering someone else off of it.

    For the record, the correct solution to this particular problem is to fix it in the API layer by removing the (false) dichotomy between stateful and stateless components. This is what React did with hooks.

    (Not that React is the Paragon of Not Having Guidelines, or anything…)

    I’m hesitant to add more heuristics to this conversation because they always have edge cases, but:

    LiveView is a stateful, server-side framework. Generally speaking you should default to keeping state on the server. The more state you try to keep on the client, the harder everything will get. (Again: there are always exceptions to heuristics like this.)

    If you are working on an application where it feels like keeping state on the server is some sort of burden, that’s a good clue that the app is a bad fit for LiveView and should be built with a client framework (React, Vue, etc). But many applications are actually quite happy with their state on the server!

    3 Likes

    This doesnt solve directly the same as your problem because it doesnt have filtering, but does handle many-to-many selects.
    This is in my core_components.ex

      @doc """
      Renders a multi-select dropdown with checkboxes.
    
      ## Examples
    
          <.multi_select
            id="team-select"
            name="ticket[team_ids]"
            options={@teams}
            selected={@selected_team_ids}
            label="Teams"
            label_field={:name}
            value_field={:id}
          />
      """
      attr :id, :string, required: true
      attr :name, :string, required: true
      attr :label, :string, default: nil
      attr :options, :list, required: true
      attr :selected, :list, default: []
      attr :label_field, :atom, default: :name
      attr :value_field, :atom, default: :id
      attr :placeholder, :string, default: "Select options..."
      attr :errors, :list, default: []
    
      def multi_select(assigns) do
        # Ensure selected is always a list and filter out empty strings
        selected =
          assigns.selected
          |> List.wrap()
          |> Enum.reject(&(&1 == "" || &1 == nil))
    
        assigns = assign(assigns, :selected, selected)
    
        ~H"""
        <div class="fieldset mb-2">
          <label>
            <span :if={@label} class="label mb-1">{@label}</span>
            <div class="relative">
              <%!-- Hidden inputs for form submission --%>
              <input type="hidden" name={@name} value="" />
              <div :for={value <- @selected} style="display: none;">
                <input type="hidden" name={@name <> "[]"} value={value} />
              </div>
    
              <div class="relative">
                <div
                  role="button"
                  class="select select-bordered w-full text-left cursor-pointer transition-all duration-300 ease-in-out"
                  phx-click={
                    JS.toggle(to: "##{@id}-dropdown", in: "fade-in-scale", out: "fade-out-scale")
                  }
                >
                  {if length(@selected) == 0 do
                    @placeholder
                  else
                    "#{length(@selected)} selected"
                  end}
                </div>
                <div
                  class="hidden absolute z-10 bg-base-100 rounded-box shadow-lg w-full max-h-60 overflow-auto p-2 mt-1"
                  id={"#{@id}-dropdown"}
                  phx-click-away={JS.hide(to: "##{@id}-dropdown", transition: "fade-out-scale")}
                >
                  <div
                    :for={option <- @options}
                    class="flex items-center p-2 hover:bg-base-200 cursor-pointer rounded"
                    phx-click="toggle_multiselect_item"
                    phx-value-id={@id}
                    phx-value-name={@name}
                    phx-value-value={Map.get(option, @value_field)}
                  >
                    <div class="w-5 h-5 border-2 border-base-300 rounded flex items-center justify-center mr-3">
                      <.icon
                        :if={
                          to_string(Map.get(option, @value_field)) in Enum.map(@selected, &to_string/1)
                        }
                        name="hero-check"
                        class="size-4 text-primary"
                      />
                    </div>
                    <span class="select-none">{Map.get(option, @label_field)}</span>
                  </div>
                </div>
              </div>
            </div>
          </label>
          <.error :for={msg <- @errors}>{msg}</.error>
        </div>
        """
      end
    

    and this is a helper, which helps me make it generic over any and all relationships instead of writing them one by one, the must important part is the handle_multiselect_toggle, the last func handle_form_name isn’t really needed for the multiselect.

    defmodule JnbWeb.Shared.MultiSelectHelpers do
      @moduledoc """
      Helper functions for LiveViews that use multi-select fields.
    
      ## Usage
    
      In your LiveView:
    
       use JnbWeb, :live_view
       import JnbWeb.Shared.MultiSelectHelpers      
      Then use the helper functions in your event handlers.
      """
    
      import Phoenix.Component, only: [assign: 2, to_form: 1]
      import Phoenix.LiveView, only: [put_flash: 3, push_navigate: 2]
    
      @doc """
      Safely gets selected values from a form, handling nil data gracefully.
    
      ## Parameters
        - form: The form changeset
        - field: The field name as an atom or string
    
      ## Example
          get_selected_values(@form, :team_ids)
      """
      def get_selected_values(form, field) when is_atom(field) do
        get_selected_values(form, to_string(field))
      end
    
      def get_selected_values(form, field) when is_binary(field) do
        params_value = form.params[field]
        data_value = if form.data, do: Map.get(form.data, String.to_atom(field))
    
        List.wrap(params_value || data_value || [])
      end
    
      @doc """
      Handles the toggle_multiselect_item event for multi-select fields.
    
      ## Parameters
        - socket: The LiveView socket
        - field_name: The full field name (e.g., "team[ticket_ids]")
        - value: The value to toggle
        - resource_type: The resource type as a string (e.g., "team")
      """
      def handle_multiselect_toggle(socket, field_name, value, resource_type) do
        # Extract field name from "resource[field]" format
        case String.split(field_name, "[", parts: 2) do
          [^resource_type, field_with_bracket] ->
            field = String.trim_trailing(field_with_bracket, "]")
    
            current_form = socket.assigns.form.source
    
            current_values =
              (socket.assigns.form.params[field] ||
                 (current_form.data && Map.get(current_form.data, String.to_atom(field))) || [])
              |> List.wrap()
              |> Enum.reject(&(&1 == "" || &1 == nil))
    
            new_values =
              if value in Enum.map(current_values, &to_string/1) do
                Enum.reject(current_values, &(to_string(&1) == value))
              else
                current_values ++ [value]
              end
    
            # Create complete params including all form fields
            all_params = Map.merge(socket.assigns.form.params, %{field => new_values})
    
            # Validate with complete params
            new_form = AshPhoenix.Form.validate(current_form, all_params)
    
            assign(socket, form: to_form(new_form))
    
          _ ->
            # Field name doesn't match expected format, return socket unchanged
            socket
        end
      end
    
      @doc """
      Prepares parameters for forms with multi-select fields.
    
      ## Parameters
        - params: The form parameters
        - multi_select_fields: List of field names that are multi-selects (as atoms)
    
      ## Example
          prepare_multiselect_params(params, [:ticket_ids, :user_ids])
      """
      def prepare_multiselect_params(params, multi_select_fields) do
        Enum.reduce(multi_select_fields, params, fn field, acc ->
          field_str = to_string(field)
    
          value =
            case acc[field_str] do
              "" -> []
              nil -> []
              ids -> List.wrap(ids)
            end
    
          Map.put(acc, field_str, value)
        end)
      end
    
      @doc """
      Common save handler for forms with standard success/error handling.
    
      ## Parameters
        - socket: The LiveView socket
        - form: The form changeset/struct
        - params: The submitted parameters
        - opts: Options including:
          - :resource_name - Name of the resource for flash messages
          - :return_path_fn - Function to generate return path
      """
      def handle_form_save(socket, form, params, opts \\ []) do
        resource_name = Keyword.get(opts, :resource_name, "Resource")
        return_path_fn = Keyword.get(opts, :return_path_fn, fn _, _ -> "/" end)
    
        case AshPhoenix.Form.submit(form, params: params) do
          {:ok, resource} ->
            # Send notification if the module defines notify_parent
            if function_exported?(socket.view, :notify_parent, 1) do
              apply(socket.view, :notify_parent, [{:saved, resource}])
            end
    
            socket
            |> put_flash(:info, "#{resource_name} #{form.source.type}d successfully")
            |> push_navigate(to: return_path_fn.(socket.assigns.return_to, resource))
    
          {:error, form} ->
            assign(socket, form: form)
        end
      end
    end
    
    

    then you would just call it like this

        <.multi_select
          id="team-tickets"
          name="team[ticket_ids]"
          options={@tickets}
          selected={get_selected_values(@form, :ticket_ids)}
          label="Tickets"
          label_field={:title}
          value_field={:id}
          placeholder="Select tickets..."
        />
    

    and then use the functions like this

      def handle_event("validate", %{"team" => team_params}, socket) do
        prepared_params = prepare_multiselect_params(team_params, [:ticket_ids])
    
        {:noreply,
         assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, prepared_params))}
      end
    
      def handle_event("toggle_multiselect_item", %{"name" => name, "value" => value}, socket) do
        {:noreply, handle_multiselect_toggle(socket, name, value, "team")}
      end
    
      def handle_event("save", %{"team" => team_params}, socket) do
        prepared_params = prepare_multiselect_params(team_params, [:ticket_ids])
    
        socket =
          handle_form_save(socket, socket.assigns.form, prepared_params,
            resource_name: "Team",
            return_path_fn: &return_path/2
          )
    
        {:noreply, socket}
      end
    
    

    The downside of this approach is each toggle is a callback to the server, which in something more FE focussed you wouldnt need, after all the options you can select are already pre-rendered, but I think it might help you, also this is liveview, most of the state should live on the server, otherwise maybe liveview is a bad fit. About doing full trips to the server I wouldn’t worry too much about it, I have my app in a VPS server in Germany and I am in Central America, there’s very little latency I would say

    1 Like