Le bon pattern pour ajouter des boutons par ligne dans un DataTable Shiny

Auteur : Charles Bordet Mise à jour : 13 May 2026

On a tous eu ce besoin.

Un tableau avec des lignes. Sur chaque ligne, un bouton « Éditer ». Un bouton « Supprimer ». Peut-être un bouton « Voir le détail ». Des trucs basiques.

En Shiny, afficher un tableau, c’est trivial : DT::datatable(). Mais dès qu’on veut des boutons cliquables dans les cellules, on se heurte à un vrai problème d’architecture. Pas un problème de CSS ou de JavaScript. Un problème de réactivité Shiny.

Je vais vous montrer le chemin que j’ai parcouru ces dernières années pour en arriver au pattern que j’utilise aujourd’hui, celui qui tient dans une fonction de 15 lignes, qui est vectorisé, qui gère les clics répétés sans bug, et qu’on réutilise dans tous nos modules CRUD.

Le réflexe (qui ne marche pas)

Quand on débute en Shiny, on pense en termes de composants Shiny : actionButton(), observeEvent(), terminé.

Alors pour un tableau avec 10 lignes, on se dit : « Je vais créer 10 actionButton(), un par ligne, chacun avec un ID unique. Puis j’aurai 10 observeEvent() côté serveur. »

# UI : on génère un actionButton par ligne
for (i in seq_len(nrow(data))) {
    output[[paste0("edit_", data$id[i])]] <- renderUI({
        actionButton(
            inputId = ns(paste0("edit_", data$id[i])),
            label = "Éditer"
        )
    })
}

# Server : un observeEvent par bouton
for (i in seq_len(nrow(data))) {
    observeEvent(input[[paste0("edit_", data$id[i])]], {
        # ... ouvrir la modale d'édition
    })
}

Ça marche. Sur 10 lignes, c’est même fluide.

Maintenant, essayez avec 200 lignes. Ou avec un tableau dont le nombre de lignes change à chaque filtre.

Trois problèmes apparaissent immédiatement :

  1. On ne sait pas combien de boutons on aura. Les données viennent de la base, le nombre de lignes varie. Il faut créer dynamiquement les observeEvent(), ce qui est déjà un signal d’alarme.
  2. Les observeEvent sont créés dans une boucle, souvent dans un observe() qui se re-déclenche à chaque changement de données. On empile des observers qui ne sont jamais détruits, on crée des fuites de réactivité.
  3. C’est lent. Chaque actionButton est un input Shiny à part entière, avec son binding JavaScript, son ID dans le DOM, sa gestion côté serveur. Multipliez par le nombre de lignes, par le nombre de types de boutons (éditer + supprimer + …), et l’app se traîne.

Ce pattern, c’est celui qu’on trouve dans les tutoriels Stack Overflow de 2018. Il est compréhensible. Il est intuitif. Et il ne passe pas à l’échelle.

L’idée : un seul input, N boutons

La solution, c’est de contourner le système d’inputs natif de Shiny.

Au lieu de créer un actionButton() par ligne (un composant Shiny avec un ID unique), on génère un simple bouton HTML <button> avec un onclick qui appelle directement l’API JavaScript de Shiny :

Shiny.setInputValue('mon_input', 42);

Shiny.setInputValue() est une fonction JavaScript exposée par Shiny. Elle permet de créer ou mettre à jour n’importe quel input depuis le navigateur, sans passer par les bindings habituels.

Concrètement, un bouton complet ressemble à ça :

<button type="button" onclick="Shiny.setInputValue('mon_input', 42)">
    Éditer
</button>

L’attribut onclick est un attribut HTML standard : il contient du code JavaScript que le navigateur exécute à chaque clic sur le bouton. Ici, à chaque clic, le navigateur appelle Shiny.setInputValue('mon_input', 42), ce qui met à jour l’input mon_input côté serveur avec la valeur 42. Pas de binding Shiny, pas d’ID unique : juste un bouton HTML qui écrit dans un input quand on clique dessus.

