Tables in Shiny

Lecture overview

Learning goals

  1. Render a DataGrid and DataTable in Shiny
  2. Enable sorting, filtering, and row selection
  3. Use data_view() and cell_selection() to drive reactive outputs
  4. Replace a filtered table with a chart driven by row selection
  5. Update a table from an external input widget

Covered apps

  • app-01a-table-basic.py — basic DataGrid
  • app-01b-table-basic.py — basic DataTable
  • app-02-table-from-input.py — dropdown -> update table
  • app-03-table-selection.py — row selection -> chart
  • app-04-table-linked.py — row selection -> linked Altair chart

Two table types in Shiny

Shiny provides two built-in table renderers:

render.DataGrid

  • Spreadsheet-like appearance
  • Best for data exploration
  • Compact rows
@render.data_frame
def my_table():
    return render.DataGrid(df)

render.DataTable

  • HTML table appearance
  • Best for display / reporting
  • Wider rows with more whitespace
@render.data_frame
def my_table():
    return render.DataTable(df)

Both use the same ui.output_data_frame("my_table") placeholder.

Both support: sorting · filtering · row selection · editable cells

Basic table: DataGrid

pagination, filtering, sorting enabled

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

from shiny import App, ui, render
from vega_datasets import data as vega_data
import pandas as pd

cars = pd.DataFrame(vega_data.cars()).iloc[:, :5]

app_ui = ui.page_fluid(
    ui.h4("Cars dataset"),
    ui.layout_columns(
        ui.input_switch("filters", "Show filters", True),
        col_widths=[3],
    ),
    ui.output_data_frame("grid"),
)

def server(input, output, session):
    @render.data_frame
    def grid():
        return render.DataGrid(
            cars,
            filters=input.filters(),
            height="400px",
            width="100%",
        )

app = App(app_ui, server)

Basic table: DataTable style

Same features — different visual style (wider rows, HTML table look):

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

from shiny import App, ui, render
from vega_datasets import data as vega_data
import pandas as pd

cars = pd.DataFrame(vega_data.cars()).iloc[:, :5]

app_ui = ui.page_fluid(
    ui.h4("Cars dataset — DataTable"),
    ui.layout_columns(
        ui.input_switch("filters", "Show filters", True),
        col_widths=[3],
    ),
    ui.output_data_frame("table"),
)

def server(input, output, session):
    @render.data_frame
    def table():
        return render.DataTable(
            cars,
            filters=input.filters(),
            height="400px",
            width="100%",
        )

app = App(app_ui, server)

DataGrid vs DataTable

Same data, different presentation:

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 600
#| packages: [vega_datasets]

from shiny import App, ui, render
from vega_datasets import data as vega_data
import pandas as pd

cars = pd.DataFrame(vega_data.cars()).iloc[:, :5]

app_ui = ui.page_fluid(
    ui.layout_columns(
        ui.card(
            ui.card_header(ui.code("render.DataGrid")),
            ui.output_data_frame("grid"),
        ),
        ui.card(
            ui.card_header(ui.code("render.DataTable")),
            ui.output_data_frame("table"),
        ),
    ),
)

def server(input, output, session):
    @render.data_frame
    def grid():
        return render.DataGrid(cars, height="300px")

    @render.data_frame
    def table():
        return render.DataTable(cars, height="300px")

app = App(app_ui, server)

Example 1: Updating a table from an input

flowchart LR
  A[/input_origin/] --> P([my_table])

The table is just a reactive output

  • my_table is a @render.data_frame — it re-runs whenever any input.* it reads changes
  • input.origin() filters the dataframe in Python before returning it to render.DataGrid
  • No special table API needed: this is the same input -> output pattern as any other Shiny output
  • Sorting and filtering in the rendered table are applied on top of whatever Python returns
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Weight_in_lbs"]

app_ui = ui.page_fluid(
    ui.h4("Filter table with a dropdown"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        col_widths=[3],
    ),
    ui.output_data_frame("tbl"),
)

def server(input, output, session):
    @render.data_frame
    def tbl():
        df = cars[cols]
        if input.origin() != "All":
            df = df[cars["Origin"] == input.origin()]
        return render.DataGrid(df, width="100%", height="380px")

app = App(app_ui, server)

Example 1: Updating a table from an input

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 500
#| packages: [vega_datasets]

