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

Feature request: live component unmount callback #2454

Closed
AHBruns opened this issue Feb 14, 2023 · 7 comments
Closed

Feature request: live component unmount callback #2454

AHBruns opened this issue Feb 14, 2023 · 7 comments

Comments

@AHBruns
Copy link
Contributor

AHBruns commented Feb 14, 2023

So, this feature was proposed in 2020, and shot down due to a perceived lack of need, but I think I have a good use case.

I want a live list of accounts. This list should update in realtime as accounts are created, deleted, or updated. To implement this, I will need to have my live view subscribe to account creation, deletion, and update pub sub events, load the initial accounts list, and render that list. Because rendering of this list might be complex, I'll use a functional component. The issue comes about when I have a very large live view, like an admin dashboard, and somewhere deep in this live view I conditionally render this accounts list functional component from another component which might in turn be conditionally rendered.

Ideally, the live view should only need to subscribe to account updates and load accounts when it is actually showing accounts. However because the logic for when it's show accounts is encapsulated into one or more live and functional components, this is non-trivial to do. In fact, the only way for the live view to do this would be reimplement all that rendering logic in itself and use it to inform whether or not accounts are being shown. This is, of course, not maintainable.

In practice, most live views simply load and subscribe to everything they might need up front. For large live views that may rarely need very high cost resources this is not an option.

My proposal is to add an unmount callback to live components. This would solve this problem by allowing live components to send their live view a message on mount and on unmount. With this the burden of knowing if a certain piece of data was currently needed or not could be flipped. Instead of a live view having to know when data was needed, live components could tell their live view when they needed data (on mount) and when they no longer needed data (on unmount). This would allow live views to render arbitrarily complex UIs without having to sacrifice on either performance (over loading/subscribing) or maintainability (reimplementing logic in the live view to dynamically determine what needs to be loaded).

@AHBruns
Copy link
Contributor Author

AHBruns commented Feb 14, 2023

Something to keep in mind is that this in no way implies the live component is a process. The live component in my example is not "subscribing and unsubscribing to accounts" it is declaring when some bit of data (accounts) is or isn't currently being used.

@AHBruns
Copy link
Contributor Author

AHBruns commented Feb 14, 2023

To get more concrete, this callback would allow this pattern:

defmodule WithAccounts do
  use Phoenix.LiveComponent

  @impl true
  def render(assigns) do
    ~H"""
    <div>
    <%= render_slot(@inner_block, @accounts) %>
    </div>
    """
  end

  @impl true
  def mount(socket) do
    send(self(), {:started_using, :accounts})
    {:ok, socket}
  end

  @impl true
  def unmount(socket) do
    send(self(), {:stopped_using, :accounts})
    {:ok, socket}
  end

  @impl true
  def update(assigns, socket) do
    {:ok,
      socket
      |> assign(assigns)
      |> update(:accounts, fn
        :unloaded -> Accounts.all()
        accounts -> accounts
      end)}
  end
end

