Building Filter UI in LiveView

TL;DR

Client-side filtering in LiveView: load all data on mount, store in assigns, filter with Enum.filter/2 on events. No database round-trips, instant feedback.

The Problem

You have a list of items - projects, articles, products. You want filter buttons that instantly update the list. Click “Open Source” and only open source projects show.

Two approaches:

  • Server-side filtering: Query database on each filter change
  • Client-side filtering: Load all data once, filter in memory

For small datasets (under ~1000 items), client-side wins. No latency, no round-trips.

The Pattern

def mount(_params, _session, socket) do
projects = Content.list_projects()
{:ok,
socket
|> assign(all_projects: projects, filter: :all)
|> apply_filter(:all)}
end
def handle_event("filter", %{"category" => category}, socket) do
filter = String.to_existing_atom(category)
{:noreply, apply_filter(socket, filter)}
end
defp apply_filter(socket, filter) do
projects = socket.assigns.all_projects
filtered =
case filter do
:all -> projects
category -> Enum.filter(projects, &(&1.category == category))
end
assign(socket, filter: filter, filtered_projects: filtered)
end

Key Points

  1. Store original data: all_projects never changes after mount
  2. Derive filtered view: filtered_projects is computed from all_projects
  3. Store current filter: Track which filter is active for UI state

The Template

<div class="filter-bar">
<button
phx-click="filter"
phx-value-category="all"
class={"filter-pill #{if @filter == :all, do: "filter-pill--active"}"}
>
All
</button>
<button
phx-click="filter"
phx-value-category="work"
class={"filter-pill #{if @filter == :work, do: "filter-pill--active"}"}
>
Work
</button>
<button
phx-click="filter"
phx-value-category="open_source"
class={"filter-pill #{if @filter == :open_source, do: "filter-pill--active"}"}
>
Open Source
</button>
</div>
<div class="project-grid">
<%= for project <- @filtered_projects do %>
<.project_card project={project} />
<% end %>
</div>

Event Binding

phx-click="filter" sends the event name. phx-value-category="work" adds the category to the event params.

The handler receives:

%{"category" => "work"}

Active State

The class toggle:

class={"filter-pill #{if @filter == :work, do: "filter-pill--active"}"}

Conditionally adds filter-pill--active when this filter is selected.

Excluding Hero Items

Common pattern: show a featured item separately from the grid. Don’t duplicate it.

def mount(_params, _session, socket) do
projects = Content.list_projects()
{featured, _rest} = Enum.split_with(projects, & &1.featured)
hero = List.first(featured)
{:ok,
socket
|> assign(all_projects: projects, hero_project: hero, filter: :all)
|> apply_filter(:all)}
end
defp apply_filter(socket, filter) do
projects = socket.assigns.all_projects
hero = socket.assigns.hero_project
hero_id = if hero, do: hero.id, else: nil
filtered =
case filter do
:all -> projects
category -> Enum.filter(projects, &(&1.category == category))
end
# Remove hero from grid
grid_projects = Enum.reject(filtered, &(&1.id == hero_id))
assign(socket, filter: filter, grid_projects: grid_projects)
end

The hero stays constant. The grid excludes it but still respects the active filter.

Live Stats

Show counts that update with the filter:

<div class="filter-stats">
<span class="stat">
<span class="stat__value">{length(@grid_projects)}</span>
<span class="stat__label">showing</span>
</span>
<span class="stat">
<span class="stat__value">
{Enum.count(@grid_projects, &(&1.status == :active))}
</span>
<span class="stat__label">active</span>
</span>
</div>

Since @grid_projects updates on filter change, the counts update automatically.

Multi-Filter Extension

Need multiple filters (category AND status)?

def mount(_params, _session, socket) do
{:ok,
socket
|> assign(
all_projects: projects,
filter_category: :all,
filter_status: :all
)
|> apply_filters()}
end
def handle_event("filter_category", %{"value" => category}, socket) do
{:noreply,
socket
|> assign(filter_category: String.to_existing_atom(category))
|> apply_filters()}
end
def handle_event("filter_status", %{"value" => status}, socket) do
{:noreply,
socket
|> assign(filter_status: String.to_existing_atom(status))
|> apply_filters()}
end
defp apply_filters(socket) do
projects = socket.assigns.all_projects
filtered =
projects
|> filter_by_category(socket.assigns.filter_category)
|> filter_by_status(socket.assigns.filter_status)
assign(socket, filtered_projects: filtered)
end
defp filter_by_category(projects, :all), do: projects
defp filter_by_category(projects, category) do
Enum.filter(projects, &(&1.category == category))
end
defp filter_by_status(projects, :all), do: projects
defp filter_by_status(projects, status) do
Enum.filter(projects, &(&1.status == status))
end

Chain the filters. Each one narrows the previous result.

When to Use Server-Side Filtering

Client-side filtering breaks down when:

  • Large datasets: 1000+ items means slow initial load
  • Complex queries: Full-text search, joins, aggregations
  • Paginated results: Can’t filter what you haven’t loaded

For those cases, use Flop or build custom Ecto queries that run on each filter change.

References