Communityライティング&編集github.com

Streamlit Core (1.58.x) — 実行モデル、状態管理、更新

Streamlit の実行モデル、状態管理、更新の仕組みを徹底解説。バグの原因となるパターンを回避し、安定したアプリを構築するためのルールとベストプラクティスを提供します。

Streamlit Core (1.58.x) — 実行モデル、状態管理、更新 とは?

このスキルは、Streamlit 1.58.x におけるスクリプト実行モデル、セッション状態の管理、および画面更新の正確な動作を理解するために設計されています。Streamlit はユーザー操作のたびにスクリプト全体を上から下まで再実行するという「究極の原則」に基づき、無限ループや不適切な`st.rerun()`の使用など、よくある落とし穴を回避するための具体的なルールを提示します。`st.fragment`を使ったスコープ付き自動更新、コールバックによる状態変更のタイミング制御、フォームによる再実行のバッチ処理、`@st.cache_data`/`@st.cache_resource`による適切なキャッシュ戦略など、実践的なテクニックを多数含んでいます。このスキルは、Claude Code、Cursor、GitHub Copilot、Codex などのAIエージェントがStreamlitアプリケーションのデバッグや新規開発を支援する際に、正確かつ安全なコードを生成するために活用できます。

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

Installed? Explore more ライティング&編集 skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Streamlit Core (1.58.x) — 実行モデル、状態管理、更新 は何をしますか?

唯一のメンタルモデル: あらゆるユーザーインタラクション(いずれかのウィジェット操作)のたびに、Streamlit はスクリプト全体を 上から下まで 再実行します。イベントハンドラ、永続的なローカル変数、メインループは存在しません。1回の実行は st.session_state + 現在のウィジェット値の純粋関数です。以下のすべてはこの原則に基づいています。

すべての主張を実行可能な証明: このスキルフォルダ内の st_core_lab.py(python -m streamlit run ~/.roo/skills/st-core/st_core_lab.py)。 Streamlit 1.58 で検証済み、API は 1.55 で確認: このスキルの内容は両方のバージョンに存在します(st.fragment(run_every=) は ≥1.37 が必要)。

厳格なルール(これに違反すると古典的な壊れたアプリになります)

  1. スクリプトレベルで決して while True: を使わない。 スクリプトは各実行を完了しなければなりません。ライブセクションには @st.fragment(run_every="2s") を使用します(後述)。有限のジョブ(プログレスバー、st.write_stream)のためのループは、1回の実行の 内部でのみ 問題なく使用できます。
  2. st.rerun() を無条件に(または次の実行でも真である条件下で)呼び出してはいけない — Streamlit にはループ保護機能が なく、無限に回り続けます。すべての st.rerun() は、事前にフリップする状態ガードの後ろに配置しなければなりません:
    if st.session_state.get("needs_refresh"):
        st.session_state.needs_refresh = False   # rerun の前にフリップ
        refresh_data()
        st.rerun()
    
  3. 自動更新のために time.sleep(n); st.rerun() を決して使わない。 ページが安定しません: ウィジェットがちらつき、スリープ中にクリックが無視されます。@st.fragment(run_every=...) を使用するか、「更新」が単により新しいデータを意味するのであれば st.cache_data(ttl=...) を使用してください。ネイティブの st.autorefresh は存在しません(確認済み — dir(st) には何もありません)。streamlit-autorefresh PyPI パッケージはサードパーティ製で、ティックごとにスクリプト全体を再実行します(すべてのウィジェットをリセットし、フォーカスを奪います)。ネイティブのスコープ付き @st.fragment(run_every=...) を優先してください。
  4. st.experimental_rerun() は存在しません(1.27+ で削除)。st.rerun() です。同様に、st.experimental_memo/singleton → st.cache_data / st.cache_resource、st.experimental_fragment → st.fragment、@st.cache も削除されました。
  5. ほとんどの st.rerun() 呼び出しは不要です。 ウィジェットのインタラクションはすでに再実行をトリガーし、on_click / on_change コールバックはその再実行の 前に 実行されるため、それらの状態への書き込みは別の再実行を要求しなくても参照可能です。正当な使用例: st.dialog を閉じる、それがレンダリングされた 下位 で行われたプログラム上の状態変更を適用する(ルール2のパターン)。