defmodule Dashboard do
  use Phoenix.LiveComponent

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <button phx-click="toggle_accounts" phx-target={@myself}>show/hide accounts</button>
      <.live_component :if={@showing_accounts} :let={accounts} module={WithAccounts} id="..." accounts={@accounts}>
        <p :for={account <- accounts}><%= account.email %></p>
      </.live_component>
    </div>
    """
  end

  @impl true
  def mount(socket) do
    {:ok, assign(socket, :showing_accounts, false)}
  end

  @impl true
  def handle_event("toggle_accounts", _, socket) do
    {:ok, update(socket, :showing_accounts, fn value -> !value end)}
  end
end

defmodule MyLiveView do
  use Phoenix.LiveView

  @impl true
  def render(assigns) do
     ~H"""
    <.live_component module={Dashboard} id="..." accounts={@accounts} />
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, accounts: :unloaded, accounts_use_count: 0)}
  end

  @impl true
  def handle_info({:started_using, :accounts}, socket) do
    socket =
      if socket.assigns.accounts_use_count == 0 do
        Phoenix.PubSub.subscribe(:my_pub_sub, "accounts")
        assign(socket, :accounts, Accounts.all())
      else
        socket
      end

    {:ok, update(socket, :accounts_use_count, fn count -> count + 1 end)
  end
  
  def handle_info({:stopped_using, :accounts}, socket) do
    socket =
      if socket.assigns.accounts_use_count == 1 do
        Phoenix.PubSub.unsubscribe(:my_pub_sub, "accounts")
        assign(socket, :accounts, :unloaded)
      else
        socket
      end

    {:ok, update(socket, :accounts_use_count, fn count -> count - 1 end)
  end

  def handle_info({"accounts", _some_event}, socket) do
    {:ok,
     update(socket, :accounts, fn
       :unloaded -> :unloaded
       _ -> Accounts.all()
     end)
  end
end

I'm sure there's a good deal of typos, but the general idea here is:

  • if the user never toggles the accounts to being shown, the accounts will never be loaded
  • if the user toggles the accounts to being shown then the accounts will be be loaded right away but will be "dead". Then, later, the live view will "realize" it is now showing accounts, and will load accounts and subscribe to account updates. At this point the accounts will becomes "live".
  • finally, if at any point the user toggles the accounts to being hidden, the live view will eventually realize and unsubscribe from account updates + clear the accounts from its assigns.

A neat advantage of this approach is that if the WithAccounts live component could be used without anything knowing it "needed" accounts. In this case, it would just provide a dead accounts list to its inner block. This graceful fallback is nice as it allows consumers to first implement the their view with dead data then slowly make pieces live as needed.

@josevalim
Copy link
Member

Yeah, I am a bit torn on this issue.

The only reason why we want to message the parent is for it to subscribe to events. We could, however, skip this if we allowed the component to subscribe to those directly but, because live components are not processes, resource management in them is tricky since, as you note, you have to both subscribe and unsubscribe to events.

Furthermore, both in your solution and the one I mention above, if you have multiple components interested in those events, you can end-up accidentally subscribing to the same event multiple times. And I also have concerns about promoting data loading downstream (in components) because it is an easy way to get N+1 issues in the future.

If I were solving this problem, I would make it so toggle_account sends a message to the parent that also keeps a @toggled_accounts assign. Untoggling also goes through the parent. This way you don't need mount/unmount and you keep all data loading and subscription on the parent. The just released streams should also make it easier to handle dynamic data loading.

@AHBruns
Copy link
Contributor Author

AHBruns commented Feb 14, 2023

The issue with "promoting" loading to the parent is that, that parent could itself be dynamically rendered, and that parent's parent, etc. eventually you end up with the original "solution" of replicating all that dynamic rendering logic as dynamic loading/subscribing logic in the live view itself.

As for N+1s, I agree this could lead to them if misused, but that's more or less a solved problem through the use of data loaders.

Streams also, afaict, only provide a way to defer data loading until use. They don't solve the problem of knowing when to start/stop subscribing to events efficiently.

I should clarify, I'm not really promoting this as a pattern that regular app code should use, but I think using this pattern in library code will be very powerful.

I could imagine a world where live views become generic "live data" loaders, and views simply declare what live data they need. All live views on a node could share a live data cache, and views could be moved between live views without changes because all live views would know how to load the same data. Views would be portable and live views would be efficient.

I don't want to confuse or conflate this feature with that theoretical future, but this feature is certainly necessary for a future like that, or any other in which we need a way to know what's being shown on the screen.

@AHBruns
Copy link
Contributor Author

AHBruns commented Mar 13, 2023

Hey, @josevalim, I just wanted to see if you'd given this anymore thought. I'd be happy to work on a PR if it will be accepted.

@josevalim
Copy link
Member

I have given more thought but I didn't reach a conclusion yet. I think we will need another pair of eyes from other maintainers. :)

@josevalim
Copy link
Member

Closing this. It adds complexity, has complicate trade-offs, and it doesn't seem to be a common need. :)

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

No branches or pull requests

2 participants