On peut donc créer un unique input côté serveur, et N boutons HTML qui écrivent tous dans ce même input, chacun avec une valeur différente (l’ID de la ligne).

# Un seul observeEvent pour tous les boutons "éditer"
observeEvent(input$edit_row, {
    row_id <- input$edit_row
    # ... ouvrir la modale pour la ligne row_id
})

Plus besoin de boucle. Plus besoin de créer dynamiquement des observers. Un seul observeEvent, quel que soit le nombre de lignes dans le tableau.

Ça semble parfait. Sauf qu’il y a un piège.

Le piège du cache Shiny

Essayez de cliquer deux fois de suite sur le bouton « Éditer » de la même ligne.

La première fois, Shiny.setInputValue('edit_row', 42) envoie la valeur 42. L’observeEvent se déclenche. Tout va bien.

La deuxième fois, Shiny.setInputValue('edit_row', 42) envoie… encore 42. Même valeur. Et Shiny, qui est malin, se dit : « la valeur n’a pas changé, pas besoin de notifier les observers ».

Résultat : le deuxième clic ne fait rien.

C’est le comportement par défaut de Shiny. Un input ne notifie ses dépendances que quand sa valeur change. Si on re-envoie la même valeur, pas de notification.

Il y a deux solutions connues. La première, qu’on trouve dans beaucoup de tutoriels, consiste à reset l’input après chaque traitement :

observeEvent(input$edit_row, {
    row_id <- input$edit_row
    # ... traitement ...

    # Reset via un message JS custom
    session$sendCustomMessage("resetInputValue", ns("edit_row"))
})

Avec le handler JavaScript correspondant :

Shiny.addCustomMessageHandler("resetInputValue", function(name) {
    Shiny.setInputValue(name, null);
});

Ça marche. Mais c’est lourd : il faut enregistrer un custom message handler, ne pas oublier le reset dans chaque observeEvent, ni d’entourer l’input avec ns().

Il existe une solution plus élégante.

Le nonce : la solution propre

Plutôt que de reset l’input après chaque clic, on s’assure que chaque clic envoie une valeur différente, même si c’est le même bouton.

Le truc : au lieu d’envoyer juste l’ID de la ligne, on envoie un objet JavaScript avec l’ID et un nonce (une valeur unique générée à chaque clic) :

Shiny.setInputValue('edit_row', {
    id: '42',
    nonce: Date.now()
}, {priority: 'event'});

Trois ingrédients dans cette ligne :

{id: '42', nonce: Date.now()} : La valeur envoyée à Shiny. C’est un objet avec deux champs : id contient l’identifiant de la ligne (ce qui nous intéresse), et nonce contient le timestamp en millisecondes au moment du clic. Comme Date.now() retourne un nombre différent à chaque appel (sauf si vous cliquez deux fois en moins d’une milliseconde), la valeur globale de l’input est toujours différente. Shiny voit un changement et notifie les observers.

{priority: 'event'} : Le troisième argument de setInputValue. Par défaut, Shiny traite les changements d’inputs avec une priorité « deferred » : ils sont regroupés et envoyés au serveur à la fin du cycle de flush. Avec priority: 'event', le changement est envoyé immédiatement, comme un événement. C’est ce qui garantit que le clic est traité sans délai perceptible. Et c’est aussi ce qui assure que même un clic identique dans un intervalle très court sera traité comme un nouvel événement, pas fusionné avec le précédent.

Côté serveur, on récupère l’objet complet sous forme de liste R :

observeEvent(input$edit_row, {
    row_id <- as.integer(input$edit_row$id)
    # ... traitement avec row_id
})

input$edit_row est une liste avec $id et $nonce. On ne lit que $id, le nonce a fait son travail (rendre la valeur unique) et on l’ignore.

La fonction utilitaire

Maintenant qu’on a le mécanisme, il faut générer le HTML des boutons.

Voici la fonction qu’on utilise chez Data Champ’ :