st.button は一瞬 — 出力が消えるバグの原因第一位

st.button() は 正確に1回の実行(クリック時)でのみ True を返し、その後は False に戻ります。したがって、if st.button("run"): st.write(result) は、ユーザーが他の何かに触れるまで結果を表示しますが、その後 消えます。そして、ネストされたボタンは両方が真になることは決してありません。内側のボタンをクリックすると外側のボタンは再び False になります(確認済み)。

# 間違い                                # 正しい — 状態を介して永続化する
if st.button("compute"):               if st.button("compute"):
    r = model()                            st.session_state.r = model()
    st.write(r)   # 次の run で消える      if "r" in st.session_state:
                                            st.write(st.session_state.r)

複数ステップのフロー: session_state 内の1つの step キーと、ルール2に従った st.rerun() を使用します。ボタンの中にボタンを決して入れないでください。

session_state のルール

  • 一度だけ初期化: st.session_state.setdefault("k", default)(または if "k" not in st.session_state: の形式)。ウィジェットの前に、トップで行います。
  • **key="k" を持つウィジェットがこの実行で作成された 後 に st.session_state.k を書き込むと、StreamlitAPIException: cannot be modified が発生します。ウィジェットをプログラムで設定するには、ウィジェット行の 前に キーを書き込みます(通常: フラグを設定して st.rerun()、次の実行の先頭で書き込みを適用する)— またはコールバック内で行います。これは常に安全です。
  • key がすでに session_state にある場合、value= は無視されます — 状態が優先されます。唯一の痕跡はサーバーログの警告("created with a default value but also had its value set via the Session State API")のみで、UI には何も表示されません。両方を渡してデフォルトが適用されることを期待しないでください。
  • ある実行でその行が実行されないウィジェットは、その状態を失います(条件による非表示タブ、折りたたまれたブランチ — 確認済み: 8に設定したスライダーが0に戻ってきました)。非表示になっても状態を維持するには、自分のキーにミラーリングします: st.slider(..., key="_w", on_change=lambda: st.session_state.update(w=st.session_state._w)) そして、書き込み-前-ウィジェットのパターンでそれをフィードバックします。(st.tabs は安全です — すべてのタブのコードは、非表示かどうかに関係なく毎回実行されます。そのため、負荷の高いタブにはキャッシュが必要です。)
  • 同じタイプとパラメータを持つ2つのウィジェットは StreamlitDuplicateElementId を発生させます — 個別の key= を渡してください。ループ内のウィジェット: key=f"row-{i}"。

手動再実行よりコールバック

レンダリングポイントより下位で変更された状態は、1回の実行で古い値を読み取ります(確認済み: if st.button(...) ブランチは、その上のカウンターが描画された後に実行されます)。コールバックは順序を修正します — スクリプトが再実行される前に実行されます:

st.session_state.setdefault("count", 0)
def inc(): st.session_state.count += 1
st.button("+1", on_click=inc)                 # count はこの同じ run で新鮮
st.metric("count", st.session_state.count)

on_change は入力/選択でも同様に機能します。コールバックは任意の状態キーを書き込むことができます(ウィジェットのキーも含む、その行がまだ実行されていないため)。st.rerun() は不要です。

  • コールバック内の st.rerun() は NO-OP です(確認済み): ユーザーに見える警告 Calling st.rerun() within a callback is a no-op. が表示され、コールバック本体の残りが 中断されます — 余分な再実行も例外も発生しません。クリックはすでに再実行をスケジュールしています。コールバック内では session_state を変更して return するだけです。"変更を適用する"ために st.rerun() を追加すると、コールバックが暗黙のうちに切り詰められます。

フォーム — キーストロークごとの再実行を止める

すべてのウィジェットインタラクションはスクリプトを再実行します。N個の入力がある場合、ユーザーがそれらを入力している間にN回の再実行が発生します(毎回データレイヤーにヒットします)。それらをバッチ処理します:

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

