Put widgets shared across pages in the st.navigation entrypoint, or copy page-widget values to a separate persistent key before switching pages. Submit forms before external controls rerun the app, move page-specific heavy work out of the entrypoint, and use fragments for local interactions.
Fix Streamlit Form Resets and Slow Reruns in Multipage Apps
Diagnose Streamlit widget resets across pages, protect unsubmitted forms, reduce repeated work, and use fragments to scope reruns in a multipage app.
// The short answer
In this guide · 4 sections
Getting a multipage Streamlit app to feel solid comes down to working with its default top-to-bottom reruns instead of fighting them. That means pulling heavy work out of your main entrypoint and being intentional about how you hold onto widget values. If you want state to survive when a user switches pages, you either need shared widgets living in the entrypoint or separate persistent state keys – and leaning on fragments will save you from tons of unnecessary reruns during local interactions.
- What you're working with: Streamlit v1.37+ (specifically
st.navigationandst.fragment) - The main headaches: Annoying full-page rerenders, UI ghosting, and session state getting out of sync.
- How to fix it: Native
st.session_statekey binding,@st.fragment, and callback-driven state updates. - How to check your work: Time your reruns before and after each tweak – there's no magic number to hit, you just want to see things getting faster.
How Streamlit Runs Under the Hood (and Why State Drops)
Every time a user clicks something, Streamlit reads and executes your Python script straight from top to bottom. Whether you're dragging a slider or jumping between pages with st.navigation, Streamlit normally reruns both the entrypoint and the active page, unless that widget lives inside a fragment (in which case, only the fragment reruns). The reason state vanishes when you switch pages is pretty straightforward: page-bound widget identities change, and the moment Streamlit stops rendering a widget, it cleans up its state.
The Read/Write Session State Trap
If your sliders keep snapping back to zero or your multi-selects randomly clear out, you're probably reinitializing a widget with changing defaults or trying to assign its key after creating it. When you pass a changing dictionary value into a widget's value parameter while also tracking that state somewhere else, those two sources of truth are bound to drift apart. As soon as the default or identity shifts between runs, the widget looks like it just reset itself.
Skip the manual juggling and bind the widget straight to session state using the key parameter instead:
# Manual default can diverge when other code changes the stored value
if "form_data" not in st.session_state:
st.session_state.form_data = {"cote": 0}
# Fragile: two sources of state can diverge when defaults change
cote = st.select_slider("Cote", options=range(0, 999), value=st.session_state.form_data["cote"])
st.session_state.form_data["cote"] = cote
# THE FIX: Let Streamlit manage the synchronization natively
if "cote_slider" not in st.session_state:
st.session_state.cote_slider = 0
st.select_slider("Cote", options=range(0, 999), key="cote_slider")
# Retrieve the value elsewhere directly via st.session_state["cote_slider"]MPAv2 Navigation vs. Component Remounting
Starting in Streamlit 1.36, the st.navigation API changes how subpages actually render. If you leave initialization routines or heavy database queries sitting at the root level of main.py, they're going to fire on every single rerun – including basic page switches.
All that extra work bogs down your transitions and causes that jarring flicker as elements redraw. Keep your shared navigation and global widgets in the entry script, push page-specific data loading down into the target pages themselves, and cache stable queries wherever you can.
Fixing Form Resets and Ghosting
Managing State Outside Form Boundaries
Forms (st.form) are great for batching inputs so your app doesn't rerun on every single keystroke. The catch?
If a user tweaks a widget sitting outside the form while they're halfway through filling it out, that external rerun will completely wipe their unsubmitted form data. Giving a form widget a key lets you read submitted values inside callbacks, sure, but it can't rescue edits that never made it to the backend in the first place. To keep users from losing half-finished work, either make sure they submit the form before touching outside controls, or rethink your layout so external widgets don't interrupt an active edit.
Preventing Double Reruns on File Uploads and Sliders
Uploading a hefty CSV or panning around a map can quickly expose expensive, repeated work on back-to-back reruns – especially if your parsing logic is just sitting loose in the main script. Changing a file uploader triggers a normal script rerun, and an on_change callback will fire right before that rerun happens (whereas a separate watcher rerun only triggers if an actual watched file changes). Hook up an on_change callback to handle one-time parsing and cache the heavy lifting, keeping in mind that the callback won't stop the normal rerun from happening.
def process_upload():
# Process data only once when the file changes, not on every page rerun
if st.session_state.uploaded_file:
st.session_state.df = pd.read_csv(st.session_state.uploaded_file)
st.file_uploader("Upload CSV", key="uploaded_file", on_change=process_upload)Resolving Slow Page Transitions
The st.empty() Pitfall in Multipage Apps
Trying to fake a "loading screen" by wrapping your whole page in st.empty() and clearing it during a page switch usually just makes the redraw look clunky. More often than not, the placeholder wipes itself before the next page is actually ready to render, leaving your user staring at a blank white gap or elements popping in out of order.
Think of st.empty() as a single replaceable slot – you can nest a container inside it to swap out a group of elements, but don't try to use it as a page-navigation controller. Clearing out a massive container forces a noticeable visual redraw and does nothing to lighten the workload for the incoming page.
Leveraging Fragments (@st.fragment) for Partial Rendering
Rather than wrestling against full-page reruns, just cordon off your heaviest components. Streamlit added @st.fragment specifically so you can let individual functions rerun on their own without dragging the rest of the script along.
If you've got a live-updating chart or a KPI metric hitting a slow query, wrap it in a fragment. Any time a user interacts with widgets inside that block, only that specific section reruns, sparing the rest of your page from an unnecessary redraw.
import streamlit as st
import time
st.title("Main MPAv2 Page")
st.write("This stays unchanged during fragment-only interactions.")
@st.fragment
def heavy_kpi_dashboard():
# Only this specific section reruns on interaction
target = st.selectbox("Select Region", ["North", "South", "East"])
with st.spinner("Querying database..."):
time.sleep(2) # Simulate heavy DB call
st.metric(label=f"{target} Revenue", value="45,000 USD")
heavy_kpi_dashboard()Reproducible Testing: State Matrix & Timing Harness
If you want to be 100% sure your components are holding onto their state without triggering sneaky double reruns, drop this little timing harness straight into your main.py. It tracks execution time and keeps a shared entrypoint widget pinned across page switches so you can see exactly what's happening.
import streamlit as st
import time
# 1. Initialize Timing Harness
if 'start_time' not in st.session_state:
st.session_state.start_time = time.time()
st.session_state.run_count = 0
st.session_state.run_count += 1
start = time.perf_counter()
# 2. MPAv2 Navigation Setup
pages = {
"Dashboard": [st.Page(lambda: st.write("Core App"), title="Main")],
"Settings": [st.Page(lambda: st.write("Config"), title="Config")]
}
pg = st.navigation(pages)
# 3. State Matrix Verification Widget
st.sidebar.text_input("State Retention Test", key="test_input")
pg.run()
# 4. Measure Execution Delta
end = time.perf_counter()
st.sidebar.divider()
st.sidebar.caption(f"Run Count: {st.session_state.run_count}")
st.sidebar.caption(f"Rerun Time: {end - start:.4f}s")Fire this up and start clicking rapidly through options in your main view. If test_input wipes itself out, double-check your navigation setup and where your widgets live; if the run count ticks up when you haven't even touched anything, go inspect your callbacks and watched files. As a rule of thumb: keep shared widgets in the entrypoint, give page-level widgets their own persistent keys, and lean on fragments whenever a local interaction shouldn't trigger a full-page refresh.
Quick answers
Frequently asked questions
Why does a widget lose its value when users switch pages in a multipage Streamlit app?
A widget will drop its value when you switch pages if you haven't explicitly saved its state with a unique key in session state. Whenever you define a widget inside a dynamic layout or render it conditionally without assigning it a key, Streamlit simply throws out that state during the navigation rerun. If it's a widget you need across multiple pages, your best bet is rendering it right in the st.navigation entry point. For widgets that belong to a single page, make sure to copy their values into a separate persistent key before the user heads off to another view.
How do you stop a Streamlit widget from resetting every other interaction?
That frustrating back-and-forth reset usually happens when a widget's identity or its default value keeps shifting between runs. If you pass a dynamic state value as the default while trying to manage another copy somewhere else, the two can easily get out of sync. You can clear this up pretty easily by giving the widget its own dedicated key and just letting Streamlit handle the internal updates on its own.
Why does interacting with a widget outside an st.form reset unsubmitted form inputs?
Whenever someone touches a widget outside the form, Streamlit kicks off a full top-to-bottom rerun, which resets any unkeyed form fields straight back to their defaults. Since nobody hit submit, those transient inputs only lived in the user's browser, so the re-execution just wipes them out. Giving the fields keys won't rescue unsubmitted edits either. You'll either need users to submit the form before touching outside controls, or rethink how that outside interaction fits into the workflow.
What causes an immediate double rerun when uploading files in Streamlit?
Under normal circumstances, an upload triggers just a single rerun, but an explicit st.rerun call or an altered file being watched by Streamlit will trigger a second one right on its heels. Doing heavy processing right in the main body makes that first run feel sluggish, while a file-watcher trigger only happens if an actual monitored file changes. If you move your parsing logic into an on_change callback, the heavy lifting only happens when the uploader changes, though keep in mind the regular script rerun still runs as expected.
Why does wrapping pages in st.empty cause visual ghosting during multipage navigation?
Wiping the entire screen with st.empty clears or swaps out the placeholder before the incoming view has finished rendering. That tends to make page switches feel jarring and glitchy when the next page takes a moment to load, even though it doesn't mean your frontend state is actually corrupted. The cleaner fix is to put heavy tasks behind proper loaders inside the specific pages instead of clearing out the root container.
How does using st.fragment help reduce rerun delays in a multipage application?
Wrapping a function in st.fragment confines widget reruns strictly to that isolated block of code. When someone interacts with widgets inside the fragment, your main multipage script, root navigation, and surrounding components don't have to rerun from scratch. Tucking resource-heavy calculations into their own fragments keeps the rest of your app snappy and responsive.
Where should database queries be placed when using st.navigation in Streamlit 1.36 and later?
You'll want to keep page-specific database queries inside the actual page functions, leaving your root entry script for shared navigation and global widgets. Sticking heavy queries into main.py forces them to run every single time any page reruns, which slows down navigation and makes UI redraws much more obvious. Keeping data fetching and setup inside each child view makes sure only the active page spends time querying your database.
Something wrong or out of date?
Tools change fast. Tell the editor what no longer works.