DEV Community

Cover image for Phoenix LiveComponent Provider Pattern
Mykolas Mankevicius
Mykolas Mankevicius

Posted on

Phoenix LiveComponent Provider Pattern

If you've worked with React, you've probably used the Provider pattern. It's a component that wraps children and provides them with data via context.
In Phoenix LiveView, we can achieve something similar using LiveComponent's :let directive combined with assign_async.

The Problem

I had a product detail page that needed to show similar products. The naive approach would be to load them in the parent LiveView's mount:

def mount(params, _session, socket) do
  product = get_product!(params)

  # Blocking the mount with an async search operation 🙈
  {:ok, similar_products} = Search.similar_products(product.id, limit: 12)

  socket
  |> assign(:product, product)
  |> assign(:similar_products, similar_products)
  |> ok()
end
Enter fullscreen mode Exit fullscreen mode

Two problems here:

  1. Blocking mount - the search delays the entire page render
  2. Scattered concerns - similar products logic lives in the parent LiveView

The Provider Pattern

Instead, I created a "provider" component that handles its own data loading and exposes the result to its children via :let.

The Provider (LiveComponent):

defmodule MarkoWeb.Components.SimilarProducts.Provider do
  use MarkoWeb, :live_component

  @impl LiveComponent
  def update(assigns, socket) do
    {%{product_id: product_id, limit: limit}, assigns} =
      Map.split(assigns, [:product_id, :limit])

    socket
    |> assign(assigns)
    |> assign_async(:search, fn ->
      {:ok, result} = Search.similar_products(product_id, limit: limit)
      {:ok, %{search: result}}
    end)
    |> ok()
  end

  @impl LiveComponent
  def render(assigns) do
    ~H"""
    <div id={@id}>
      <div :if={@search.loading}>
        <%= if @loading == [] do %>
          <.loading />
        <% else %>
          {render_slot(@loading)}
        <% end %>
      </div>

      <div :if={@search.ok? && Enum.any?(@search.result.hits)}>
        {render_slot(@inner_block, %{items: @search.result.hits, query: @search.result.query})}
      </div>

      <div :if={@search.ok? && Enum.empty?(@search.result.hits)}>
        <%= if @no_results == [] do %>
          <.no_results />
        <% else %>
          {render_slot(@no_results)}
        <% end %>
      </div>
    </div>
    """
  end

  defp loading(assigns) do
    ~H"""
    <Loading.spinner />
    """
  end

  defp no_results(assigns) do
    ~H"""
    <p>No results</p>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

The key line is {render_slot(@inner_block, %{items: @search.result.hits, query: @search.result.query})}.

By passing a map, you control exactly what the provider exposes. Need to add more data later? Just add another key.

The Component Module:

defmodule MarkoWeb.Components.SimilarProducts do
  use MarkoWeb, :component

  # === Convenience components ===

  attr :product, Product, required: true
  attr :limit, :integer, required: true
  slot :loading
  slot :no_results

  def grid(assigns) do
    ~H"""
    <.provider :let={context} product={@product} limit={@limit}>
      <:loading :for={slot <- @loading}>{render_slot(slot)}</:loading>
      <:no_results :for={slot <- @no_results}>{render_slot(slot)}</:no_results>
      <.grid_inner {context} />
    </.provider>
    """
  end

  # === Provider (for custom rendering) ===

  attr :id, :any, default: nil
  attr :product, Product, required: true
  attr :limit, :integer, required: true
  slot :inner_block, required: true
  slot :loading
  slot :no_results

  def provider(assigns) do
    ~H"""
    <.live_component
      :let={context}
      module={__MODULE__.Provider}
      id={@id || id(:similar_products_provider, @product.id)}
      product_id={@product.id}
      limit={@limit}
      loading={@loading}
      no_results={@no_results}
    >
      {render_slot(@inner_block, context)}
    </.live_component>
    """
  end

  # === Inner presentation ===

  defp grid_inner(assigns) do
    ~H"""
    <ul class="grid grid-cols-2 gap-4 sm:grid-cols-4">
      <li :for={item <- @items}><ProductUI.card product={item} /></li>
    </ul>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

Usage

For most cases, you don't even need to know about the provider:

# Grid layout - just use it
<SimilarProducts.grid product={@product} limit={12} />

# Custom loading/empty states - still simple
<SimilarProducts.grid product={@product} limit={12}>
  <:loading><ProductUI.skeleton_grid count={12} /></:loading>
  <:no_results><EmptyState.explore_more /></:no_results>
</SimilarProducts.grid>

# Custom rendering? Reach for the provider
<SimilarProducts.provider :let={%{items: items, query: query}} product={@product} limit={4}>
  <SimilarProducts.grid items={items} />
  <a href={~p"/search?#{query}"}>See all results</a>
</SimilarProducts.provider>
Enter fullscreen mode Exit fullscreen mode

Why This Works

  1. Simple things are simple - <SimilarProducts.grid product={@product} limit={12} />
  2. Complex things are possible - provider exposes full visual control when needed
  3. Self-contained async - the provider owns its data fetching
  4. Extensible - need more data? Add a key to the context map

This is essentially React's Context Provider pattern, but in LiveView. The product detail page went from managing similar products loading to simply declaring where they should appear. Much cleaner.


This post was written with the help of AI, but I curate and review everything thoroughly. The ideas, code, and opinions are all mine.

Top comments (0)