CommunityRédaction et éditiongithub.com

st-core — Modèle d'exécution, état et rafraîchissement de Strea…

Maîtrisez le modèle d'exécution de Streamlit : script re-exécuté à chaque interaction, règles strictes pour éviter les boucles infinies, gestion d'état avec session_state, utilisation des fragments pour un rafraîchissement partiel, et bonnes pratiques de caching.

Qu'est-ce que st-core — Modèle d'exécution, état et rafraîchissement de Strea… ?

Cette compétence vous donne une compréhension fondamentale et complète du modèle d'exécution de Streamlit, essentielle pour construire des applications fiables et performantes. Le principe clé est que Streamlit re-exécute l'intégralité du script du haut vers le bas à chaque interaction utilisateur, ce qui élimine les gestionnaires d'événements classiques. La compétence détaille les règles strictes à respecter (pas de boucle infinie, pas d'appel inconditionnel à st.rerun, pas de time.sleep pour l'auto-rafraîchissement) et explique comment utiliser correctement st.session_state pour gérer l'état persistant. Elle couvre également la stratégie des callbacks (on_click, on_change) qui s'exécutent avant la re-exécution du script, permettant de modifier l'état sans rerun supplémentaire, et le pattern des formulaires (st.form) pour regrouper les interactions. Une partie majeure est dédiée à st.fragment, qui permet de rafraîchir uniquement une partie de la page à un intervalle donné (run_every) sans re-exécuter l'ensemble du script, résolvant ainsi les problèmes de performance et de vol de focus. Enfin, les bonnes pratiques de caching (st.cache_data, st.cache_resource) sont expliquées pour éviter les re-exécutions coûteuses et les problèmes de valeurs obsolètes. Cette compétence est validée sur Streamlit 1.58 et compatible 1.55+.

Compatible avec~Claude Code~Codex CLI~Cursor
npx skills add 4k7yvjv7w4-ops/streamlit-agent-skills

Installed? Explore more Rédaction et édition skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Streamlit core (1.58.x) — modèle d'exécution, état, rafraîchissement

Le modèle mental unique : à CHAQUE interaction utilisateur (tout widget touché), Streamlit ré-exécute l'INTÉGRALITÉ du script du début à la fin. Il n'y a pas de gestionnaires d'événements, pas de variables locales persistantes, pas de boucle principale. Une exécution est une fonction pure de st.session_state + des valeurs actuelles des widgets. Tout ce qui suit en découle.

Preuve exécutable de chaque affirmation : st_core_lab.py dans ce dossier de compétence (python -m streamlit run ~/.roo/skills/st-core/st_core_lab.py). Vérifié sur Streamlit 1.58, API vérifiée sur 1.55 : tout ce qui est dans cette compétence existe sur les deux (st.fragment(run_every=) nécessite ≥1.37).

