Écosystème R modulaire pour développer des applications modernes, à la manière du web.
2026-05-29
“Ce que tu fais avec Shiny, ce n’est pas du vrai web.”
Mon ancien patron
La release de {plumber2} sur le CRAN !
🔥 htmx gère les interactions côté client via des attributs HTML.
🔥 plumber2 gère les points de terminaison R côté serveur.
🚀 htmxr fait le lien entre les deux grâce à :
Application htmxr :
Applications Shiny :
via Shinyproxy : 🔗 open-apps.breant.art/app/hello-shiny
via Posit Connect : 🔗 connect.thinkr.fr/hello-shiny
Faire du web depuis R, le web comme il a été conçu
htmxr-> Où : sur mon VPS directement.
-> Outil : Playwright.
-> Quoi mesuré : temps écoulé entre page.goto(url) et le moment où le plot SVG/IMG est réellement visible dans le DOM.
-> Où : sur mon VPS directement.
-> Quoi mesuré : temps écoulé quand on bouge le slider
le navigateur fait un GET /plot?bins=X,
R génère un SVG en 12 ms.
htmx swap dans le DOM.
-> Trois étapes, toutes standard.
nouvelle valeur via WebSocket,
invalidation du graphe réactif,
recalcul,
rendu PNG, encodage base64,
retour via WS,
update DOM.
-> Sept étapes, dont plusieurs spécifiques à Shiny.
Pourquoi ces différences ? Deps JS/CSS : 2 vs 13
htmxr (stateless HTTP) ──────────────────────
1 process R partagé
RAM ♾️ requêtes actives
User inactif = gratuit
Shiny (stateful WS) ─────────────────────
1 session R par user
RAM ♾️ users connectés
User inactif = coûte
Avec mon VPS à 6.62 €/mois TTC :
→ 20 users Shiny en simultané (1 container/user)
→ 1000+ users htmxr (1 process pour tous)
À 500 users actifs :
htmxr → même VPS-1 → 6.62 € TTC/mois
Shiny → dédié RISE-1 64 GB → ~78 € HT/mois
🔥 HTTP est stateless par design : chaque requête est indépendante !
htmxr = HTTP + HTML + composants natifs du navigateur
Ce que qu’on peut récupérer gratuitement :
→ Cache HTTP, CDN, replicas, scaling horizontal
→ Monitoring et debugging avec les outils web standard
→ Déployable n’importe où (Docker, VPS, k8s, serverless)
→ URLs adressables
→ on paie pour le travail, pas pour les sessions
Client
Tu commandes
Serveur (API)
Il transmet
Cuisine (backend)
Elle prépare
Le client ne rentre jamais en cuisine.
Le serveur fournit un menu (la documentation).
Le client s’engage à :
Le serveur s’engage à :
Comme un menu de restaurant : tu sais ce que tu commandes, à quel prix, en combien de temps.
Comme un contrat juridique : chacun connaît ses obligations.
Avec htmxr, un endpoint est consommable de partout : navigateur, curl, Python, JS, Go, …
Avec Shiny, une app n’est consommable que par un navigateur qui fait tourner Shiny.
| Verbe | Sens | Idempotent |
|---|---|---|
GET |
“donne-moi” | ✅ |
POST |
“voici qqch à créer” | ❌ |
PUT |
“remplace tout” | ✅ |
PATCH |
“modifie un bout” | ✅ |
DELETE |
“supprime” | ✅ |
🔥 Règle d’or : chaque requête est indépendante.
Le serveur ne se souvient pas de toi entre deux requêtes : c’est le stateless qu’on a vu plus tôt.
Même donnée (diamond_data()), deux endpoints :
GET /api/rows
#* @serializer json
→ Pour curl, Python, mobile…
GET /rows
#* @serializer none (HTML)
→ Pour htmx
🔥 Même backend, deux clients.
C’est htmxr: une API REST avec une sérialisation HTML pour le navigateur.
Shiny |
htmxr |
|
|---|---|---|
| Communication | WebSocket (connexion persistante) | HTTP (requête / réponse) |
| Paradigme | Graphe réactif | Requêtes HTTP explicites |
| Mises à jour UI | Shiny décide quoi recharger | On cible le DOM précisément |
| État | Session R par utilisateur | Base de données ou URL |
| Backend | R uniquement | N’importe quel serveur HTTP |
Shiny, le serveur réagit,htmxr, le navigateur demande.
📦 htmxr Version actuelle : v0.2.0.
🔗 Dépend de plumber2 pour le serveur HTTP.
| Attribut HTML | Paramètre htmxr |
Rôle |
|---|---|---|
hx-get |
get |
requête GET |
hx-post |
post |
requête POST |
hx-target |
target |
sélecteur CSS cible |
hx-swap |
swap |
mode d’insertion |
hx-trigger |
trigger |
événement déclencheur |
💡 Chaque attribut HTML de htmx correspond à un paramètre dans htmxr.
Tu écris du R, htmxr génère le HTML avec les bons attributs.
hx_page() → document HTML complethx_head() → section <head>
💡 hx_page() injecte automatiquement le <script> qui charge htmx.min.js.
📖 Les annotations plumber2 :
#* @get / → cette fonction répond aux requêtes GET /#* @parser none → pas de parsing du body (inutile pour un GET)#* @serializer html → le résultat est sérialisé en HTMLhx_slider_input() → <input type="range">hx_select_input() → <select>hx_button() → <button>
💡 Chaque composant accepte les 5 paramètres htmx (get, target, trigger…).
🧠 Composants d’input conçus pour ressembler aux composants Shiny (sliderInput, selectInput), pour faciliter la transition mentale.
hx_table() → squelette <table> avec <tbody> ciblablehx_table_rows() → fragment de lignes <tr>
💡 Pattern courant : la page renvoie le squelette, un endpoint séparé renvoie les rows.
api.Rlibrary(htmxr)
# ── Route page complète ─────────────────
#* @get /
#* @parser none
#* @serializer html
function() {
hx_page(
hx_head(title = "Ma première app"),
tags$div(
id = "content"
) |>
hx_set(
get = "/content",
trigger = "load",
target = "#content"
)
)
}
# ── Route fragment HTML ─────────────────
#* @get /content
#* @parser none
#* @serializer html
function() {
tags$p("Bonjour depuis le serveur !")
}
api() → lit api.R et crée le routeur plumber2hx_serve_assets() → sert htmx.min.js comme asset statiqueignite() → démarre le serveur HTTP
Récupérer les fichiers Gist :
api.R
run_api.R
🔲 Lancer l’API pour faire tourner l’application
🐛 L’application se lance mais le slider ne réagit pas…
Reproduire :
htmxr ne force aucun framework CSSDans hello/api.R :
💡 Commenter ces 4 lignes → l’app reste fonctionnelle, juste sans style.
Shiny vs htmxr : le <head> rappel
À gauche : 2 dépendances (htmx.min.js + Bootstrap CSS).
À droite : 13 dépendances dont la moitié… ne sont pas Bootstrap.
ShinyShiny (et bslib) complète Bootstrap par-dessus.
Classes spécifiques qu’on retrouve dans le DOM Shiny :
❌ shiny-input-container → inventions Shiny, pas Bootstrap.
htmxr.bootstraphtmxr.bootstrap
✅ Bootstrap pur : classes officielles, doc Bootstrap directement applicable
La valeur du slider ne s’affiche pas à côté du curseur.
💡 Comment l’afficher ?
❌ 50 requêtes/seconde quand l’utilisateur bouge le slider.
❌ Tout ça pour… afficher un nombre déjà connu côté client.
alpiner (Alpine.js)
✅ Même app que tout à l’heure, avec la valeur du slider en live.
✅ Zéro requête HTTP pour cet affichage : tout côté client.
💡 x-model.number = binding bidirectionnel typé number.
x_set(text = "bins") = le contenu du span suit bins.
x_set(data = ...) = déclare le scope réactif Alpine.
htmxr quand il faut un aller-retour serveur,alpiner quand c’est purement visuel / client.
🔥 Trois packages, trois rôles :
htmxr → interactivité serveur (DOM ↔︎ R)alpiner → interactivité client (sans R)htmxr.bootstrap (ou daisy, etc.) → composants stylés prêts à l’emploiglitchtiprLe problème
En prod, stop("...") génère un simpleError.
Tes logs : tout est simpleError.
Impossible de filtrer, prioriser, alerter.
La solution
GlitchTip : error tracking open source, self-hostable, compatible API Sentry.
glitchtipr : le wrapper R (en dev).
💥 Erreur côté serveur → remontée dans GlitchTip → visible dans la dashboard.
Faire du web depuis R, le web comme il a été conçu.
📖 Documentation officielle : hyperverse.world