#' Create an action button for a DataTable row
#'
#' Generates an HTML `<button>` that calls `Shiny.setInputValue` on click.
#' Vectorized over `value` via `sprintf`.
#'
#' @param input_id Namespaced input id (use `ns("edit_row")`).
#' @param value Row identifier (usually the primary key).
#' @param label Optional button label text.
#' @param icon Optional FontAwesome icon name (e.g. `"pen"`, `"trash"`).
#' @param class Full CSS class string for the button.
#' @return Character vector of HTML button strings.
create_table_action_button <- function(
    input_id,
    value,
    label = NULL,
    icon = NULL,
    class = "btn btn-sm btn-outline-secondary"
) {
    inner <- paste0(
        if (!is.null(icon)) paste0('<i class="fa-solid fa-', icon, '"></i> ') else "",
        if (!is.null(label)) label else ""
    )
    sprintf(
        paste0(
            '<button type="button" class="%s" ',
            'onclick="Shiny.setInputValue(\'%s\', ',
            '{id: \'%s\', nonce: Date.now()}, {priority: \'event\'})">',
            '%s</button>'
        ),
        class, input_id, value, inner
    )
}

Quelques points de conception :

Vectorisée grâce à sprintf. Le paramètre value peut être un vecteur (typiquement, la colonne id de votre data.table). sprintf itère nativement sur les arguments vectoriels : un appel, N boutons. Pas de lapply, pas de boucle.

inner précalculé une seule fois. Le contenu du bouton (icône + label) est le même pour toutes les lignes. On le construit une fois avant le sprintf, ce qui évite de le recalculer pour chaque ligne.

Classe CSS complète en paramètre. Le paramètre class prend la chaîne entière ("btn btn-sm btn-outline-primary"). On aurait pu préfixer automatiquement btn btn-sm et ne demander que la variante ("btn-outline-primary"), mais en pratique, avoir la classe complète donne plus de flexibilité quand on veut un bouton qui sort du moule (taille différente, style custom).

Icône et label sont tous les deux optionnels. On peut avoir un bouton avec juste une icône, juste un label, ou les deux. Chez nous, on utilise généralement les icônes seules dans les colonnes « actions » pour gagner de la place.

input_id est namespacé par l’appelant. La fonction attend un ns("edit_row"), pas un "edit_row" brut. C’est l’appelant (le module) qui connaît son namespace, pas la fonction utilitaire.

L’utilisation dans un module

Voici à quoi ressemble un module CRUD complet qui utilise cette fonction. C’est un vrai module tiré de notre codebase, simplifié pour la lisibilité.

Le renderDataTable

output$pillars_table <- DT::renderDataTable({
    dt <- pillars_list()

    display_dt <- dt[, .(id, label, target_percent)]
    display_dt[, actions := paste0(
        create_table_action_button(
            ns("edit_row"), id,
            icon = "pen", class = "btn btn-sm btn-outline-primary"
        ),
        " ",
        create_table_action_button(
            ns("delete_row"), id,
            icon = "trash", class = "btn btn-sm btn-outline-danger"
        )
    )]
    display_dt[, id := NULL]

    DT::datatable(
        data = display_dt,
        rownames = FALSE,
        colnames = c("Pilier", "% cible", ""),
        selection = "none",
        escape = FALSE,
        options = list(
            dom = "t",
            paging = FALSE,
            ordering = FALSE,
            columnDefs = list(list(
                targets = 2L,
                orderable = FALSE,
                searchable = FALSE,
                className = "dt-right"
            ))
        )
    )
})

Décortiquons.

Construction de la colonne actions. On utilise data.table pour ajouter une colonne actions au tableau d’affichage. Chaque cellule contient deux boutons (éditer + supprimer), séparés par un espace. Le paste0() concatène les deux chaînes HTML.

La colonne id sert à générer les boutons (c’est la valeur envoyée au clic), mais on la supprime ensuite du tableau d’affichage (display_dt[, id := NULL]) pour ne pas la montrer à l’utilisateur.