import pandas as pd
from vega_datasets import data as vega_data
from shiny import App, ui, render

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Weight_in_lbs"]

app_ui = ui.page_fluid(
    ui.h4("Filter table with a dropdown"),
    ui.layout_columns(
        ui.input_select("origin", "Origin", choices=["All"] + origins),
        col_widths=[3],
    ),
    ui.output_data_frame("tbl"),
)

def server(input, output, session):
    @render.data_frame
    def tbl():
        df = cars[cols]
        if input.origin() != "All":
            df = df[cars["Origin"] == input.origin()]
        return render.DataGrid(df, width="100%", height="380px")

app = App(app_ui, server)

Reading table state with data_view()

Once rendered, the decorator result exposes methods to read the table’s current state:

Method Returns
tbl.data_view() All rows after sort + filter
tbl.data_view(selected=True) Only selected rows
tbl.data() Original underlying data
tbl.cell_selection() Row indices of selection
@reactive.calc
def selected_rows():
    # require at least one row selected
    df = tbl.data_view(selected=True)
    req(not df.empty)
    return df

req() stops execution silently if the condition is “falsy” - clean way to handle “nothing selected yet”.

Row selection -> reactive output

Set selection_mode="rows" to let users select rows.

Access selected rows with .data_view(selected=True):

@render.data_frame
def my_table():
    return render.DataGrid(df, selection_mode="rows")

@reactive.calc
def selected():
    return my_table.data_view(selected=True)

data_view(selected=True) returns selected rows with any sorting/filtering the user has applied — it is the data as the user sees it.

Example 2: Row selection drives a chart

flowchart LR
  A([my_table]) -- ".data_view<br>selected=True" --> P([scatter])

A rendered table can also be a reactive source

  • my_table is a @render.data_frame output — but it also exposes .data_view(selected=True)
  • When the user selects rows, any output that calls .data_view(selected=True) is invalidated and re-runs
  • scatter re-renders with the selected rows highlighted — no separate input.* needed
  • The selection lives inside the rendered table; Shiny tracks it automatically
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 560
#| packages: [vega_datasets, altair, shinywidgets]

import pandas as pd
import altair as alt
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req
from shinywidgets import output_widget, render_altair

cars = pd.DataFrame(vega_data.cars()).iloc[:, :5]

app_ui = ui.page_fluid(
    ui.h4("Select rows to highlight them in the chart"),
    ui.layout_columns(
        ui.card(ui.output_data_frame("tbl"), height="300px"),
        ui.card(output_widget("scatter")),
        col_widths=[5, 7],
    ),
)

def server(input, output, session):
    @render.data_frame
    def tbl():
        return render.DataGrid(cars, selection_mode="rows", height="250px")

    @render_altair
    def scatter():
        selected = tbl.data_view(selected=True)
        base = alt.Chart(cars).mark_circle(color="#D1D5DB", size=60).encode(
            x=alt.X("Miles_per_Gallon:Q"),
            y=alt.Y("Horsepower:Q"),
            tooltip=["Name:N", "Miles_per_Gallon:Q", "Horsepower:Q"],
        )
        if selected is None or selected.empty:
            return base.properties(width="container", height=260)
        highlight = alt.Chart(selected).mark_circle(color="#3B82F6", size=80).encode(
            x="Miles_per_Gallon:Q",
            y="Horsepower:Q",
        )
        return (base + highlight).properties(width="container", height=260)

app = App(app_ui, server)

Example 2: Row selection drives a chart

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 560
#| packages: [vega_datasets, altair, shinywidgets]

import pandas as pd
import altair as alt
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req
from shinywidgets import output_widget, render_altair

cars = pd.DataFrame(vega_data.cars()).iloc[:, :5]

app_ui = ui.page_fluid(
    ui.h4("Select rows to highlight them in the chart"),
    ui.layout_columns(
        ui.card(ui.output_data_frame("tbl"), height="300px"),
        ui.card(output_widget("scatter")),
        col_widths=[5, 7],
    ),
)

