R

ReflexKR

Reflex Docs

The Component Workshop: Reflex's 100 Library Components

·

Course overview

Layout

·

Box

·

Flex

·

Stack

·

Grid

·

Container

·

Section

·

Center

·

Spacer

·

Separator

·

Card

·

Inset

·

Aspect Ratio

·

Fragment

Typography

·

Text

·

Heading

·

Link

·

Code

·

Markdown

·

Blockquote

·

Quote

·

Strong

·

Em

·

Kbd

Forms

·

Button

·

Input

·

Text Area

·

Checkbox

·

Switch

·

Radio Group

·

Select

·

Slider

·

Form

·

Upload

Data display

·

Badge

·

Avatar

·

Icon

·

Callout

·

List

·

Data List

·

Code Block

·

Progress

·

Spinner

·

Scroll Area

·

Moment

Disclosure

·

Tabs

·

Accordion

·

Segmented Control

Overlay

·

Tooltip

·

Popover

·

Hover Card

·

Dialog

·

Alert Dialog

·

Drawer

·

Dropdown Menu

·

Context Menu

·

Toast

Dynamic rendering

·

Cond

·

Match

·

Foreach

·

Auto Scroll

Media

·

Image

·

Audio

·

Video

Tables and data grids

·

Table

·

Data Table

·

Data Editor

Charts

·

Area Chart

·

Bar Chart

·

Line Chart

·

Composed Chart

·

Pie Chart

·

Radar Chart

·

Radial Bar Chart

·

Scatter Chart

·

Funnel Chart

·

Treemap

·

Sankey Chart

·

Error Bar

Chart parts

·

Axis

·

Cartesian Grid

·

Chart Tooltip

·

Legend

·

Label

·

Reference

·

Brush

Other chart libraries

·

Plotly

·

Pyplot

HTML elements

·

HTML

·

HTML Text

·

HTML Layout

·

HTML Forms

·

HTML Media

·

HTML Tables

·

SVG

Other tools

·

Theme

·

Skeleton

·

Clipboard

·

Html Embed

·

Script

·

Memo

The Component Workshop: Reflex's 100 Library Components
Tables and data grids

Table

rx.table.rootrx.table.headerrx.table.rowrx.table.column_header_cellrx.table.bodyrx.table.cellrx.table.row_header_cell

Official docs

Workshop order

Build a table of team members for the team management screen. Each row has an avatar, a name link, an email and a role badge.

rx.table is a table you assemble yourself from header rows, rows and cells. Any component can go in a cell, such as an avatar, a link or a button, which makes it a good fit for lists whose look you want to control. If you need a table with sorting, search and pagination built in, Data Table may get you there faster.

  • table.root: the whole table.
  • table.header: the head section (<thead>). Inside it go a table.row and table.column_header_cells (column title cells).
  • table.body: the body (<tbody>). Inside it go table.rows.
  • table.row: a single row.
  • table.row_header_cell: the first cell that labels its row (a row title shown in bold).
  • table.cell: a regular cell.
Full nameEmailGroup
Danilo Sousadanilo@example.comDeveloper
Zahra Ambessazahra@example.comAdmin
Jasper Eriksjasper@example.comDeveloper
rx.table.root(
    rx.table.header(
        rx.table.row(
            rx.table.column_header_cell("Full name"),
            rx.table.column_header_cell("Email"),
            rx.table.column_header_cell("Group"),
        ),
    ),
    rx.table.body(
        rx.table.row(
            rx.table.row_header_cell("Danilo Sousa"),
            rx.table.cell("danilo@example.com"),
            rx.table.cell("Developer"),
        ),
        rx.table.row(
            rx.table.row_header_cell("Zahra Ambessa"),
            rx.table.cell("zahra@example.com"),
            rx.table.cell("Admin"),
        ),
        rx.table.row(
            rx.table.row_header_cell("Jasper Eriks"),
            rx.table.cell("jasper@example.com"),
            rx.table.cell("Developer"),
        ),
    ),
    width="100%",
)

