Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unclear documentation for code organization (such as extracting related handle_events to a new module) #3679

Closed
ericridgeway opened this issue Feb 16, 2025 · 7 comments · Fixed by #3685

Comments

@ericridgeway
Copy link
Contributor

ericridgeway commented Feb 16, 2025

This line of the Phoenix.LiveComponent doc correctly says

Avoid using LiveComponents for code design purposes, where their main goal is to organize code

But it gives no explanation of how to do so.

  • In .heex files, repetition and related functionality can be extracted to other modules as a FunctionComponent

  • But often those FunctionComponents bind to handle_events in the parent we just extracted them from

    • Frequently we ALSO want to extract those handle_event's to the new module
  • In most tutorial's I've seen, that's done by unnecessarily making a LiveComponent

    • Which, yes, organizes the render/heex AND it's related handle_events into a single file

    • BUT as the quote above warns, this should be avoided when we're only doing it to organize code.

      • The LiveComponent we just made does not need it's own state. All state is used and owned by the parent LiveView.

      • We were just looking for a way to group function components & related handle_events

      • Without stealing management of @ assigns from the parent who actually uses them

Here's an example of using a LiveComponent to organize:

  • It DOES keep the render/heex and related handle_event's in a single file

  • BUT it doesn't actually want to manage state (the @ sorting assign). It just sends state updates straight to the parent LiveView with send(self(), {:update, opts})

  • That parent LiveView is the real owner of the state. The parent actually SETS those assigns from url params, and actually USES those assigns to make calls to the db, etc.


So, how can we delegate related handle_events out of the parent LiveView and into other modules? How should we organize code according to the above quote without resorting to a LiveComponent when the additional state of a LiveComponent is unnecessary?

  defdelegate handle_event("sort_by_key", params, socket), to: SortingComponent
  defdelegate handle_event("search", params, socket), to: FilterComponent
  # etc.

^ This seems like a possibility, except we can't use pattern matching in defdelegate

  @impl true
  def handle_event("sort_by_key", params, socket), do: SortingComponent.handle_event("sort_by_key", params, socket)

^ This works, but is that the best we can do? Repeating ("sort_by_key", params, socket) like that is so ugly. It seems like the exact reason defdelegate exists in the first place

Another possibility I saw from this answer. It looks like LiveBook is using attach_hook to group extracted function components and their related handle_events into a Function Component module (NOT a Live Component)

  • But it's very hard for me to understand how the attach_hook is achieving this, and I can't find examples in the doc

Should we be doing this handle_event organization with defdelegate somehow? Must we use attach_hook instead? And if so, is it possible to add a short example of doing so to the doc? <3

@ericridgeway
Copy link
Contributor Author

Basically, there are 3 situations when we extract/organize code in LiveViews:

  1. Extract html
  2. Extract html and related handle_events
  3. Extract html, handle_events, and create additional separate state

1 is covered by FunctionComponents. 3 is covered by LiveComponents.

But a seamless or doc-recommended way for handling 2 seems to be missing

@Gazler
Copy link
Member

Gazler commented Feb 19, 2025

This does feel like the sort of place https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#attach_hook/4 shines.

For the livebook example, the hook is attached on mount, which is called from the router https://github.com/livebook-dev/livebook/blob/2796d5f1d76841e54fb99a765d457b4c34b74199/lib/livebook_web/router.ex#L62

A pattern I often use, is to attach the hooks explicitly when mounting the LiveView. Something like this:

Mix.install([
  {:phoenix_playground, "~> 0.1.6"},
  {:phoenix_live_view, "~> 1.0.0"}
])

defmodule MySortComponent do
  use Phoenix.Component
  import Phoenix.LiveView, only: [attach_hook: 4]

  def enable_sorting(socket) do
    attach_hook(socket, :sort, :handle_event, fn
      "sort", %{"list" => key}, socket ->
        key = String.to_existing_atom(key)
        sorted = Enum.sort(socket.assigns[key])
        {:halt, assign(socket, key, sorted)}

      _, _, socket ->
        {:cont, socket}
    end)
  end
end

defmodule DemoLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    first_list = for(i <- 1..9, do: "First List #{i}") |> Enum.shuffle()
    second_list = for(i <- 1..9, do: "Second List #{i}") |> Enum.shuffle()
    socket = assign(socket, first_list: first_list, second_list: second_list)
    {:ok, MySortComponent.enable_sorting(socket)}
  end

  def render(assigns) do
    ~H"""
    <div>
      <div :for={{key, list} <- [first_list: @first_list, second_list: @second_list]}>
        <ul><li :for={item <- list}>{item}</li></ul>
        <button phx-click="shuffle" phx-value-list={key}>Shuffle</button>
        <button phx-click="sort" phx-value-list={key}>Sort</button>
      </div>
    </div>
    """
  end

  def handle_event("shuffle", %{"list" => key}, socket) do
    key = String.to_existing_atom(key)
    shuffled = Enum.shuffle(socket.assigns[key])
    {:noreply, assign(socket, key, shuffled)}
  end
end

PhoenixPlayground.start(live: DemoLive, port: 4200)

@SteffenDE
Copy link
Collaborator

I agree with @Gazler that attach_hook is the way to go here.

@ericridgeway do you want to adjust your PR to mention attach_hook instead?

@ericridgeway
Copy link
Contributor Author

@Gazler Wow that was a good explanation. The example code helped wrap my mind around how attach_hook works here
Thank you!

re. attach_hook for organizing event handling, do we mind that:

  • The extracted code needs to change it's return value from {:noreply, socket} to {:halt, socket}
  • AND requires the destination module add the extra _, _, socket -> {:cont, socket} clause

This definitely works, but I worry it's a bit less readable/easy-to-understand, and it almost feels like we're overly bending the use-case for hooks. I thought they were more for escape-hatch'ing to extra JavaScript functionality

@Gazler
Copy link
Member

Gazler commented Feb 19, 2025 via email

@ericridgeway
Copy link
Contributor Author

Alright, if attach_hook is still the preferred way, no worries! I will update the PR to mention it instead

I wish defdelegate still let us pattern match, or that we had something similar here

I suppose regular old functions are also always a fallback, even with the annoying repetition of the function parameters

def handle_event("sort_by_key", params, socket), do: SortingComponent.handle_event("sort_by_key", params, socket)

@brandon-vaughan
Copy link

brandon-vaughan commented Feb 20, 2025

Clever use of attach_hook.

I have been testing out moving handlers to modules but keeping the module handlers a little more focused so say rather than

def handle_event("sort_by_key", params, socket), do: SortingComponent.handle_event("sort_by_key", params, socket)
def handle_event("sort_by_key", params, socket), do: SortingComponent.sort_by_key(params, socket)

Then I have also been trying out unit testing those handlers with mocked sockets so I don't have to spin up an entire LV in a test to confirm the handler does what I expect. Still early and agree I would love a way to pattern match on defdelegate but so far been working pretty well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants