DEV Community

India Owens
India Owens

Posted on

Action Buttons in Dataframes with Record-Level Routing in Shiny for Python

Why this is more useful than a modal

This is Part 3 of the Shiny for Python: The Missing Manual series. Part 1 covered Quill.js rich text input, Part 2 covered multi-page routing with Starlette. This article builds directly on Part 2's routing setup — read that first if you haven't.

Adding action buttons to a dataframe is actually reasonably documented for Shiny — if you're using R. The Python documentation is sparse, and almost every tutorial I found stops at the same place: a button that triggers a modal dialog showing the record.

Modals are fine for simple use cases. But in a real internal tool, you often want something closer to how a normal web application behaves: a View button that opens a dedicated read-only page for the record, an Edit button that opens a form pre-populated with that record's data, and a Delete button that removes it in place. Each page lives at its own URL, the record ID is passed as a query parameter, and the server reads it to fetch the right data.

This article builds directly on the routing setup from Part 2. If you haven't read that, the short version is: we're using Starlette to mount multiple Shiny apps at different routes, all sharing a single server function. Here we're adding two new routes — /view_record and /edit_record — and wiring the dataframe buttons to navigate to them.

Two types of buttons, two different approaches

The three buttons we're building — View, Edit, Delete — fall into two distinct categories. Understanding the difference before you write any code will save you from a confusing debugging session.

Button Type How it works Why
View Link (<a> tag) Navigates to /view_record/?record_id=N in a new tab No action needed — just navigation with a param
Edit Link (<a> tag) Navigates to /edit_record/?record_id=N in a new tab Same — destination page handles the logic
Delete Button + JavaScript JS calls Shiny.setInputValue(), Python reactive fires Must trigger server-side logic in the current session

View and Edit are pure navigation — they don't need to talk to the Shiny server at all. Delete is different: it needs to actually execute something on the server, update the database, and refresh the table in the current page. That's why it uses the same JavaScript bridge pattern from Part 1.

Updated architecture

Starlette routes

main.py (Starlette)

├─ /submit ──────▶
submit.py

├─ /view ────────▶
view.py      
← buttons rendered here

├─ /view_record ─▶
view_record.py
?record_id=N

├─ /edit_record ─▶
edit_record.py
?record_id=N

└─ / ────────────▶
redirect → /submit

All pages import ▼

server.py (shared reactive logic + delete handler)

Reads/writes ▼

myshinyapp.db (SQLite)
Enter fullscreen mode Exit fullscreen mode

Step 1: Add the two new page files

These are minimal — each one defines a navbar UI and imports the shared server. The actual content is rendered dynamically by the server function using the record ID from the URL.

view_record.py

view_record.py

from shiny import App, ui
from server import server_function

display_ui = ui.page_navbar(
    ui.nav_control(ui.a("← Back to Main", href="../submit")),
    ui.nav_panel(
        "Record View",
        ui.output_ui("view_record")
    )
)

app = App(display_ui, server_function)
Enter fullscreen mode Exit fullscreen mode

edit_record.py

edit_record.py

from shiny import App, ui
from server import server_function

display_ui = ui.page_navbar(
    ui.nav_control(ui.a("← Back to Main", href="../submit")),
    ui.nav_panel(
        "Record Edit",
        ui.output_ui("edit_record")
    )
)

app = App(display_ui, server_function)
Enter fullscreen mode Exit fullscreen mode

Step 2: Register the new routes in main.py

main.py — updated

from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.responses import RedirectResponse
from submit      import app as form_app
from view        import app as display_app
from view_record import app as view_record_app  # new
from edit_record import app as edit_record_app  # new

async def homepage_redirect(request):
    root_path = request.scope.get("root_path", "")
    return RedirectResponse(url=f"{root_path}/submit")

routes = [
    Mount("/submit",      app=form_app),
    Mount("/view",        app=display_app),
    Mount("/view_record", app=view_record_app),  # new
    Mount("/edit_record", app=edit_record_app),  # new
    Route("/",            endpoint=homepage_redirect),
]

app = Starlette(routes=routes)
Enter fullscreen mode Exit fullscreen mode

Step 3: Render the action buttons in the dataframe

The buttons are injected into the dataframe as raw HTML using ui.HTML() applied row-by-row with df.apply(). Each row gets three buttons: View and Edit as anchor tags with the record ID baked into the URL, and Delete as a button that fires a JavaScript callback.

💡 Where this lives The action_buttons() helper and the JavaScript file both live in server.py and js/buttons.js respectively. The helper is called every time the table is (re)rendered — including after a delete.

server.py — action_buttons helper

server.py (excerpt)

from urllib.parse import parse_qs
from shiny.ui import icon_svg  # Bootstrap icons built into Shiny