送信まで何も再実行されません(確認済み: 実行カウンターが合計+1)。フォーム内では: st.button なし、送信ボタン以外のウィジェットごとのコールバックもなし。

「入力中に再実行される」というのは、ほとんどの場合キーストロークが原因ではありません。 ウィジェットは異なるイベントでコミットします(Streamlit フロントエンドの動作): text_input / text_area / number_input は Enter または blur で発火、slider / select_slider は リリース時 — キーストロークごとではなく、ドラッグ中でもありません。checkbox / radio / selectbox / toggle は即座に発火します。1つのコミットされた変更 = 1回の再実行であり、値が変更されていない場合、フロントエンドは何も送信しません。したがって、入力中の継続的な再実行は、フォーカスを奪う 並行する アプリ全体の自動更新(タイマー / st_autorefresh / sleep + rerun)を意味します — その更新を、入力を含まない @st.fragment にスコープするか、入力を st.form でラップしてください。キーストロークのデバウンスは不要です。Streamlit にはそれが必要ありません。

キャッシュ — 「不良な更新」のほとんどは実際にはこれ

  • @st.cache_data — DataFrame / シリアル化可能な結果。呼び出しごとに新しい コピー を返します(変更は安全、is False を確認済み)。更新されるべきデータには ttl="15m" を。↻ ボタンには Foo.clear() または st.cache_data.clear() を。
  • @st.cache_resource — 接続、モデル、スレッド。すべてのセッションに 同じ オブジェクトが返されます(確認済み)— 決してユーザーごとに変更せず、必要に応じてロックを追加してください。
  • 引数はハッシュ可能であり、キャッシュキーを形成する必要があります。ハッシュ不可能なものは _ をプレフィックスとして除外します(def load(_conn, day):)。
  • キャッシュがない場合、すべてのウィジェットのティックがスクリプト内のすべてのロードを再実行します — 各クリックに数秒かかるため、アプリが「動かなくなったように感じます」。最初にキャッシュし、その後再実行をデバッグしてください。

正しく行うライブ更新 — st.fragment

@st.fragment(run_every="2s")        # 整数秒 / timedelta / "2s" 文字列
def ticker():
    st.metric("last", get_price())  # この関数のみ再実行
  • ページの残りの部分は 再実行されません(確認済み: 外部の実行カウンターはフラグメントがティックしている間停止しています)。フラグメント内のウィジェットはフラグメントのみを再実行します。st.rerun(scope="fragment") はフラグメントだけを再起動します。
  • 落とし穴(確認済み): フラグメント本体がアプリ全体の実行(初回描画)の一部として実行されている場合、scope="fragment" は エラーを発生させます — ガードするか、状態をフリップして run_every に拾わせてください。
  • フラグメントは session_state を自由に読み書きできます。変更をページ全体に 外に プッシュするには、状態を書き込んでから st.rerun() します(スコープ "app"、デフォルト)。
  • run_every フラグメントを外部スクリプトから停止または再タイミングする — 内部のウィジェットではできません。 run_every はアプリ全体の実行でのみ再読み取りされるため、フラグメント 内部 の間隔スライダーや停止ボタン(フラグメントのみを再実行する)はタイマーを再設定しません(確認済み)。外部からフラグメントの呼び出しをゲートしてください:
    secs = st.slider("interval", 1, 10, 2)          # フラグメントの外部
    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(または呼び出さない)は自動更新メッセージを発行せず、タイマーは停止します。
  • フラグメントの戻り値は、完全な実行でのみ呼び出し元に到達します(確認済み)。run_every ティックまたはフラグメント内のウィジェット変更では、外部のスクリプトは再実行されないため、data = ticker() は 古い 値を保持し、下流のチャートは決して更新されません。フラグメント 内部 で書かれた st.session_state を介してライブデータを外部にプッシュします(st.session_state.data = fetch())。外部でそれを読み取ります — またはフラグメント自体の中でチャートを描画します。フラグメントを、ティック間で割り当て可能なプレーンな関数として扱わないでください。
  • フラグメントはアプリの単一スレッドで実行されます — バックグラウンドスレッドではありません。 内部の time.sleep はその間ページ全体を 凍結 します。run_every を周期とし、決してスリープしないでください。ネストされたフラグメントは許可されています(確認済み)が、それぞれが独立した再実行スコープです — 子のティックは親を再実行しないため、データを所有するフラグメントに run_every を保持してください。同じデータに対してタイマーをネストしないでください。本当のバックグラウンド処理(遅いジョブを送信し、ナビゲーションを続け、結果が到着する) → [st-jobs]。
  • 同じキーを持つウィジェットをフラグメントの内部と外部の両方で作成しないでください。
  • フラグメントはティックするが、値が決して変わらない?ライブ読み取りがキャッシュされています。 ttl なしの @st.cache_data ローダーはその本体を 一度だけ 実行し(確認済み)、すべてのティックでその凍結された値を返します — run_every は関数を再実行しますが、キャッシュがそれをショートサーキットします。これが「私のフラグメントが更新されない」原因の第1位です。修正: ライブ読み取りをキャッシュしないか、更新周期以下の ttl を設定するか(@st.cache_data(ttl="2s"))、毎ティック引数を変えてください。