def server(input, output, session):
    @render.data_frame
    def tbl():
        return render.DataGrid(cars, selection_mode="rows", height="250px")

    @render_altair
    def scatter():
        selected = tbl.data_view(selected=True)
        base = alt.Chart(cars).mark_circle(color="#D1D5DB", size=60).encode(
            x=alt.X("Miles_per_Gallon:Q"),
            y=alt.Y("Horsepower:Q"),
            tooltip=["Name:N", "Miles_per_Gallon:Q", "Horsepower:Q"],
        )
        if selected is None or selected.empty:
            return base.properties(width="container", height=260)
        highlight = alt.Chart(selected).mark_circle(color="#3B82F6", size=80).encode(
            x="Miles_per_Gallon:Q",
            y="Horsepower:Q",
        )
        return (base + highlight).properties(width="container", height=260)

app = App(app_ui, server)

Example 3: Linked table + chart

flowchart LR
  A[/input_origin/] --> F{{filtered}}
  F --> P1([my_table])
  F --> P2([scatter])
  P1 -- ".data_view<br>selected=True" --> P2

@reactive.calc shares the selected rows between table and chart — the same pattern as with maps.

  • filtered() runs once when input.origin() changes — both my_table and scatter share the cached result
  • scatter has two reactive dependencies: filtered() (the full filtered set, grey points) and my_table.data_view(selected=True) (highlighted points)
  • Changing the dropdown re-runs filtered(), which invalidates both outputs at once
  • Selecting rows only invalidates scattermy_table itself does not re-render
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 620
#| packages: [vega_datasets, altair, shinywidgets]

import pandas as pd
import altair as alt
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req
from shinywidgets import output_widget, render_altair

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]

app_ui = ui.page_fluid(
    ui.h4("Cars explorer"),
    ui.input_select("origin", "Origin", choices=["All"] + origins),
    ui.layout_columns(
        ui.card(
            ui.card_header("Data — select rows to highlight"),
            ui.output_data_frame("tbl"),
        ),
        ui.card(
            ui.card_header("MPG vs Horsepower"),
            output_widget("scatter"),
        ),
        col_widths=[5, 7],
    ),
)

def server(input, output, session):

    @reactive.calc
    def filtered():
        df = cars[display_cols]
        if input.origin() != "All":
            df = df[cars["Origin"] == input.origin()]
        return df

    @render.data_frame
    def tbl():
        return render.DataGrid(filtered(), selection_mode="rows", height="300px")

    @render_altair
    def scatter():
        df = filtered()
        selected = tbl.data_view(selected=True)
        base = alt.Chart(df).mark_circle(color="#D1D5DB", size=60).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
            tooltip=display_cols,
        )
        if selected is None or selected.empty:
            return base.properties(width="container", height=320)
        hi = alt.Chart(selected).mark_circle(color="#3B82F6", size=90).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
        )
        return (base + hi).properties(width="container", height=320)

app = App(app_ui, server)

Example 3: Linked table + chart: full example

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| viewerHeight: 620
#| packages: [vega_datasets, altair, shinywidgets]

import pandas as pd
import altair as alt
from vega_datasets import data as vega_data
from shiny import App, ui, render, reactive, req
from shinywidgets import output_widget, render_altair

cars = pd.DataFrame(vega_data.cars())
origins = sorted(cars["Origin"].unique())
display_cols = ["Name", "Miles_per_Gallon", "Cylinders", "Horsepower", "Origin"]

app_ui = ui.page_fluid(
    ui.h4("Cars explorer"),
    ui.input_select("origin", "Origin", choices=["All"] + origins),
    ui.layout_columns(
        ui.card(
            ui.card_header("Data — select rows to highlight"),
            ui.output_data_frame("tbl"),
        ),
        ui.card(
            ui.card_header("MPG vs Horsepower"),
            output_widget("scatter"),
        ),
        col_widths=[5, 7],
    ),
)

def server(input, output, session):

    @reactive.calc
    def filtered():
        df = cars[display_cols]
        if input.origin() != "All":
            df = df[cars["Origin"] == input.origin()]
        return df

    @render.data_frame
    def tbl():
        return render.DataGrid(filtered(), selection_mode="rows", height="300px")

    @render_altair
    def scatter():
        df = filtered()
        selected = tbl.data_view(selected=True)
        base = alt.Chart(df).mark_circle(color="#D1D5DB", size=60).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
            tooltip=display_cols,
        )
        if selected is None or selected.empty:
            return base.properties(width="container", height=320)
        hi = alt.Chart(selected).mark_circle(color="#3B82F6", size=90).encode(
            x="Miles_per_Gallon:Q", y="Horsepower:Q",
        )
        return (base + hi).properties(width="container", height=320)

app = App(app_ui, server)