Skip to main content

Module 6 — Session state and forms

Module 1 laid the trap: every interaction reruns the script and every top-level variable is lost. Modules 2 through 5 lived with that limitation by keeping each rerun stateless. This module makes state official. st.session_state is the browser-tab-scoped dictionary that survives reruns, and st.form is the container that lets you batch several inputs into a single submission — the two mechanisms together turn the churn dashboard from a proof of concept into a real workflow.

The session_state dictionary

st.session_state behaves like an ordinary Python dict, with the twist that it survives every rerun of the current browser tab. It is created lazily on first access, and it disappears when the tab is closed or the server restarts. Two syntaxes are interchangeable.

import streamlit as st

# Dict style
if "scored_count" not in st.session_state:
st.session_state["scored_count"] = 0

# Attribute style
if "scored_count" not in st.session_state:
st.session_state.scored_count = 0

The classic beginner error is st.session_state.scored_count = 0 at the top of the script — every rerun re-executes that line and resets the counter. Guarding with if "scored_count" not in st.session_state initializes exactly once.

Widgets and session_state share a namespace

Every widget you create with a key= argument writes its current value into st.session_state[key]. That is the mechanism, and it has three consequences worth internalizing early.

name = st.text_input("Customer name", key="customer_name")

# name == st.session_state["customer_name"] # always true

First, reading st.session_state["customer_name"] from anywhere in the script gives the current widget value, so you can pass it between functions without threading it through arguments. Second, you can pre-fill a widget by writing to st.session_state before the widget is created — set st.session_state["customer_name"] = "Acme" and the text input will render with that value. Third, you cannot set a widget's state after it has been created in the same run without an error; the assignment must precede the widget call.

Callbacks: on_change and on_click

Widgets accept on_change= and on_click= callbacks that fire before the rerun. Combined with key=, they let you react to a specific gesture without polling.

def score():
st.session_state.scored_count += 1
st.session_state.last_customer = st.session_state.customer_name

st.text_input("Customer name", key="customer_name")
st.button("Score", on_click=score)

st.caption(f"Scored {st.session_state.get('scored_count', 0)} customers this session.")

Callbacks are the cleanest place to enforce preconditions ("do nothing if the name is empty") and atomic updates ("increment the counter and store the current name in one gesture"). Do not put slow work in a callback; it blocks the rerun and freezes the UI. Callbacks are for state changes, cached functions are for the work.

The classic bug: two widgets, one key

Two widgets in the same page cannot share a key=. The second creation raises DuplicateWidgetID, and even if you use different keys, two widgets that display the same value will drift the moment one is edited alone. The rule is one widget per key, and if you need the "current" value from anywhere else in the page, read it through st.session_state[key] — do not create a second widget.

Forms: batch inputs into one submission

A st.form container defers reruns until the user clicks the submit button. Every widget inside behaves as usual, but their on_change callbacks do not fire and the script does not rerun on each interaction. Everything happens in one shot at submission.

with st.form("scoring_form"):
st.subheader("Score a customer")
customer_id = st.text_input("Customer ID", key="form_id")
tenure = st.slider("Months as a customer", 0, 72, 12, key="form_tenure")
contract = st.selectbox("Contract", ["Month-to-month", "One year", "Two years"], key="form_contract")

submitted = st.form_submit_button("Score", type="primary")

if submitted:
st.success(f"Scored {customer_id or 'unknown'} with contract {contract}.")

Forms fix two real user-experience problems. They stop the network noise of a rerun on every keystroke — critical when you have five widgets and the user fills them left to right. And they give a natural place for client-side validation before hitting the model: check that customer_id is not empty, that tenure is greater than zero, and only then call the prediction. Note also that widgets in a form ignore their on_change callback — if you rely on one, keep the widget outside the form.

A three-step wizard with session_state

Session state and forms together make the "wizard" pattern easy: a sequence of steps where the user progresses only after each is valid.

if "step" not in st.session_state:
st.session_state.step = 1

st.progress((st.session_state.step - 1) / 2, text=f"Step {st.session_state.step} of 3")

if st.session_state.step == 1:
st.text_input("Customer ID", key="wiz_id")
if st.button("Next", disabled=not st.session_state.get("wiz_id")):
st.session_state.step = 2
st.rerun()

elif st.session_state.step == 2:
st.selectbox("Contract", ["Month-to-month", "One year", "Two years"], key="wiz_contract")
cols = st.columns(2)
if cols[0].button("Back"):
st.session_state.step = 1
st.rerun()
if cols[1].button("Next", type="primary"):
st.session_state.step = 3
st.rerun()

else:
st.subheader("Confirm and score")
st.write({"id": st.session_state.wiz_id, "contract": st.session_state.wiz_contract})
if st.button("Score", type="primary"):
st.session_state.step = 1
st.success("Scored — form reset.")

Two lessons out of this wizard. st.rerun() is called after the state change so the next block is the one drawn on the fresh run; without it the user would need to click twice. And the "Back" and "Next" buttons live in the same column row so they align — a small piece of layout discipline that matters more than it looks.

The running example with persistent results

Here is the sixth version of the app. The scored result stays on the page across reruns, the counter of scored customers persists, and the form batches five inputs into a single submission.

import streamlit as st

if "last_result" not in st.session_state:
st.session_state.last_result = None
if "scored_count" not in st.session_state:
st.session_state.scored_count = 0

with st.form("scoring_form"):
customer_id = st.text_input("Customer ID", placeholder="C-01847")
tenure = st.slider("Months as a customer", 0, 72, 12)
contract = st.selectbox("Contract type", ["Month-to-month", "One year", "Two years"])
monthly_charge = st.number_input("Monthly bill (USD)", 0.0, 500.0, 70.0, 5.0)
submitted = st.form_submit_button("Score this customer", type="primary")

if submitted:
if not customer_id:
st.error("Please enter a customer ID.")
else:
score = 0.5 - 0.005 * tenure + 0.002 * monthly_charge
score += {"Month-to-month": 0.15, "One year": 0.0, "Two years": -0.10}[contract]
st.session_state.last_result = {"id": customer_id, "score": max(0.0, min(1.0, score))}
st.session_state.scored_count += 1

if st.session_state.last_result:
r = st.session_state.last_result
st.metric(f"Churn probability for {r['id']}", f"{r['score']:.0%}", delta_color="inverse")

st.caption(f"Scored {st.session_state.scored_count} customers in this session.")

The result now survives when the user moves the sidebar's threshold slider, and the counter increments correctly. Both changes cost three lines of state management.

Session state is per browser tab

Two tabs on the same laptop are two independent sessions. State is not shared across users, and it is lost on every server restart. For data that must outlive the tab, write it to disk, a database or an API — session_state is not persistent storage.

In summary

  • st.session_state is a dict scoped to the browser tab that survives reruns; initialize once with if key not in st.session_state.
  • Widgets with a key= write their value into session_state, and you can pre-fill a widget by writing to that key before creating it.
  • on_change and on_click callbacks run before the rerun; use them for state changes, never for slow work.
  • st.form batches widgets into a single submission and silences their on_change callbacks; ideal for validation before hitting the model.

Next module: uploading a CSV of customers, validating the columns and scoring the whole file in one batch.