スコープ付きワークボックスとしてのフラグメント — 今フェッチして後で使用する(完全な再実行なし)

run_every なしのフラグメントは、そのウィジェットがボックスのみを再実行するページ内ボックスです — 「ここで参照データを検索し、後続のアクションで消費する」というパターンで、検索ごとにページ全体を再実行しません:

@st.fragment
def fetch_box():                              # ここでのクリックはボックスのみ再実行
    with st.form("lookup"):                   # フラグメント内のフォーム: 合法(確認済み)
        code = st.selectbox("entity", codes, key="box_code")
        go = st.form_submit_button("fetch details")
    if go:
        st.session_state.details = get_details(code)   # STATE を介して受け渡す
        st.success(f"fetched {code}")
fetch_box()

if st.button("run request"):                  # 外部 → そのクリックは完全な再実行であり、
    d = st.session_state.get("details")       # 格納された詳細を読み取る
    if d is None: st.warning("fetch first")
    else:        run_request(d)
  • コンシューマーは st.rerun() を必要としません: それ自身のクリックは新鮮な状態を読み取る完全な実行です(確認済み)。外部ページがフェッチされたデータを(次のクリックを待たずに)即座に 表示する必要がある場合のみ、状態を書き込んだ後、フラグメント内で st.rerun() を実行します — 1回の完全な実行コストがかかります。
  • 受け渡しは session_state を介して行い、フラグメントの戻り値は決して使用しないでください(ボックスオンリーの再実行で古くなります — 上記の箇条書き)。@st.cache_data で検索をキャッシュし、同じエンティティの再フェッチを無料にします。

作業中のステータス表示(1回の実行内 — ループは許可)

ph = st.empty()                       # プレースホルダ、その場で上書き
for i, step in enumerate(steps):
    ph.text(f"{i+1}/{len(steps)} {step.name}")
    step.run()
ph.empty()

また、st.progress、st.status("...", expanded=True)、st.spinner、st.toast も使用できます。これらは実行内でアニメーションします — 再実行は関係なく、ループのリスクもありません。

「ループに陥った / 更新し続ける」問題のデバッグチェックリスト

  1. st.rerun を grep — それぞれが flip-first 状態ガード(ルール2)の後ろにあるか?
  2. while True / time.sleep をスクリプトレベルで grep → フラグメントに変更(ルール1/3)。
  3. あるウィジェットが別の行が書き戻す状態キーを書き込む → ピンポン再実行。キーごとに1つのライターを維持。
  4. key= の衝突、または実行ごとに変化する f-string キー(例: time.time() を含む)— 毎回新しいキー = 毎回ウィジェットがリセット。
  5. キャッシュされていない遅いロードは、通常の再実行をループのように見せかけます — st.cache_data と実行カウンター(st.session_state.runs += 1)を追加して、実際に何が起こっているかを確認してください。
  6. ライブセクションがタイマーで更新されるが、古い 値を表示する → ttl なしの @st.cache_data 関数がそれを供給しています。フラグメントは再実行されますが、キャッシュは古い値を返します。ttl を追加するか、ライブ読み取りをキャッシュしないでください。

関連スキル