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)
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)
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)
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)
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 inserver.pyandjs/buttons.jsrespectively. 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
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' });
}
⚠️ Don't forget to include the JS file Add
ui.include_js(Path(__file__).parent / "js" / "buttons.js")to theui.page_navbar()call inview.py— the page that renders the table. Without it, clicking Delete silently does nothing.⚠️ render.data_frame not render.table
render.tablestrips custom HTML from cell content — yourui.HTML()buttons will silently disappear with no error. Userender.data_frameandui.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)
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")
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])
✅ Why reactive.value and not a local variable
reactive.valueintegrates with Shiny's reactive graph — any render function that callsid.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']),
)
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 ""
Gotchas worth knowing
-
Use
render.data_frame, notrender.table. This is the most common gotcha with this pattern.render.tablestrips custom HTML from cell content — theui.HTML()buttons you inject viaaction_buttons()will silently disappear and you'll have no idea why.render.data_framepreserves the button markup. Useui.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 = ?withparams=(record_id,)is the correct pattern — even for internal tools. -
The shared server means input IDs must be unique across pages. If
view.pyandedit_record.pyboth have an input namedfirst_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. Removetarget="_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 callingdata_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. Theif not record_idguard 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
- Adding Action Buttons in Rows of DT Data Table in R Shiny — That Data Though (R — the foundational tutorial that inspired this approach)
-
Shiny for Python — session.ClientData API Reference — Posit (used for
url_search())
Top comments (0)