Phoenix LiveComponent Provider Pattern
Source: Dev.to

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
Two problems here:
- Blocking mount – the search delays the entire page render.
- 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"""
{render_slot(@loading)}
{render_slot(@inner_block, %{items: @search.result.hits, query: @search.result.query})}
{render_slot(@no_results)}
"""
end
defp loading(assigns) do
~H"""
"""
end
defp no_results(assigns) do
~H"""
No results
"""
end
end
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"""
{render_slot(slot)}
{render_slot(slot)}
"""
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"""
{render_slot(@inner_block, context)}
"""
end
# === Inner presentation ===
defp grid_inner(assigns) do
~H"""
-
"""
end
end
Usage
For most cases, you don’t even need to know about the provider:
# Grid layout – just use it
Custom loading/empty states – still simple:
# (Insert custom loading / empty state markup here)
Custom rendering? Reach for the provider:
# (Insert custom rendering markup here)
# Example call inside a template
<.similar_products.provider
product={@product}
limit={12}
loading={...}
no_results={...}>
<:inner_block let={%{items: items, query: query}}>
<!-- custom rendering using items and query -->
</:inner_block>
</.similar_products.provider>
Why This Works
- Simple things are simple – the default grid works out‑of‑the‑box.
- Complex things are possible – the provider exposes full visual control when needed.
- Self‑contained async – the provider owns its data fetching.
- Extensible – need more data? Add a key to the context map.
This is essentially React’s Context Provider pattern, but in Phoenix LiveView.
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.
