2025, Oct 16 06:00
Dynamic UI in Python Shiny: Add Squares and Remove Them with Per-Button reactive.event Handlers
Learn how to build dynamic UI in Python Shiny with per-element remove buttons. Bind reactive.event handlers on creation to observe clicks and remove tiles.
Dynamic UI in Python Shiny looks easy until you need per-element actions. A global “Add” button is straightforward, but individual remove controls must trigger their own events. The typical stumbling block is wiring up a click on each dynamic “X” button so that it removes its own element without affecting the rest.
Problem setup
The goal is to add square-like widgets with a single “Add Square” button and to remove any square through its own corner “X”. Adding works; observing the clicks on each individual “X” is where it gets tricky. Below is a minimal version that demonstrates the issue.
from shiny import App, ui, reactive
# UI
app_view = ui.page_fluid(
    ui.input_action_button("spawn_tile", "Add Square", class_="btn btn-primary"),
    ui.div(id="tile_area", class_="wrap"),
    ui.tags.style(
        """
        .tile {
            width: 120px;
            height: 120px;
            background-color: #f9f9f9;
            border-radius: 15%;
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 10px;
            box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
            position: relative;
        }
        .wrap {
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            margin-top: 20px;
            justify-content: center;
        }
        .rm-btn {
            position: absolute;
            top: 5px;
            right: 5px;
            background-color: #ff4d4d;
            color: white;
            border: none;
            border-radius: 50%;
            width: 20px;
            height: 20px;
            font-size: 12px;
            cursor: pointer;
            text-align: center;
            line-height: 17px;
            padding: 0;
        }
        .rm-btn:hover {
            background-color: #e60000;
        }
        """
    ),
)
# Server
def backend(inputs, outputs, sess):
    ids_store = reactive.Value([])
    @reactive.Effect
    @reactive.event(inputs.spawn_tile)
    def make_tile():
        current = ids_store.get()
        new_key = max(current, default=0) + 1
        current.append(new_key)
        ids_store.set(current)
        ui.insert_ui(
            ui=ui.div(
                ui.TagList(
                    ui.input_text(f"label_{new_key}", None, placeholder=f"Square {new_key}"),
                    ui.input_action_button(f"rm_{new_key}", "X", class_="rm-btn"),
                ),
                id=f"tile_{new_key}",
                class_="tile",
            ),
            selector="#tile_area",
            where="beforeEnd",
        )
    @reactive.effect
    def watch_rm():
        keys = ids_store.get()
        for k in keys:
            if inputs.get(f"rm_{k}", 0) > 0:
                ui.remove_ui(selector=f"#tile_{k}")
                remaining = [x for x in keys if x != k]
                ids_store.set(remaining)
                sess.reset_input(f"rm_{k}")
                break
app = App(app_view, backend)
What’s actually going on
Each “X” is created dynamically, but the logic that attempts to catch its click iterates over a list and checks counters. That pattern doesn’t attach a dedicated event source per button. For reliably reacting to a specific dynamic button click, the event must be explicitly registered for that specific input once the button exists.
The fix: bind a per-button reactive event when the button is created
The straightforward pattern is to register a new reactive function for each freshly created “X” using reactive.event(getattr(input, f"…")). This binds the handler to that exact button’s input so that clicking the “X” triggers removal of its own element.
from shiny import App, ui, reactive
app_view = ui.page_fluid(
    ui.input_action_button("spawn_tile", "Add Square", class_="btn btn-primary"),
    ui.div(id="tile_area", class_="wrap"),
    ui.tags.style(
        """
        .tile { width: 120px; height: 120px; background-color: #f9f9f9; border-radius: 15%; display: flex; justify-content: center; align-items: center; margin: 10px; box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1); position: relative; }
        .wrap { display: flex; flex-wrap: wrap; gap: 15px; margin-top: 20px; justify-content: center; }
        .rm-btn { position: absolute; top: 5px; right: 5px; background-color: #ff4d4d; color: white; border: none; border-radius: 50%; width: 20px; height: 20px; font-size: 12px; cursor: pointer; text-align: center; line-height: 17px; padding: 0; }
        .rm-btn:hover { background-color: #e60000; }
        """
    ),
)
def backend(inputs, outputs, sess):
    ids_store = reactive.Value([])
    @reactive.Effect
    @reactive.event(inputs.spawn_tile)
    def make_tile():
        current = ids_store.get()
        new_key = max(current, default=0) + 1
        current.append(new_key)
        ids_store.set(current)
        ui.insert_ui(
            ui=ui.div(
                ui.TagList(
                    ui.input_text(f"label_{new_key}", None, placeholder=f"Square {new_key}"),
                    ui.input_action_button(f"rm_{new_key}", "X", class_="rm-btn"),
                ),
                id=f"tile_{new_key}",
                class_="tile",
            ),
            selector="#tile_area",
            where="beforeEnd",
        )
        @reactive.effect
        @reactive.event(getattr(inputs, f"rm_{new_key}"))
        def handle_remove():
            ui.remove_ui(selector=f"#tile_{new_key}")
app = App(app_view, backend)
This pattern creates a dedicated reactive effect for each button right after the element is inserted, ensuring the click on that button is properly observed.
Why you should care
When UI is created at runtime, inputs don’t exist at startup. Event binding has to happen at the moment of creation. Without explicitly registering an event for each dynamic input, click handling becomes brittle and hard to reason about. Binding a per-button reactive.event keeps the logic local and predictable.
One caveat about identifiers
There is an important practical note raised in the approach above: if an element with the maximum ID is deleted and a new element later reuses the same ID, the same handler name would be created twice for the same ID, which can cause issues. Keep that in mind when managing how IDs are generated and reused.
Takeaways
For individually removable dynamic elements in Python Shiny, attach a per-element reactive handler immediately after rendering the element. Use reactive.event(getattr(input, f"…")) to bind to the correct button. Be mindful of potential ID reuse, as recreating handlers for the same identifier may lead to unexpected behavior.
The article is based on a question from StackOverflow by Goldjunge3010 and an answer by furas.