2025, Oct 16 06:17

Удаление динамических элементов в Python Shiny по кнопке X

Как надёжно обрабатывать клики по кнопкам X у динамических элементов в Python Shiny: пер-элементные обработчики через reactive.event, insert_ui и remove_ui.

Динамический интерфейс в Python Shiny кажется простым, пока не понадобятся действия на уровне каждого элемента. Глобальная кнопка «Add» реализуется без труда, а вот отдельные элементы удаления должны запускать собственные события. Самое частое затруднение — правильно привязать клик по каждой динамической кнопке «X», чтобы она удаляла только свой элемент и не затрагивала остальные.

Постановка задачи

Нужно добавлять квадратные виджеты одной кнопкой «Add Square» и уметь удалять любой квадрат его собственным угловым «X». Добавление работает; сложность начинается при попытке отследить клики по каждому конкретному «X». Ниже — минимальный пример, показывающий проблему.

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
    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)

Что на самом деле происходит

Каждый «X» создаётся динамически, но логика, которая пытается отловить клик, просто перебирает список и проверяет счётчики. Такой подход не закрепляет отдельный источник события за конкретной кнопкой. Чтобы надёжно реагировать на клик именно нужной динамической кнопки, событие нужно явно регистрировать для этого конкретного input сразу после появления кнопки.

Решение: привязывать реактивное событие для каждой кнопки в момент её создания

Простой и надёжный приём — при создании каждой новой кнопки «X» регистрировать отдельную реактивную функцию с помощью reactive.event(getattr(input, f"…")). Так обработчик привязывается ровно к этой кнопке, и клик по «X» удаляет именно её элемент.

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)

Этот шаблон создаёт отдельный реактивный эффект для каждой кнопки сразу после вставки элемента, поэтому клик по этой кнопке корректно отслеживается.

Почему это важно

Когда интерфейс создаётся во время работы приложения, входные элементы изначально отсутствуют. Привязка событий должна происходить в момент создания. Если не регистрировать событие для каждого динамического input явно, обработка кликов становится ненадёжной и запутанной. Локальная привязка через per-button reactive.event делает логику предсказуемой.

Один нюанс про идентификаторы

Есть практический момент: если элемент с максимальным ID удалён, а позже новый элемент снова получает тот же ID, для этого идентификатора будет дважды создан обработчик с тем же именем — это может привести к проблемам. Учитывайте это при генерации и повторном использовании ID.

Итоги

Чтобы динамические элементы в Python Shiny можно было удалять по одному, сразу после рендеринга прикрепляйте к каждому элементу отдельный реактивный обработчик. Используйте reactive.event(getattr(input, f"…")) для привязки к нужной кнопке. Следите за повторным использованием идентификаторов: повторное создание обработчиков для того же ID может вызывать неожиданные эффекты.

Статья основана на вопросе на StackOverflow от Goldjunge3010 и ответе пользователя furas.