Data table
Sorting, filtering, search, pagination and selection from one State struct. A free in-memory engine runs it over a plain list, or run the same state against your own query layer.
Replaces the Flop data table
How it works
One DataTable.State
struct holds the whole query: order, filters, search term, page and page size. You hand that
state plus your rows to the component, and it renders sortable headers, typed filter popovers,
a search box, the range summary and pagination against it. Nothing is hidden in the component,
so the same state can be driven two ways: post one event per interaction
(on_change), or turn every interaction into a patch URL (path).
Engine.List
runs a state over an in-memory list, which is what every example on this page uses. Point the
same state at your own query layer when the data lives in a database.
A live table, wired in event mode
Everything below is server-driven and real: sort by customer or amount, filter email by text
or status by checkbox, search across all three string columns, change the page size, select
rows and archive them. Selection survives paging because it lives in an assign, keyed by row_id, not by row position.
| Status | ||||
|---|---|---|---|---|
| Fatima Zahra | fatima.zahra@example.com | pending | $921 | |
| Felix Dubois | felix.dubois@example.com | refunded | $902 | |
| Diego Ramos | diego.ramos@example.com | paid | $862 | |
| Zoe Papadakis | zoe.papadakis@example.com | pending | $843 | |
| Caleb Osei | caleb.osei@example.com | refunded | $824 | |
| Oscar Lindqvist | oscar.lindqvist@example.com | paid | $784 | |
| Maya Kapoor | maya.kapoor@example.com | pending | $765 | |
| Nina Okafor | nina.okafor@example.com | refunded | $725 |
<.data_table
id="orders"
rows={@rows}
state={@table}
on_change="table"
searchable
selectable
selected={@selected}
row_id={&row_key/1}
page_size_options={[8, 16, 32]}
>
<:col :let={o} field={:customer} sortable>{o.customer}</:col>
<:col :let={o} field={:email} filterable="text">{o.email}</:col>
<:col :let={o} field={:status} filterable="select" options={~w(paid pending refunded)}>
<.badge size="sm" variant="soft" label={o.status} />
</:col>
<:col :let={o} field={:amount} sortable align="right">${o.amount}</:col>
<:bulk_action :let={ids}>
<.button size="sm" variant="soft" color="danger" phx-click="archive"
phx-value-ids={Enum.join(ids, ",")}>
Archive {length(ids)}
</.button>
</:bulk_action>
</.data_table>
Sorted, paged, engine-run
The whole surface from one State struct: sortable headers (aria-sort included), the range summary, and pagination that picks numbered mode because total is known. This static example runs the free in-memory engine over 20 rows at render time - zero setup, no database. In your app the same State drives handle_params (link mode) or a single op-grammar event (event mode).
| Tia | tia@example.com | $360 |
| Quin | quin@example.com | $349 |
| Ned | ned@example.com | $338 |
| Kim | kim@example.com | $327 |
| Gus | gus@example.com | $316 |
<% state = %State{order_by: [amount: :desc], page: 1, page_size: 5} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table id="sx-dt-basic" rows={rows} state={state} path="#">
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} sortable align="right">${row.amount}</:col>
</.data_table>
The full toolbar: search, typed filters, per-page
searchable sweeps string fields case-insensitively before filters (totals stay honest); filterable columns get buttons that open typed popover editors - operator + value for text/number/date (between reveals its second bound, no JS), a checkbox list for select - and read their predicate while active, with an inline clear. page_size_options adds the per-page select; Reset filters appears while any filter is active. Event mode posts one op-grammar event for all of it (State.handle_op/3 is the whole handler); link mode patches curl-able URLs.
<% state = %State{
search: "i",
filters: [%{field: :status, op: :in, value: ["pending", "refunded"]}],
page_size: 5
} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-toolbar"
rows={rows}
state={state}
on_change="table"
searchable
page_size_options={[5, 10, 20]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email} filterable="text">{row.email}</:col>
<:col
:let={row}
field={:status}
filterable="select"
options={[{"Paid", "paid"}, {"Pending", "pending"}, {"Refunded", "refunded"}]}
>
<.badge
size="sm"
variant="soft"
color={
case row.status do
"paid" -> "success"
"pending" -> "warning"
_ -> "danger"
end
}
label={row.status}
/>
</:col>
<:col :let={row} field={:amount} sortable align="right" filterable="number">
${row.amount}
</:col>
</.data_table>
Row selection with a morphing toolbar
selectable adds a checkbox column keyed by row_id (a field or a function - it must uniquely identify records across ALL pages). The header checkbox is tri-state; while rows are selected the toolbar morphs into the count, your :bulk_action slot (:let receives the ids) and a clear button. Selection is UI state: it rides the on_ui event (defaulting to on_change) with select / select_all / clear_selection ops and never touches URLs - a MapSet plus three clauses is the whole backend.
<% state = %State{page_size: 4} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-selection"
rows={rows}
state={state}
on_change="table"
selectable
selected={["1", "3"]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} align="right">${row.amount}</:col>
<:bulk_action :let={ids}>
<.button size="sm" variant="soft" color="danger">Archive {length(ids)}</.button>
</:bulk_action>
</.data_table>
Columns visibility and order
column_toggle renders a Columns dropdown - every declared column with a checkbox, toggling immediately (the panel stays open for multi-toggling). reorderable adds move controls posting a field + dir delta; DataTable.move_column/4 applies it to your current order in one line, race-free under rapid clicks. Both are presentation state on the on_ui event, never in URLs; the last visible column can't be hidden.
<% state = %State{page_size: 4} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-columns"
rows={rows}
state={state}
on_change="table"
column_toggle
reorderable
hidden_columns={["email"]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} align="right">${row.amount}</:col>
</.data_table>
Loading skeletons
loading swaps the page for skeleton rows - one per page_size row, respecting column count and alignment. Flip it off when the query resolves.
| Name | Amount | |
|---|---|---|
<% state = %State{total: 74, page_size: 4} %>
<.data_table id="sx-dt-loading" rows={[]} state={state} path="#" loading>
<:col :let={row} field={:name}>{row}</:col>
<:col :let={row} field={:email}>{row}</:col>
<:col :let={row} field={:amount} align="right">{row}</:col>
</.data_table>
The filters-aware empty state
An empty result set with active filters says so and offers the way out - a clear-filters patch link in link mode, the op-grammar event in event mode. Without filters it is a plain no-results line. Override either with the :empty slot.
| Name |
|---|
<% state = %State{total: 0, filters: [%{field: :name, op: :contains, value: "zz"}]} %>
<.data_table id="sx-dt-empty" rows={[]} state={state} path="#">
<:col :let={row} field={:name}>{row}</:col>
<:col :let={row} field={:email}>{row}</:col>
</.data_table>
Event mode - one event, one grammar
Every interaction posts the same event name with an op
and its payload, so the backend is one handle_op/3
call plus a re-run of your engine. Selection and column visibility are UI state and get their
own clauses, because handle_op/3
ignores those ops by design. The fields
option is the allow-list: an op naming any other field is dropped, so a crafted payload cannot
sort or filter by a column you never exposed. This is the exact code behind the table above.
# Selection is UI state, so it gets its own clauses - handle_op
# ignores those ops by design. Events post strings, so keep
# `selected` a list of strings.
def handle_event("table", %{"op" => "select", "id" => id}, socket) do
{:noreply,
update(socket, :selected, fn sel ->
if id in sel, do: List.delete(sel, id), else: sel ++ [id]
end)}
end
def handle_event("table", %{"op" => "select_all"}, socket) do
page_ids = Enum.map(socket.assigns.rows, &row_key/1)
sel = socket.assigns.selected
{:noreply,
assign(
socket,
:selected,
if(Enum.all?(page_ids, &(&1 in sel)),
do: sel -- page_ids,
else: Enum.uniq(sel ++ page_ids)
)
)}
end
def handle_event("table", %{"op" => "clear_selection"}, socket),
do: {:noreply, assign(socket, :selected, [])}
# Everything else - sort, page, search, page_size, filter,
# clear_filters - is query state, and this is the whole backend.
def handle_event("table", params, socket) do
state = State.handle_op(socket.assigns.table, params, fields: @fields)
{rows, state} = run_table(state)
{:noreply, assign(socket, rows: rows, table: state)}
end
defp run_table(state) do
{rows, state} = Engine.List.run(all_orders(), state, search_fields: @search_fields)
pages = State.total_pages(state)
# the engine never clamps the page down; snap after deletes
if state.total > 0 and state.page > pages do
Engine.List.run(all_orders(), %{state | page: pages}, search_fields: @search_fields)
else
{rows, state}
end
end
# ONE identity function, used as both the component's row_id and
# select_all's page sweep - they must never disagree
defp row_key(row), do: to_string(row.id)
Link mode - the table state in the URL
Pass path
instead of on_change
and the component renders patch links rather than posting events. Sort order, filters, search
and page all live in the query string, so a filtered view can be bookmarked, shared in Slack,
or reopened after a refresh, and the back button steps through it. The backend collapses to
State.from_params/2
in handle_params.
# Link mode: pass `path` instead of `on_change` and every
# interaction becomes a patch URL, so table views are shareable,
# bookmarkable and survive a refresh.
def handle_params(params, _uri, socket) do
state = State.from_params(params, fields: @fields)
{rows, state} = Engine.List.run(all_orders(), state, search_fields: @search_fields)
{:noreply, assign(socket, rows: rows, table: state)}
end
Filter operators
Seventeen operators across the four filter types. The popover only ever offers the ones that
make sense for the column's type, and between
reveals its second bound with no JS. Pass filter_op_labels
to rename any of them for your users.
| Filter type | Operators |
|---|---|
text
|
contains, not_contains, eq, neq, starts_with, is_empty,
is_not_empty
|
number
|
eq, neq, gt, gte, lt, lte, between, is_empty,
is_not_empty
|
date
|
before, on, after, between, is_empty,
is_not_empty
|
select
|
in, not_in
|
Properties
Every attr and slot on data_table/1, read straight off the component at render
time - so this table can't drift from the real API.
| Attribute | Type | Default | Description |
|---|---|---|---|
actions_label
|
string |
"Actions"
|
the actions column's header, announced but not shown |
apply_label
|
string |
"Apply"
|
the filter editors' submit label, localizable |
class
|
any |
nil
|
|
clear_filters_label
|
string |
"Clear filters"
|
|
clear_selection_label
|
string |
"Clear selection"
|
|
column_order
|
list |
[]
|
fields in display order (atoms or strings) - presentation state on the `on_ui` event, like `hidden_columns`, never in URLs. Fields not listed keep their declared order after the listed ones. Empty means the declared `:col` order. |
column_toggle
|
boolean |
false
|
render a columns-visibility dropdown in the toolbar (rides the `on_ui` event) |
column_toggle_label
|
string |
"Columns"
|
|
density
|
string |
"comfortable"
|
one of: "comfortable", "compact"
|
filter_op_labels
|
map |
%{}
|
overrides for the operator display names, e.g. %{contains: "enthält"} |
filter_options_placeholder
|
string |
"Filter options…"
|
the select editor's option-filter placeholder (shown from 8 options up), localizable |
hidden_columns
|
list |
[]
|
fields currently hidden (atoms or strings) - presentation state, never in URLs |
id*
|
string |
||
loading
|
boolean |
false
|
render skeleton rows instead of data |
max_height
|
string |
nil
|
caps the table body's height (any CSS length), making it scroll under a pinned header |
move_down_label
|
string |
"Move down"
|
|
move_up_label
|
string |
"Move up"
|
reorder buttons, localizable |
no_filtered_results_text
|
string |
"No results for these filters"
|
the empty message while filters are active |
no_results_text
|
string |
"No results"
|
|
of_label
|
string |
"of"
|
the range summary's connective, localizable |
on_change
|
string |
nil
|
event mode: the event every table interaction pushes, with an op-shaped payload (`op` of "sort" | "page" | "clear_filters"). |
on_ui
|
string |
nil
|
the event UI-state ops (selection) push, both wiring modes. Defaults to `on_change`; required alongside `selectable` in link mode. |
page_label
|
string |
"Page"
|
cursor mode's page word, localizable |
page_size_options
|
list |
[]
|
when non-empty, render a rows-per-page select in the footer |
path
|
string |
nil
|
link mode: the base path sort and page changes patch to, with the state encoded as query params. Required unless `on_change` is set. |
per_page_label
|
string |
"Per page"
|
the rows-per-page label, localizable |
reorderable
|
boolean |
false
|
render move up/down controls in the Columns menu (requires `column_toggle`) |
reset_filters_label
|
string |
"Reset filters"
|
|
results_label
|
string |
"results"
|
the announced result-count noun, localizable |
row_id
|
any |
:id
|
row identity for selection: a field (atom) or a 1-arity function of the row. The key must uniquely identify a record across ALL pages, not just the visible one - a selection retained while paging is only as sound as this key. Same-page duplicates raise. |
row_label
|
any |
nil
|
a 1-arity function returning a human name for a row, used as the selection checkbox's accessible name. Without it the checkbox announces its position ("Select row 3"), because a primary key - a UUID, say - is not something a screen reader user can act on. |
rows
|
list |
[]
|
|
search_debounce
|
integer |
300
|
quick-search debounce in ms, both wiring modes |
search_placeholder
|
string |
"Search…"
|
|
searchable
|
boolean |
false
|
render the quick-search input in the toolbar (drives `state.search`) |
select_all_label
|
string |
"Select all rows"
|
the header checkbox's aria-label |
select_row_label
|
string |
"Select row"
|
the row checkbox's aria-label prefix, localizable |
selectable
|
boolean |
false
|
render a leading checkbox column |
selected
|
list |
[]
|
the currently selected row ids (any terms; compared as strings) |
selected_label
|
string |
"selected"
|
the selection count's word, localizable |
state*
|
{:struct, PetalComponents.DataTable.State} |
||
sticky_header
|
boolean |
false
|
pin the header row. Inside a data table this needs `max_height` too: the scroll region is what the header sticks to, and a wrapper that scrolls only sideways cannot pin anything. |
striped
|
boolean |
false
|
|
target
|
any |
nil
|
event mode: the phx-target (e.g. @myself) |
variant
|
string |
"basic"
|
one of: "ghost", "basic"
|
:action
slot
|
slot |
trailing actions column, `:let` receives the row | |
:bulk_action
slot
|
slot |
toolbar content while rows are selected; `:let` receives the selected ids | |
:col
slot
|
slot |
||
:empty
slot
|
slot |
custom empty state; a filters-aware default renders otherwise | |
:toolbar
slot
|
slot |
custom toolbar content rendered above the table |
Migrating from the Flop data table
The shapes changed, because the new table does not require Ecto. Swap the import, drop the schema annotations, keep your columns:
| Flop era | Now |
|---|---|
meta (a Flop.Meta) and items
|
state (a DataTable.State) and rows
|
@derive Flop.Schema on the Ecto schema
|
Gone - the fields
option on handle_op/3
or from_params/2
is the allow-list
|
filterable={[:ilike]}
- Flop operator atoms
|
filterable="text"
- a filter type, and the popover offers the operators that fit it
|
| Flop query params, always in the URL |
path for URL state, or on_change to keep it in assigns
|
| Sorting and paging require an Ecto queryable |
Engine.List runs a plain list, or run the state against your own query layer
|
| Row selection and bulk actions - hand-rolled |
selectable, selected, row_id
and the :bulk_action
slot
|