Working with page switching, session state and the new bind parameter

Having trouble developing a feature in my app, if anyone could please bring some insights.

I have an app with multiple pages, and each page has a form. While user navigates between pages, the form widgets (say with key “a”) keep their state in a different session state (say with key “page_a”).

When the original page is regenerated, each widget has its value loaded from the session state value. That is fine, but I also want to incorporate with the new bind parameter to enable URL sharing.

The problem arises as when I start a page the query_params doesn’t populate and the URL is empty.
My current workaround is to check whether we are switching back from another page, set the query parameters from the session state, initialize widget (so it has the stored session state value), then rerun the page with bind.

This works mostly, except on some widgets, for example, the date_input. Whenever I rerun the page, the URL collapses because it thinks that the new date is the default. However, it is not the default of the page, as I use session state to update value of widget, I initially initialise “page_a” with the default if it already doesn’t exist, this causes an issue since if this URL is shared, the default date is given to the receiver whereas the sharer is on their own date (recognised as a default)

Below is a short example of how my current app works. I’m not sure how to workaround this, any suggestions much appreciated.

Thank you in advance

import datetime as dt

import streamlit as st

# function that saves the widget value to the persistent session state value
def persist():
    st.session_state["persist_across_page"] = st.session_state["widget"]

# mock page for switching
def other_page():
    pass

# main page
def main_page():
    # default date that is applied if no persistent session state value
    default_date = dt.date(2026, 1, 1)
    if "persist_across_page" not in st.session_state:
        st.session_state["persist_across_page"] = default_date

    # check whether coming back from a page swap (in which case, widget has never been initialized
    initial_load = "widget" not in st.session_state

    # add value to widget (either default OR what the session state orginally had
    st.session_state["widget"] = st.session_state["persist_across_page"]
    # populate query params (URL) if necessary
    if initial_load:
        st.query_params["widget"] = st.session_state["widget"]
    with st.form(key="form"):
        # create widget without bind initially as we've updated query params manually
        # then create with bind so URL updates as user updates
        st.date_input(
            key="widget",
            label="date",
            bind="query-params" if not initial_load else None,
        )
        submit = st.form_submit_button(label="Submit", on_click=persist)
    if submit:
        # mock form submit
        st.write(st.session_state["widget"])
    if initial_load:
        # re reun page if needed to regenerate bound widget
        st.rerun()


st.navigation(
    [st.Page(other_page),
    st.Page(main_page)]
).run()

Welcome to the Streamlit community, and thanks for the detailed question and code! :balloon: You’re running into a subtle but common issue when combining session state, custom defaults, and the new bind parameter for URL sharing—especially with widgets like st.date_input.

What’s happening:
When you initialize your widget’s value from session state (which may differ from the hardcoded default), Streamlit’s query param binding logic can treat this as the “default” for the session, so the URL param is omitted. This means sharing the URL won’t always reproduce the sender’s state for the receiver, since the receiver falls back to the widget’s default, not the session state value. This is a known limitation of how Streamlit currently handles widget defaults, session state, and query param binding together, especially for widgets like date_input that have complex default logic (see docs).

Best-practice workaround:
To ensure the URL always reflects the current widget value (even if it’s not the widget’s hardcoded default), you should:

  • Always use the bind="query-params" parameter on the widget (not just after rerun).
  • On initial load, if the query param is missing, set both the session state and the query param to your desired value (from session state or your app’s default).
  • Avoid setting the widget’s value directly in session state before rendering the widget, as this can confuse Streamlit’s widget identity and default logic.
  • Let the widget’s value be controlled by the query param (if present), falling back to your app’s default otherwise.

Here’s a streamlined pattern that avoids double initialization and ensures the URL always matches the widget’s value:

import datetime as dt
import streamlit as st

def persist():
    st.session_state["persist_across_page"] = st.session_state["widget"]

def main_page():
    default_date = dt.date(2026, 1, 1)
    # If query param is present, use it; else use session state or default
    widget_value = st.query_params.get("widget", [None])[0]
    if widget_value:
        widget_value = dt.date.fromisoformat(widget_value)
    elif "persist_across_page" in st.session_state:
        widget_value = st.session_state["persist_across_page"]
    else:
        widget_value = default_date

    # Set session state for persistence
    st.session_state["persist_across_page"] = widget_value

    with st.form(key="form"):
        st.date_input(
            key="widget",
            label="date",
            value=widget_value,
            bind="query-params",
        )
        submit = st.form_submit_button(label="Submit", on_click=persist)
    if submit:
        st.write(st.session_state["widget"])

st.navigation([
    st.Page(lambda: None, title="Other Page"),
    st.Page(main_page, title="Main Page"),
]).run()

This way, the widget always gets its value from the query param if present, otherwise from your persistent session state, and the URL always reflects the current value. This avoids the “URL collapse” and ensures sharing the URL always reproduces the sender’s state.

For more details and best practices, see Streamlit’s widget behavior docs and multipage widget state guide.

Sources:

Thanks, this however doesn’t solve the issue that on re run the widget value is set from the persisted session state, which is interpreted as the default. so when the URL (which doesn’t have the widget query_params due to the collapse of the bind for default values) is shared, the other user does NOT get the persisted value from the first use, instead gets the default date