It is best to fit the table to its container with width. If there are so many columns that it overflows, wrap it as in rx.box(rx.table.root(...), overflow_x="auto", width="100%") so only the table scrolls sideways, not the page.

Try the props

variant and size go on table.root; justify, the horizontal alignment of a cell's content, goes on each cell. Number columns are usually aligned to the right (end).

Try the props

ItemQtyPrice
Coffee beans2$24.00
Paper filters1$5.50

variant

size

justify

rx.table.root(
    rx.table.header(
        rx.table.row(
            rx.table.column_header_cell("Item"),
            rx.table.column_header_cell("Qty", justify="end"),
            rx.table.column_header_cell("Price", justify="end"),
        ),
    ),
    rx.table.body(
        rx.table.row(
            rx.table.row_header_cell("Coffee beans"),
            rx.table.cell("2", justify="end"),
            rx.table.cell("$24.00", justify="end"),
        ),
        rx.table.row(
            rx.table.row_header_cell("Paper filters"),
            rx.table.cell("1", justify="end"),
            rx.table.cell("$5.50", justify="end"),
        ),
    ),
    variant="ghost",
    size="2",
    width="100%",
)
variant="surface" gives the table a border and background, while "ghost" keeps only the lines. size is the cell padding.
PartPropValuesUse
rootvariant"surface" "ghost"A table with border and background, or one with lines only
rootsize"1" "2" "3"Cell padding and font size
rootalign · summary"left" "center" "right" · stringTable alignment, table summary (HTML attributes)
rowalign"start" "center" "end" "baseline"Vertical alignment of the cells in the row
column_header_cell · cell · row_header_celljustify"start" "center" "end"Horizontal alignment of the cell's content
All three cellscol_span · row_spanintA cell spanning several columns or rows
All three cellsmin_width · max_widthCSS lengthMinimum and maximum column width
cell · row_header_cellp px py pt pr pb pl"0" ~ "9"Cell padding (theme spacing steps)
column_header_cell · row_header_cellscope"col" "row" etc.What the header cell refers to (HTML attribute)

header and body have no props of their own. A cell's align is the old HTML align attribute; for Radix alignment, use justify.

Merging cells and vertical alignment

col_span=2 makes one cell take up two columns, and row_span=2 two rows. A row's align sets the vertical position of its cells.

DayMorningAfternoon
MonWorkshop
TueReviewDemo
WedQ&A
rx.table.root(
    rx.table.header(
        rx.table.row(
            rx.table.column_header_cell("Day"),
            rx.table.column_header_cell("Morning"),
            rx.table.column_header_cell("Afternoon"),
        ),
    ),
    rx.table.body(
        rx.table.row(
            rx.table.row_header_cell("Mon"),
            rx.table.cell("Workshop", col_span=2, justify="center"),
        ),
        rx.table.row(
            rx.table.row_header_cell("Tue"),
            rx.table.cell("Review", row_span=2),
            rx.table.cell("Demo"),
            align="center",
        ),
        rx.table.row(
            rx.table.row_header_cell("Wed"),
            rx.table.cell("Q&A"),
        ),
    ),
    variant="surface",
    width="100%",
)

Showing State data: foreach

If the table's content has to change, keep the data in State and draw the rows with rx.foreach. A separate function that draws one row keeps the table code short. Row data can also be dicts or dataclasses instead of lists (with a dataclass you read fields by name, as in person.email).

Full nameEmailGroup
Danilo Sousadanilo@example.comDeveloper
Zahra Ambessazahra@example.comAdmin
Jasper Eriksjasper@example.comDeveloper
class TableForEachState(rx.State):
    people: list[list[str]] = [
        ["Danilo Sousa", "danilo@example.com", "Developer"],
        ["Zahra Ambessa", "zahra@example.com", "Admin"],
        ["Jasper Eriks", "jasper@example.com", "Developer"],
    ]


