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)
- 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). - 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. Chaquest.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() - 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=...), oust.cache_data(ttl=...)si "rafraîchir" signifie simplement de nouvelles données. Il n'y a PAS dest.autorefreshnatif (vérifié — rien dansdir(st)). Le paquet PyPIstreamlit-autorefreshest 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=...). st.experimental_rerun()n'existe pas (supprimé dans 1.27+). C'estst.rerun(). De même pourst.experimental_memo/singleton→st.cache_data/st.cache_resource;st.experimental_fragment→st.fragment;@st.cachea également disparu.- La plupart des appels
st.rerun()sont inutiles. Une interaction avec un widget déclenche déjà une ré-exécution, et les callbackson_click/on_changes'exécutent AVANT cette ré-exécution, donc leurs écritures d'état sont visibles sans en demander une autre. Utilisations légitimes : fermer unst.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 formeif "k" not in st.session_state:). Faites-le en haut, avant les widgets. - Écrire
st.session_state.kAPRÈS que le widget aveckey="k"a été créé lors de cette exécution lèveStreamlitAPIException: 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
keyest 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.tabsest 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 deskey=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'utilisateurCalling 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 mutersession_stateet retournez. Ajouterst.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éisFalse).ttl="15m"pour les données qui doivent être rafraîchies ;Foo.clear()oust.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 laissezrun_everyle 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_everydepuis le script EXTERNE — un widget À L'INTÉRIEUR ne le peut pas.run_everyn'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 :Lors de la ré-exécution complète,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()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_everyou d'un changement de widget à l'intérieur du fragment, le script externe n'est PAS ré-exécuté, doncdata = ticker()garde la valeur OBSOLÈTE et les graphiques en aval ne se mettent jamais à jour. Poussez les données en direct viast.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 ; laissezrun_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 gardezrun_everysur 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_datasansttlexécute son corps UNE FOIS (vérifié) et renvoie cette valeur figée à chaque tick —run_everyré-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 unttl≤ 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 faitesst.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_datapour 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"
- Recherchez
st.rerun— est-ce que chaque appel est derrière une garde d'état à basculement en premier (règle 2) ? - Recherchez
while True/time.sleepau niveau du script → fragment (règle 1/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é.
- Collisions de
key=ou clés f-string qui changent à chaque exécution (par ex. contenanttime.time()) — une nouvelle clé à chaque exécution = le widget se réinitialise à chaque exécution. - Les chargements lents non mis en cache font que les ré-exécutions normales RESSEMBLENT à des boucles — ajoutez
st.cache_dataet un compteur d'exécutions (st.session_state.runs += 1) pour voir ce qui se passe réellement. - La section en direct se rafraîchit sur sa minuterie mais affiche des valeurs OBSOLÈTES → une
fonction
@st.cache_datasansttll'alimente ; le fragment se ré-exécute mais le cache renvoie l'ancienne valeur. Ajoutezttlou ne mettez pas en cache la lecture en direct.