escape = FALSE est crucial. Par défaut, DT::datatable échappe le HTML dans les cellules (pour éviter les injections XSS). Ici, on veut que nos boutons soient rendus comme du HTML, pas affichés comme du texte brut. escape = FALSE désactive l’échappement sur toutes les colonnes.

Si vous avez des colonnes qui contiennent des données utilisateur (texte libre, noms, etc.) et que vous voulez échapper le HTML sur celles-là mais pas sur la colonne actions, vous pouvez utiliser un vecteur : escape = c(TRUE, TRUE, FALSE) — un booléen par colonne, dans l’ordre.

selection = "none" désactive la sélection de lignes par clic. Sans ça, chaque clic sur un bouton sélectionnerait aussi la ligne (surlignage bleu), ce qui est visuellement confus.

columnDefs cible la colonne actions (index 2, base 0) pour la rendre non triable, non cherchable, et alignée à droite. C’est du confort visuel.

Les observeEvent

Côté serveur, un observeEvent par type d’action :

# Édition
observeEvent(input$edit_row, {
    row_id <- as.integer(input$edit_row$id)
    dt <- pillars_list()
    row <- dt[id == row_id]
    req(nrow(row) == 1L)

    showModal(modalDialog(
        title = "Modifier le pilier",
        textInput(ns("form_label"), "Label", value = row$label),
        numericInput(
            ns("form_target_percent"), "% cible",
            value = row$target_percent, min = 0, max = 100, step = 1
        ),
        footer = tagList(
            modalButton("Annuler"),
            actionButton(ns("form_submit"), "Enregistrer", class = "btn-primary")
        )
    ))
})

# Suppression (avec confirmation)
observeEvent(input$delete_row, {
    req(is.list(input$delete_row))
    row_id <- as.integer(input$delete_row$id)
    dt <- pillars_list()
    row <- dt[id == row_id]
    req(nrow(row) == 1L)

    showModal(modalDialog(
        title = "Confirmer la suppression",
        p("Supprimer le pilier", strong(row$label), "?"),
        footer = tagList(
            modalButton("Annuler"),
            actionButton(ns("confirm_delete"), "Supprimer", class = "btn-danger")
        )
    ))
})

La structure est identique pour les deux :

  1. as.integer(input$edit_row$id) : on extrait l’ID de la ligne depuis l’objet. as.integer() parce que Shiny.setInputValue transmet une string JavaScript, qu’on veut comparer avec un ID entier en base.
  2. On retrouve la ligne dans les données réactives, on vérifie qu’elle existe (req(nrow(row) == 1L)).
  3. On fait quelque chose avec (ouvrir une modale, lancer une suppression, etc.).

Deux types d’actions, deux observeEvent, quel que soit le nombre de lignes dans le tableau.

Ce qu’il y a à retenir

Ce pattern n’a rien de révolutionnaire. C’est du JavaScript basique (onclick + Shiny.setInputValue), combiné avec une astuce de déduplication (le nonce) et un peu de sprintf pour la vectorisation.

Mais c’est un de ces patterns qui, une fois codifié dans une fonction utilitaire de 15 lignes, élimine une catégorie entière de bugs : les observers fantômes, les fuites de réactivité, les clics ignorés, les resets oubliés.

Chez nous, create_table_action_button est dans le helpers_ui.R de chaque projet Shiny qui a un DataTable avec des actions par ligne. Et on l’a documenté dans nos rules Cursor pour que l’IA l’utilise systématiquement au lieu de réinventer un pattern bancal à chaque module.

Si vous avez des questions ou des variantes de ce pattern, dites-le en commentaires.

La newsletter

Si ce genre de patterns Shiny tirés de vrais projets en production vous intéresse, je partage régulièrement dans ma newsletter. C’est là que je publie ces retours d’expérience avant qu’ils ne deviennent des articles.

Commentaires

Laisser un commentaire

Les champs obligatoires sont marqués d'un astérisque *

Markdown accepté

Les commentaires sont validés manuellement.
La page va se rafraîchir après envoi.