def action_buttons(df):
    """Inject View / Edit / Delete buttons into each row of the dataframe."""
    df['action'] = df.apply(
        lambda row: ui.HTML(
            f"""
            <div class="btn-group" role="group">
              <!-- View: anchor tag, opens record detail in new tab -->
              <a class="btn btn-sm btn-outline-secondary"
                 href="/view_record/?record_id={row['id']}"
                 target="_blank"
                 title="View">
                {icon_svg("eye")}
              </a>
              <!-- Edit: anchor tag, opens edit form in new tab -->
              <a class="btn btn-sm btn-outline-secondary"
                 href="/edit_record/?record_id={row['id']}"
                 target="_blank"
                 title="Edit">
                {icon_svg("pencil")}
              </a>
              <!-- Delete: button + JS callback, acts in current session -->
              <button class="btn btn-sm btn-outline-danger"
                      id="delete_{row['id']}"
                      title="Delete"
                      type="button"
                      onclick="get_delete_id(this.id)">
                {icon_svg("trash")}
              </button>
            </div>
            """
        ),
        axis=1
    )
    return df
Enter fullscreen mode Exit fullscreen mode

Step 4: The JavaScript delete bridge

The Delete button can't use an anchor tag because it needs to trigger server-side logic — not navigate. The pattern is the same as the Quill bridge in Part 1: JavaScript captures the click, reads the button's ID to extract the record ID, and pushes it into Shiny's reactive graph via setInputValue().

js/buttons.js

js/buttons.js

