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.
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_stateis a dict scoped to the browser tab that survives reruns; initialize once withif key not in st.session_state.- Widgets with a
key=write their value intosession_state, and you can pre-fill a widget by writing to that key before creating it. on_changeandon_clickcallbacks run before the rerun; use them for state changes, never for slow work.st.formbatches widgets into a single submission and silences theiron_changecallbacks; 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.