Règles strictes (violations = l'application cassée classique)

  1. JAMAIS while True: au niveau du script. Le script doit se terminer à chaque exécution. Pour une section en direct, utilisez @st.fragment(run_every="2s") (ci-dessous). Les boucles sont correctes uniquement À L'INTÉRIEUR d'une exécution pour un travail fini (barre de progression, st.write_stream).
  2. JAMAIS appeler st.rerun() de manière inconditionnelle (ou sous une condition qui est encore vraie à l'exécution suivante) — Streamlit n'a AUCUNE protection de boucle ; il tourne indéfiniment. Chaque st.rerun() doit se trouver derrière une garde d'état que vous basculez en premier :
    if st.session_state.get("needs_refresh"):
        st.session_state.needs_refresh = False   # basculer AVANT rerun
        refresh_data()
        st.rerun()
    
  3. JAMAIS time.sleep(n); st.rerun() pour l'auto-rafraîchissement. La page ne se stabilise jamais : les widgets clignotent, les clics sont perdus en plein sommeil. Utilisez @st.fragment(run_every=...), ou st.cache_data(ttl=...) si "rafraîchir" signifie simplement de nouvelles données. Il n'y a PAS de st.autorefresh natif (vérifié — rien dans dir(st)). Le paquet PyPI streamlit-autorefresh est tiers et ré-exécute l'INTÉGRALITÉ du script à chaque tick (réinitialise chaque widget, vole le focus) ; préférez le fragment natif @st.fragment(run_every=...).
  4. st.experimental_rerun() n'existe pas (supprimé dans 1.27+). C'est st.rerun(). De même pour st.experimental_memo/singleton → st.cache_data / st.cache_resource ; st.experimental_fragment → st.fragment ; @st.cache a également disparu.
  5. La plupart des appels st.rerun() sont inutiles. Une interaction avec un widget déclenche déjà une ré-exécution, et les callbacks on_click/on_change s'exécutent AVANT cette ré-exécution, donc leurs écritures d'état sont visibles sans en demander une autre. Utilisations légitimes : fermer un st.dialog, appliquer un changement d'état programmatique effectué EN DESSOUS de l'endroit où il est rendu (modèle règle 2).

st.button est momentané — le bug numéro 1 de la disparition de sortie

st.button() renvoie True pour EXACTEMENT UNE exécution (le clic), puis renvoie False à nouveau. Donc if st.button("run"): st.write(result) affiche le résultat jusqu'à ce que l'utilisateur touche quoi que ce soit d'autre, puis il DISPARAÎT. Et les boutons imbriqués ne peuvent jamais être tous les deux vrais : cliquer sur le bouton intérieur rend le bouton extérieur à nouveau False (vérifié).

# MAUVAIS                                # BON — persister via l'état
if st.button("compute"):               if st.button("compute"):
    r = model()                            st.session_state.r = model()
    st.write(r)   # disparu à la prochaine exécution       if "r" in st.session_state:
                                            st.write(st.session_state.r)

Flux multi-étapes : une clé step dans session_state + st.rerun() selon la règle 2, jamais de bouton-dans-bouton.

Règles de session_state

  • Initialiser une fois : st.session_state.setdefault("k", default) (ou la forme if "k" not in st.session_state:). Faites-le en haut, avant les widgets.
  • Écrire st.session_state.k APRÈS que le widget avec key="k" a été créé lors de cette exécution lève StreamlitAPIException: cannot be modified. Pour définir un widget par programme, écrivez la clé AVANT la ligne du widget (généralement : définissez un indicateur + st.rerun(), appliquez l'écriture en haut de l'exécution suivante) — ou faites-le dans un callback, ce qui est toujours sûr.
  • Si key est déjà dans session_state, value= est ignoré — l'état gagne ; la seule trace est un avertissement dans le journal du SERVEUR ("created with a default value but also had its value set via the Session State API"), rien dans l'interface utilisateur. Ne passez pas les deux en vous attendant à ce que la valeur par défaut s'applique.
  • Un widget dont la ligne ne s'exécute pas lors d'une exécution PERD son état (onglet caché par condition, branche réduite — vérifié : un curseur à 8 est revenu à 0). Pour survivre à la dissimulation, reflétez-le vers votre propre clé : st.slider(..., key="_w", on_change=lambda: st.session_state.update(w=st.session_state._w)) et réinjectez-le via le modèle écriture-avant-widget. (st.tabs est sûr — le code de tous les onglets s'exécute à chaque fois, qu'ils soient cachés ou non ; c'est aussi pourquoi les onglets lourds ont besoin de caching.)
  • Deux widgets avec le même type+paramètres lèvent StreamlitDuplicateElementId — passez des key= distinctes. Widgets dans des boucles : key=f"row-{i}".

Les callbacks surpassent les reruns manuels

Un état modifié en dessous de son point de rendu est obsolète pendant une exécution (vérifié : la branche if st.button(...) s'exécute APRÈS que le compteur au-dessus a été dessiné). Les callbacks corrigent l'ordre — ils s'exécutent avant que le script ne se ré-exécute :

st.session_state.setdefault("count", 0)
def inc(): st.session_state.count += 1
st.button("+1", on_click=inc)                 # le compte est frais pour cette même exécution
st.metric("count", st.session_state.count)

on_change sur les entrées/sélections fonctionne de la même manière. Les callbacks peuvent écrire n'importe quelle clé d'état (même celle d'un widget, puisque sa ligne n'a pas encore été exécutée). Aucun st.rerun() nécessaire.

  • st.rerun() à l'intérieur d'un callback est une NON-OPÉRATION (vérifié) : il affiche un avertissement visible par l'utilisateur Calling st.rerun() within a callback is a no-op. et INTERROMP le reste du corps du callback — pas de ré-exécution supplémentaire, pas d'exception. Le clic a déjà planifié la ré-exécution ; dans un callback, ne faites que muter session_state et retournez. Ajouter st.rerun() là "pour appliquer le changement" tronque silencieusement votre callback.

Formulaires — arrêter la ré-exécution par frappe

Chaque interaction avec un widget ré-exécute le script ; N entrées = N ré-exécutions pendant que l'utilisateur les remplit (chacune recharge votre couche de données). Regroupez-les :

with st.form("params"):
    a = st.slider("a", 0, 10)
    b = st.selectbox("b", opts)
    go = st.form_submit_button("Appliquer")
if go: run_query(a, b)

Rien ne se ré-exécute jusqu'à la soumission (vérifié : compteur d'exécutions +1 au total). À l'intérieur d'un formulaire : pas de st.button, pas de callbacks par widget sauf sur le bouton de soumission.

"Ça se ré-exécute pendant que je tape" n'est presque jamais dû aux frappes. Les widgets se valident sur différents événements (comportement du frontend Streamlit) : text_input/text_area/ number_input se déclenchent sur Entrée ou perte de focus, slider/select_slider sur relâchement — PAS par frappe, PAS en cours de glissement ; checkbox/radio/selectbox/ toggle se déclenchent immédiatement. Un changement validé = une ré-exécution, et le frontend n'envoie rien lorsque la valeur est inchangée. Donc des ré-exécutions constantes pendant la frappe signifient un concurrent auto-rafraîchissement de l'application entière (une minuterie / st_autorefresh / sleep+ rerun) volant le focus — limitez ce rafraîchissement dans un @st.fragment qui NE contient PAS l'entrée, ou enveloppez les entrées dans st.form. Ne débouncez pas les frappes ; Streamlit n'en a pas besoin.

Mise en cache — la plupart des "mauvais rafraîchissements" viennent en fait de là

  • @st.cache_data — DataFrames/résultats sérialisables. Renvoie une COPIE fraîche par appel (mutations sûres, vérifié is False). ttl="15m" pour les données qui doivent être rafraîchies ; Foo.clear() ou st.cache_data.clear() sur un bouton ↻.
  • @st.cache_resource — connexions, modèles, threads. Le MÊME objet renvoyé à toutes les sessions (vérifié) — ne le mute jamais par utilisateur, ajoutez des verrous si nécessaire.
  • Les arguments doivent être hachables et former la clé de cache ; préfixez les arguments non hachables avec _ pour les exclure (def load(_conn, day):).
  • Sans mise en cache, chaque tick de widget ré-exécute chaque chargement dans le script — l'application "semble bloquée" car chaque clic coûte des secondes. Mettez en cache en premier, puis débugguez les ré-exécutions.

Rafraîchissement en direct correct — st.fragment

@st.fragment(run_every="2s")        # secondes entières / timedelta / chaînes "2s"
def ticker():
    st.metric("dernier", get_price())  # seule CETTE fonction se ré-exécute
ticker()
  • Le reste de la page NE se ré-exécute PAS (vérifié : compteur d'exécutions externe gelé pendant que le fragment ticke). Les widgets à l'intérieur du fragment se ré-exécutent uniquement dans le fragment ; st.rerun(scope="fragment") le redémarre juste.
  • Piège (vérifié) : scope="fragment" LÈVE une erreur si le corps du fragment s'exécute dans le cadre d'une exécution complète de l'application (premier affichage) — protégez-le, ou basculez l'état et laissez run_every le prendre en charge.
  • Les fragments peuvent lire/écrire librement session_state ; pour pousser un changement VERS la page entière, écrivez l'état puis st.rerun() (scope "app", la valeur par défaut).
  • Arrêter ou re-timer un fragment run_every depuis le script EXTERNE — un widget À L'INTÉRIEUR ne le peut pas. run_every n'est relu que lors d'une exécution complète de l'application, donc un curseur d'intervalle ou un bouton Stop à l'intérieur du fragment (qui ne ré-exécute que le fragment) ne réarme jamais la minuterie (vérifié). Conditionnez l'APPEL du fragment depuis l'extérieur :
    secs = st.slider("intervalle", 1, 10, 2)          # À L'EXTÉRIEUR du fragment
    run  = st.session_state.get("run", True)
    tick = st.fragment(run_every=secs if run else None)(_body)
    tick()
    if st.button("stop"): st.session_state.run = False; st.rerun()
    
    Lors de la ré-exécution complète, run_every=None (ou ne pas l'appeler) n'émet aucun message d'auto-rafraîchissement et la minuterie s'arrête.
  • La valeur de retour d'un fragment n'atteint l'appelant que lors d'une exécution COMPLÈTE (vérifié). Lors d'un tick run_every ou d'un changement de widget à l'intérieur du fragment, le script externe n'est PAS ré-exécuté, donc data = ticker() garde la valeur OBSOLÈTE et les graphiques en aval ne se mettent jamais à jour. Poussez les données en direct via st.session_state écrit À L'INTÉRIEUR du fragment (st.session_state.data = fetch()), lisez-le à l'extérieur — ou dessinez le graphique dans le fragment lui-même. Ne traitez pas un fragment comme une simple fonction que vous pouvez assigner entre les ticks.
  • Un fragment s'exécute sur le thread unique de l'application — ce n'est PAS un thread d'arrière-plan. time.sleep à l'intérieur gèle la PAGE ENTIÈRE pendant cette durée ; laissez run_every être la cadence, ne dormez jamais. Les fragments imbriqués sont autorisés (vérifié) mais chacun est une portée de ré-exécution indépendante — un tick enfant ne ré-exécute pas son parent, donc gardez run_every sur le fragment qui possède les données ; n'imbriquez pas de minuteries pour les mêmes données. Pour un véritable travail d'arrière-plan (soumettre un travail lent, continuer à naviguer, les résultats arrivent) → [st-jobs].
  • Ne créez pas le même widget avec la même clé à la fois à l'intérieur et à l'extérieur d'un fragment.
  • Le fragment ticke mais la valeur ne change jamais ? Votre lecture en direct est mise en cache. Un chargeur @st.cache_data sans ttl exécute son corps UNE FOIS (vérifié) et renvoie cette valeur figée à chaque tick — run_every ré-exécute la fonction mais le cache la court-circuite. C'est la cause numéro 1 du "mon fragment ne se rafraîchit pas". Correction : ne mettez pas en cache la lecture en direct, OU donnez-lui un ttl ≤ la cadence de rafraîchissement (@st.cache_data(ttl="2s")), OU faites varier un argument à chaque tick.

Fragment comme une boîte de travail limitée — récupérer maintenant, utiliser plus tard (pas de ré-exécution complète)

Un fragment SANS run_every est une boîte dans la page dont les widgets ne ré-exécutent QUE la boîte — le modèle pour "consulter des données de référence ici, les consommer dans une action ultérieure" sans ré-exécuter la page entière par consultation :

@st.fragment
def fetch_box():                              # les clics ici ne ré-exécutent QUE la boîte
    with st.form("lookup"):                   # formulaire dans le fragment : légal (vérifié)
        code = st.selectbox("entité", codes, key="box_code")
        go = st.form_submit_button("obtenir les détails")
    if go:
        st.session_state.details = get_details(code)   # transfert via L'ÉTAT
        st.success(f"obtenu {code}")
fetch_box()

if st.button("exécuter la requête"):                  # À L'EXTÉRIEUR → son clic EST une ré-exécution complète,
    d = st.session_state.get("details")       # qui lit les détails stockés
    if d is None: st.warning("récupérer d'abord")
    else:        run_request(d)
  • Le consommateur n'a besoin d'aucun st.rerun() : son propre clic est une exécution complète qui lit l'état frais (vérifié). Ce n'est que si la page EXTERNE doit afficher les données récupérées IMMÉDIATEMENT (avant tout prochain clic) que vous faites st.rerun() à l'intérieur du fragment après avoir écrit l'état — cela coûte une exécution complète.
  • Transférez via session_state, jamais la valeur de retour du fragment (obsolète entre les ré-exécutions de boîte uniquement — point ci-dessus). Mettez en cache la consultation avec @st.cache_data pour que la récupération de la même entité soit gratuite.

Statut pendant le travail (à L'INTÉRIEUR d'une exécution — boucles autorisées)

ph = st.empty()                       # placeholder, écraser sur place
for i, step in enumerate(steps):
    ph.text(f"{i+1}/{len(steps)} {step.name}")
    step.run()
ph.empty()

Également st.progress, st.status("...", expanded=True), st.spinner, st.toast. Ceux-ci s'animent À L'INTÉRIEUR d'une exécution — pas de rerun impliqué, pas de risque de boucle.

Liste de vérification de débogage pour "bloqué dans une boucle / rafraîchissement continu"

  1. Recherchez st.rerun — est-ce que chaque appel est derrière une garde d'état à basculement en premier (règle 2) ?
  2. Recherchez while True / time.sleep au niveau du script → fragment (règle 1/3).
  3. Un widget qui écrit une clé d'état qu'une autre ligne réécrit → ping-pong de rerun ; gardez un seul écrivain par clé.
  4. Collisions de key= ou clés f-string qui changent à chaque exécution (par ex. contenant time.time()) — une nouvelle clé à chaque exécution = le widget se réinitialise à chaque exécution.
  5. Les chargements lents non mis en cache font que les ré-exécutions normales RESSEMBLENT à des boucles — ajoutez st.cache_data et un compteur d'exécutions (st.session_state.runs += 1) pour voir ce qui se passe réellement.
  6. La section en direct se rafraîchit sur sa minuterie mais affiche des valeurs OBSOLÈTES → une fonction @st.cache_data sans ttl l'alimente ; le fragment se ré-exécute mais le cache renvoie l'ancienne valeur. Ajoutez ttl ou ne mettez pas en cache la lecture en direct.

Skills associés