Components Data Table

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

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

This component replaces the Flop-backed Pro data table in Petal Components 4.13, and it is free in the open source library. No Flop, no Ecto, no schema annotations: it sorts, filters, searches and pages a plain list of maps. Still on the old one? The legacy docs stay up, and the migration table at the bottom of this page maps every old attr to its new home.
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.

Email 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
heex
          <.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).

Email
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
heex
<% 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.

Email Status
Amy amy@example.com pending $157
Dan dan@example.com pending $168
Fay fay@example.com pending $179
Joy joy@example.com pending $190
Kim kim@example.com refunded $327
heex
<% 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.

2 selected
Email Amount
Amy amy@example.com $157
Bea bea@example.com $294
Cal cal@example.com $31
Dan dan@example.com $168
heex
<% 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.

Amount
Amy $157
Bea $294
Cal $31
Dan $168
heex
<% 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 Email Amount
heex
<% 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 Email
heex
<% 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.

elixir
          # 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)

        

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.

elixir
          # 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