def table_person_row(person: list):
    return rx.table.row(
        rx.table.cell(person[0]),
        rx.table.cell(person[1]),
        rx.table.cell(person[2]),
    )


rx.table.root(
    rx.table.header(
        rx.table.row(
            rx.table.column_header_cell("Full name"),
            rx.table.column_header_cell("Email"),
            rx.table.column_header_cell("Group"),
        ),
    ),
    rx.table.body(rx.foreach(TableForEachState.people, table_person_row)),
    width="100%",
)

Sorting and search

For data in memory, sort and search with Python's sorted() and list comprehensions. Keep the original in a backend-only variable whose name starts with an underscore, like _people, so it is never sent to the browser, and build the visible list as the computed var current_people. With cache=True it is recomputed only when the sort value or the search term changes. The search box's on_change gets .debounce(300), so the event is sent when typing pauses rather than on every keystroke.

Full nameEmailGroup
Danilo Sousadanilo@example.comDeveloper
Zahra Ambessazahra@example.comAdmin
Jasper Erikszjasper@example.comB-Developer
Alice Brownalice@example.comAdmin
class TableSearchState(rx.State):
    _people: list[dict[str, str]] = [
        {"full_name": "Danilo Sousa", "email": "danilo@example.com", "group": "Developer"},
        {"full_name": "Zahra Ambessa", "email": "zahra@example.com", "group": "Admin"},
        {"full_name": "Jasper Eriks", "email": "zjasper@example.com", "group": "B-Developer"},
        {"full_name": "Alice Brown", "email": "alice@example.com", "group": "Admin"},
    ]
    sort_value: str = ""
    search_value: str = ""

    @rx.event
    def set_sort_value(self, value: str):
        self.sort_value = value

    @rx.event
    def set_search_value(self, value: str):
        self.search_value = value

    @rx.var(cache=True)
    def current_people(self) -> list[dict[str, str]]:
        people = self._people
        if self.sort_value != "":
            people = sorted(people, key=lambda p: p[self.sort_value].lower())
        if self.search_value != "":
            term = self.search_value.lower()
            people = [p for p in people if any(term in v.lower() for v in p.values())]
        return people


def table_search_row(person: dict):
    return rx.table.row(
        rx.table.cell(person["full_name"]),
        rx.table.cell(person["email"]),
        rx.table.cell(person["group"]),
    )


rx.vstack(
    rx.flex(
        rx.select(
            ["full_name", "email", "group"],
            placeholder="Sort By: full_name",
            on_change=TableSearchState.set_sort_value,
        ),
        rx.input(
            placeholder="Search here...",
            on_change=TableSearchState.set_search_value.debounce(300),
        ),
        spacing="3",
        wrap="wrap",
    ),
    rx.table.root(
        rx.table.header(
            rx.table.row(
                rx.table.column_header_cell("Full name"),
                rx.table.column_header_cell("Email"),
                rx.table.column_header_cell("Group"),
            ),
        ),
        rx.table.body(rx.foreach(TableSearchState.current_people, table_search_row)),
        width="100%",
    ),
    width="100%",
)

If the data lives in a database, build the same screen with an SQL query. Trimmed down, the official docs example looks like this (it does not run on this page). ilike is a case-insensitive partial match, and the % on either side of the search term means "any characters".

from sqlmodel import asc, or_, select


class Customer(rx.Model, table=True):
    name: str
    email: str
    phone: str
    address: str


class DatabaseTableState(rx.State):
    users: list[Customer] = []
    sort_value: str = ""
    search_value: str = ""

    @rx.event
    def load_entries(self):
        with rx.session() as session:
            query = select(Customer)
            if self.search_value:
                pattern = f"%{self.search_value.lower()}%"
                query = query.where(or_(Customer.name.ilike(pattern), Customer.email.ilike(pattern)))
            if self.sort_value:
                query = query.order_by(asc(getattr(Customer, self.sort_value)))
            self.users = session.exec(query).all()

