Beyond Dashboards: Building Complex, State-Persistent Applications in Streamlit with st-page-state

Hi everyone,

As Streamlit matures, we are moving from simple scripts into the realm of complex internal tools and production applications. However, as app complexity grows, developers face two major hurdles:

  1. The DX Nightmare: Managing state with loose st.session_state string keys leads to brittle code, namespace collisions, and zero type safety in large projects.

  2. The UX Gap: Progress is temporary. If a user refreshes the page or shares a link, their “work-in-progress” context is lost.

I built st-page-state to treat application state as a first-class architectural citizen. It replaces loose dictionaries with strict, typed, and URL-aware classes.

Why this is for Complex Apps:

1. Architectural Integrity (The session_state Solution)

  • Declarative Schema: Define your state like a Pydantic model. It brings structure to your app, replacing magic strings with class attributes.

  • Namespace Isolation: Every PageState class has its own isolated namespace. This allows modular teams to build different parts of an app without ever worrying about key collisions in st.session_state.

  • Strict Typing: Stop worrying if a value is a string or an int. The library handles automatic type casting from both the UI and the URL.

  • Developer Tooling: Use .dump() to export your entire app state for API payloads or .reset() to restore a module to its factory settings instantly.

2. UX Persistence (The URL Solution)

  • Deep-Linked Workflows: Every step of a multi-page process is instantly synced to the URL. The URL becomes a persistent pointer to a user’s exact “work-in-progress”.

  • Selfish Persistence (Default: True): When a user interacts with a module, the URL purges stale parameters from other components, ensuring a clean, focused address bar.

  • Automatic URL Restoration: If a user manually deletes a URL parameter, the restore_url_on_touch feature automatically repopulates it from the session_state the moment that variable is accessed.

  • Cross-State Sharing: Use share_url_with to allow independent modules (like a sidebar and a main workspace) to coexist in the URL simultaneously.

3. The Contract: From Strings to Architecture

Before: Native `st.session_state` (20+ Lines)

# 1. Initialize logic scattered across the script
if "status" not in st.session_state:

    # 2. Manual URL parsing
    url_val = st.query_params.get("status", "pending")
    st.session_state["status"] = url_val


# 3. No type safety
status = st.session_state["status"]

# 4. Manual URL updating on change
def update_status():
    st.query_params["status"] = st.session_state["status"]

st.selectbox("Status", ["pending", "active"], key="status", on_change=update_status)

:white_check_mark: After: With `st-page-state` (3 Lines)

Declarative, typed, and automatically synchronized with the URL.

from st_page_state import PageState, StateVar

# Creates a State class that hold the attribute "status", whenever "status" is touch, it touchs directly on session state. 
# Ex: FilterState.status = value, value = FilterState.status
class FilterState(PageState):
    status: str = StateVar(default="pending", url_key="status")

# .bind() method returns a dict with key= and on_change=, that sync widget changing with session state and query params.
st.selectbox("Status", ["pending", "active"], **FilterState.bind("status"))

Github: https://github.com/ju-sants/st-page-state

Pypi: pip install st-page-state

3 Likes