function get_delete_id(clicked_id) {
    // clicked_id looks like "delete_5"
    // We push the whole string and parse it on the Python side
    console.log("Delete clicked:", clicked_id);
    Shiny.setInputValue('current_delete_id', clicked_id, { priority: 'event' });
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Don't forget to include the JS file Add ui.include_js(Path(__file__).parent / "js" / "buttons.js") to the ui.page_navbar() call in view.py — the page that renders the table. Without it, clicking Delete silently does nothing.

⚠️ render.data_frame not render.table render.table strips custom HTML from cell content — your ui.HTML() buttons will silently disappear with no error. Use render.data_frame and ui.output_data_frame() instead, which preserves the button markup.

server.py — data_table render function

server.py (excerpt)

# IMPORTANT: @render.data_frame not @render.table
    # render.table strips custom HTML — your buttons will silently disappear
    @render.data_frame
    @reactive.event(input.refresh, ignore_none=False)
    def data_table():
        with sqlite3.connect(DB_NAME) as conn:
            df = pd.read_sql_query(
                "SELECT * FROM name ORDER BY id DESC", conn
            )
        return action_buttons(df)
Enter fullscreen mode Exit fullscreen mode

Step 5: The delete handler in server.py

When JavaScript fires setInputValue('current_delete_id', ...), Shiny's reactive system picks it up and the handler below runs. It parses the record ID out of the button ID string, deletes the row, and re-renders the table with fresh data.

server.py — delete handler

server.py (excerpt)

@reactive.effect
@reactive.event(input.current_delete_id)
async def delete_record():
    button_id_str = input.current_delete_id()

    try:
        # "delete_5" → split on "_" → take index 1 → cast to int
        record_id = int(button_id_str.split("_")[1])

        with sqlite3.connect(DB_NAME) as conn:
            conn.execute("DELETE FROM name WHERE id = ?", (record_id,))
            ui.notification_show(f"Record {record_id} deleted.", type="warning")

            # Re-fetch and re-render the table with buttons injected
            df = pd.read_sql_query(
                "SELECT * FROM name ORDER BY id DESC", conn
            )
            await data_tables.update_data(action_buttons(df))

    except Exception as e:
        ui.notification_show(f"Delete failed: {e}", type="error")
Enter fullscreen mode Exit fullscreen mode

Step 6: Reading the record ID from the URL

When a user clicks View or Edit, the browser navigates to a new tab at /view_record/?record_id=5. The server function on that page needs to extract the ID from the URL query string and use it to fetch the right record. Shiny exposes this through session.clientdata.url_search().

Because multiple render functions on the page may need this ID, we store it in a reactive.value that gets set once on page load and read wherever it's needed.

server.py — extracting the URL param

server.py (excerpt)

from urllib.parse import parse_qs

# Reactive value to hold the record ID — set once, read anywhere
id = reactive.value("")

@reactive.effect
def initialize_from_query_params():
    # url_search() returns the raw query string, e.g. "?record_id=5"
    url_query = session.clientdata.url_search()
    params = parse_qs(url_query.lstrip("?"))

    if "record_id" in params:
        id.set(params["record_id"][0])
Enter fullscreen mode Exit fullscreen mode

✅ Why reactive.value and not a local variable reactive.value integrates with Shiny's reactive graph — any render function that calls id.get() will automatically re-run if the value changes. A plain Python variable won't trigger re-renders.

Step 7: The view and edit render functions

Both functions follow the same pattern: get the ID, query the database with a parameterized query (never string interpolation — SQL injection is a real risk even in internal tools), handle the empty case gracefully, then return Shiny UI elements.

server.py — view_record render function

server.py (excerpt)

@render.ui
def view_record():
    record_id = id.get()

    if not record_id:
        return ui.p("No record selected.")

    with sqlite3.connect(DB_NAME) as conn:
        record = pd.read_sql_query(
            "SELECT * FROM name WHERE id = ?",
            conn,
            params=(record_id,)
        )

    if record.empty:
        return ui.p(f"Record {record_id} not found.")

    return ui.div(
        ui.h2(f"Record {record_id}"),
        ui.p(ui.strong("First Name: "), record.at[0, 'first_name']),
        ui.p(ui.strong("Last Name: "),  record.at[0, 'last_name']),
    )
Enter fullscreen mode Exit fullscreen mode

server.py — edit_record render function + update handler

server.py (excerpt)

@render.ui
def edit_record():
    record_id = id.get()

    if not record_id:
        return ui.p("No record selected.")

    with sqlite3.connect(DB_NAME) as conn:
        record = pd.read_sql_query(
            "SELECT * FROM name WHERE id = ?",
            conn,
            params=(record_id,)
        )

    if record.empty:
        return ui.p(f"Record {record_id} not found.")

    return ui.div(
        ui.TagList(
            ui.h2(f"Edit Record {record_id}"),
            ui.input_text("first_name", "First Name", value=record.at[0, 'first_name']),
            ui.input_text("last_name",  "Last Name",  value=record.at[0, 'last_name']),
            ui.input_action_button("submit_update", "Save Changes", class_="btn-primary"),
            ui.output_text("status"),
        )
    )

@reactive.effect
@reactive.event(input.submit_update)
def submit_update():
    try:
        with sqlite3.connect(DB_NAME) as conn:
            conn.execute(
                "UPDATE name SET first_name = ?, last_name = ? WHERE id = ?",
                (input.first_name(), input.last_name(), id.get())
            )
            conn.commit()
    except Exception as e:
        ui.notification_show(f"Update failed: {e}", type="error")

@render.text
def status():
    if input.submit_update() > 0:
        return f"Record {id.get()} updated successfully."
    return ""
Enter fullscreen mode Exit fullscreen mode

Gotchas worth knowing

  • Use render.data_frame, not render.table. This is the most common gotcha with this pattern. render.table strips custom HTML from cell content — the ui.HTML() buttons you inject via action_buttons() will silently disappear and you'll have no idea why. render.data_frame preserves the button markup. Use ui.output_data_frame() in your UI to match.
  • Always use parameterized queries. Never use f-strings to interpolate record IDs directly into SQL. WHERE id = ? with params=(record_id,) is the correct pattern — even for internal tools.
  • The shared server means input IDs must be unique across pages. If view.py and edit_record.py both have an input named first_name, they won't conflict because each page is its own Shiny session — but be aware that the server function handles inputs from all pages, so naming collisions in your render logic can cause subtle bugs.
  • View and Edit open in a new tab (target="_blank"). This is intentional — the user stays on the table page and reviews or edits records in separate tabs. Remove target="_blank" if you want in-place navigation instead.
  • The delete handler re-injects buttons after every delete. Always pass the dataframe through action_buttons() before calling data_table.update_data(). If you forget, the refreshed table will render without buttons.
  • id.get() returns an empty string on pages where no record ID is in the URL. The if not record_id guard at the top of each render function handles this gracefully — without it, the SQL query will run with an empty string and return confusing results.

What you now have

A fully functional multi-page CRUD interface in Shiny for Python: a table with per-row View, Edit, and Delete buttons, dedicated pages for record detail and editing, URL-parameter-driven data loading, and in-place deletes that refresh the table without a page reload. This is the pattern most internal data tools actually need, and it's almost entirely undocumented for the Python version of Shiny.

That wraps up the Shiny for Python: The Missing Manual series. Part 1 covered extending Shiny with Quill.js, Part 2 covered multi-page routing with Starlette, and this article tied them together into a full CRUD interface. The complete working code for all three parts is available on GitHub.


References

  1. Adding Action Buttons in Rows of DT Data Table in R Shiny — That Data Though (R — the foundational tutorial that inspired this approach)
  2. Shiny for Python — session.ClientData API Reference — Posit (used for url_search())

Top comments (0)