The more data there is, the better it is to filter on the database side; for small data, or data fetched from an API, filtering in memory is simpler.

Pagination

To show only a few rows at a time, keep offset (rows to skip) and limit (rows to show) in State and put just that slice into users. on_mount on table.root fills in the first data. With a database, use query.offset(self.offset).limit(self.limit) instead of slicing.

Page 1 / 1

NameEmailPhone
class TablePageState(rx.State):
    _customers: list[dict[str, str]] = [
        {"name": "John Doe", "email": "john@example.com", "phone": "555-1234"},
        {"name": "Jane Smith", "email": "jane@example.com", "phone": "555-5678"},
        {"name": "Bob Johnson", "email": "bob@example.com", "phone": "555-9012"},
        {"name": "Alice Brown", "email": "alice@example.com", "phone": "555-3456"},
        {"name": "Charlie Wilson", "email": "charlie@example.com", "phone": "555-7890"},
        {"name": "Emily Davis", "email": "emily@example.com", "phone": "555-2345"},
        {"name": "Frank Miller", "email": "frank@example.com", "phone": "555-6789"},
    ]
    users: list[dict[str, str]] = []
    total_items: int = 0
    offset: int = 0
    limit: int = 3

    @rx.var
    def page_number(self) -> int:
        return self.offset // self.limit + 1

    @rx.var
    def total_pages(self) -> int:
        return max(1, -(-self.total_items // self.limit))

    @rx.event
    def load_entries(self):
        self.total_items = len(self._customers)
        self.users = self._customers[self.offset : self.offset + self.limit]

    @rx.event
    def prev_page(self):
        self.offset = max(self.offset - self.limit, 0)
        self.load_entries()

    @rx.event
    def next_page(self):
        if self.offset + self.limit < self.total_items:
            self.offset += self.limit
        self.load_entries()


def table_page_row(user: dict):
    return rx.table.row(
        rx.table.cell(user["name"]),
        rx.table.cell(user["email"]),
        rx.table.cell(user["phone"]),
    )


rx.vstack(
    rx.hstack(
        rx.button("Prev", on_click=TablePageState.prev_page, variant="soft"),
        rx.text("Page ", TablePageState.page_number, " / ", TablePageState.total_pages),
        rx.button("Next", on_click=TablePageState.next_page, variant="soft"),
        align="center",
    ),
    rx.table.root(
        rx.table.header(
            rx.table.row(
                rx.table.column_header_cell("Name"),
                rx.table.column_header_cell("Email"),
                rx.table.column_header_cell("Phone"),
            ),
        ),
        rx.table.body(rx.foreach(TablePageState.users, table_page_row)),
        on_mount=TablePageState.load_entries,
        width="100%",
    ),
    width="100%",
)

If one page has several paginated tables, each table needs its own offset and limit. When the search term or sort order changes, reset offset to 0 so you don't stay on a page past the end of the shrunken results. To reuse the same table several times, rx.ComponentState can give each table its own State.

Downloads

rx.download lets users download the table's data as a file. For JSON, the UI passes the State value directly; values that are not strings are saved as JSON text. For CSV, the handler builds the text with the csv module and returns rx.download. Either way, the file is received by the user's browser and nothing is saved on the server.

NameEmailPhone
John Doejohn@example.com555-1234
Jane Smithjane@example.com555-5678
Bob Johnsonbob@example.com555-9012
import csv
import io
import json


class TableDownloadState(rx.State):
    users: list[dict[str, str]] = [
        {"name": "John Doe", "email": "john@example.com", "phone": "555-1234"},
        {"name": "Jane Smith", "email": "jane@example.com", "phone": "555-5678"},
        {"name": "Bob Johnson", "email": "bob@example.com", "phone": "555-9012"},
    ]

    @rx.event
    def download_csv_data(self):
        output = io.StringIO()
        writer = csv.DictWriter(output, fieldnames=["name", "email", "phone"])
        writer.writeheader()
        writer.writerows(self.users)
        return rx.download(data=output.getvalue(), filename="data.csv")


def table_download_row(user: dict):
    return rx.table.row(
        rx.table.cell(user["name"]),
        rx.table.cell(user["email"]),
        rx.table.cell(user["phone"]),
    )


rx.vstack(
    rx.table.root(
        rx.table.header(
            rx.table.row(
                rx.table.column_header_cell("Name"),
                rx.table.column_header_cell("Email"),
                rx.table.column_header_cell("Phone"),
            ),
        ),
        rx.table.body(rx.foreach(TableDownloadState.users, table_download_row)),
        width="100%",
    ),
    rx.hstack(
        rx.button("Download as JSON", on_click=rx.download(data=TableDownloadState.users, filename="data.json")),
        rx.button("Download as CSV", on_click=TableDownloadState.download_csv_data),
        spacing="3",
        wrap="wrap",
    ),
    width="100%",
    spacing="4",
)

Filling the order

A team list with only a body and no header row. The first cell holds the avatar, the row title cell the name link, and the last cell the role badge; every row gets align="center" so the avatar and text line up vertically. The table is wrapped in an overflow_x="auto" box so the page doesn't overflow on narrow screens.

Your Team

Invite and manage your team members

Danilo Sousadanilo@example.comDeveloper
Zahra Ambessazahra@example.comAdmin
Jasper Erikssonjasper@example.comDeveloper
rx.flex(
    rx.heading("Your Team", as_="h2", size="5"),
    rx.text("Invite and manage your team members"),
    rx.flex(
        rx.input(placeholder="Email Address"),
        rx.button("Invite"),
        spacing="2",
    ),
    rx.box(
        rx.table.root(
            rx.table.body(
                rx.table.row(
                    rx.table.cell(rx.avatar(fallback="DS")),
                    rx.table.row_header_cell(rx.link("Danilo Sousa")),
                    rx.table.cell("danilo@example.com"),
                    rx.table.cell(rx.badge("Developer")),
                    align="center",
                ),
                rx.table.row(
                    rx.table.cell(rx.avatar(fallback="ZA")),
                    rx.table.row_header_cell(rx.link("Zahra Ambessa")),
                    rx.table.cell("zahra@example.com"),
                    rx.table.cell(rx.badge("Admin", color_scheme="orange")),
                    align="center",
                ),
                rx.table.row(
                    rx.table.cell(rx.avatar(fallback="JE")),
                    rx.table.row_header_cell(rx.link("Jasper Eriksson")),
                    rx.table.cell("jasper@example.com"),
                    rx.table.cell(rx.badge("Developer")),
                    align="center",
                ),
            ),
            width="100%",
        ),
        overflow_x="auto",
        width="100%",
    ),
    width="100%",
    direction="column",
    spacing="2",
)

Workshop notes

  • root > header(row > column_header_cell) + body(row > row_header_cell · cell).

  • variant (surface/ghost) · size on root; justify · col_span · row_span on cells; align on rows.

  • Changing data: State + rx.foreach. Keep the original in a _backend var and the visible list in a computed var.

  • Sorting, search and pagination are up to you. If you need a table with all of it built in, use rx.data_table.

  • Wrap wide tables in an overflow_x="auto" box so only the table scrolls.

← Previous

Media · Video

Next →

Tables and data grids · Data Table

Get news about new lessons

We'll email you when this course gets new lessons or a follow-up course opens.

We collect: your email address · Purpose: news about new lessons or the launch of this course · Kept until: you ask us to remove it. We never use it for anything else or share it. Reply to any notice email and we'll delete it right away.

Questions & answers

Ask anything about this lesson. Questions from every language version of this page appear together.

Shown as plain text, without links or formatting.

R

ReflexKR

Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.

ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee

Affiliate disclosure
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex