Keeping State Alive When a Dashboard Grows Past One Page
A spatial dashboard almost never stays one page. The first version is a map with filters; the second adds a table view; by the third there is a comparison page, an export page and an admin page, and something that used to be obvious has quietly become hard — an analyst pans to a region on the map page, switches to the table, comes back, and the map is at its default viewport again. The filters have reset. The layer they loaded is being loaded a second time.
Multi-page navigation is where the state model of a dashboard framework stops being an implementation detail. Streamlit reruns a different script per page, and Panel serves a different template; in both cases the objects your page built are gone and only whatever you deliberately put somewhere durable survives. This page is about deciding what that somewhere is, what belongs in it, and how to keep a heavy spatial layer from being reloaded every time somebody clicks a link.
Jump to heading The problem statement
Three distinct kinds of state cross a page boundary, and conflating them is the source of most multi-page bugs.
Navigation state is where the analyst is and what they were looking at: the viewport, the selected region, the active filters, the row they had highlighted. It is small, it is per-session, and losing it is the failure users actually notice.
Data state is the layer itself — a GeoDataFrame that took two seconds and four hundred megabytes to produce. It is large, it is identical for every session looking at the same region, and putting it in per-session storage is how a dashboard runs out of memory with six users connected.
Derived state is everything computed from the other two: a filtered subset, a rendered figure, an aggregate. It is cheap to recompute from the first two and expensive to store, and the correct default is to recompute it.
The rule that follows is short: navigation state goes in session storage, data state goes in a cache keyed by its inputs, and derived state goes nowhere. Almost every “why is this page slow” and “why did my selection disappear” question resolves to one of those three being in the wrong place.
Jump to heading Prerequisites
streamlit>=1.36for thest.Pageandst.navigationAPI, orpanel>=1.4if you are building a multi-template Panel application.- A working understanding of session state patterns — this page assumes you already know why a viewport belongs in session state and takes the next step of getting it across a navigation event.
- A cached loader for your layers, as covered in @st.cache_data implementation. Multi-page navigation makes an uncached loader painful in a way a single page does not, because every page transition reloads.
Jump to heading Core implementation workflow
Jump to heading Step 1 — Declare the pages in one place
Both frameworks have moved toward declaring navigation explicitly rather than inferring it from a directory listing. Explicit declaration is worth the few extra lines, because it puts the page list where a reader can see it and lets shared setup run before any page does.
import streamlit as st
st.set_page_config(page_title="Spatial Ops", layout="wide")
bootstrap_state() # defined in Step 2, runs before any page body
pages = st.navigation([
st.Page("pages/map_view.py", title="Map", icon=":material/map:"),
st.Page("pages/table_view.py", title="Table", icon=":material/table:"),
st.Page("pages/compare.py", title="Compare", icon=":material/compare:"),
])
pages.run()
Everything above pages.run() executes on every page, which makes it the right home for state initialisation, authentication and anything else that must be true regardless of where the analyst landed — including on a deep link that skips the map page entirely.
Jump to heading Step 2 — Give every shared key a default, once
The first script run of a session has no widgets and therefore no widget-written keys. A page that reads st.session_state.bbox before anything has written it raises, and because the raising line sits above the widget that would have created the key, the error repeats on every rerun. Initialise defensively in the shared bootstrap.
DEFAULTS = {
"bbox": (-0.51, 51.28, 0.33, 51.69), # EPSG:4326, Greater London
"epsg": 4326,
"region": "all",
"selected": [], # feature ids, not features
}
def bootstrap_state() -> None:
"""Idempotent: writes only what is missing, so it never clobbers a choice."""
for key, value in DEFAULTS.items():
st.session_state.setdefault(key, value)
Note what is not in DEFAULTS: no GeoDataFrame, no figure, no filtered subset. Session state holds identifiers and scalars — a bounding box, an EPSG code, a list of feature ids. The frame those ids refer to is fetched from the cache on whichever page needs it.
Jump to heading Step 3 — Load layers through one cached accessor
Every page that needs geometry calls the same function with the same arguments, so the second page to ask gets a cache hit rather than a second load.
import geopandas as gpd
@st.cache_data(ttl=3600, max_entries=12, show_spinner="Loading layer…")
def load_layer(region: str, epsg: int) -> gpd.GeoDataFrame:
"""One accessor, called from every page. The cache does the sharing."""
gdf = gpd.read_parquet(f"data/{region}.parquet")
if gdf.crs is None:
gdf = gdf.set_crs("EPSG:4326")
return gdf.to_crs(epsg=epsg)[["feature_id", "name", "category", "geometry"]]
# On any page:
layer = load_layer(st.session_state.region, st.session_state.epsg)
The arguments are exactly the session-state values that identify the layer, which is what makes the cache key stable across pages. Passing the frame itself between pages through session state would defeat this entirely — and would also pin one copy per session, which is the failure the previous section warned about.
Jump to heading Step 4 — Put navigation state in the URL as well
Session state survives navigation within a session; it does not survive a refresh, a shared link, or a browser restart. For anything an analyst might want to send to a colleague, mirror it into the query string, which costs a few lines and turns “here is what I am looking at” from a screenshot into a link.
def sync_query_params() -> None:
"""Session state is the source of truth; the URL is a durable mirror of it."""
st.query_params.update({
"region": st.session_state.region,
"bbox": ",".join(f"{v:.4f}" for v in st.session_state.bbox),
})
def restore_from_query_params() -> None:
"""Run once, in bootstrap, before the defaults are applied."""
params = st.query_params
if "region" in params:
st.session_state.region = params["region"]
if "bbox" in params:
try:
st.session_state.bbox = tuple(float(v) for v in params["bbox"].split(","))
except ValueError:
pass # a malformed link falls back to the default
Round the coordinates on the way out. An unrounded bounding box produces a URL of sixty characters of noise that no one can read, changes on every micro-pan, and — as dynamic spatial filtering explains — defeats the quantised cache key it is meant to correspond to. Four decimals is about eleven metres and is more precision than a shared link needs.
The try around the parse is not decoration. Query strings are user input, they get truncated by chat clients and mangled by email, and a malformed one should land the analyst on the default view rather than on a traceback.
Jump to heading Step 5 — Reconcile the viewport on the page that owns it
Only the map page can move the viewport, but every page may need to know where it is. Have the map page write it back into session state after any change, and have the other pages treat it as read-only.
# pages/map_view.py — the one page that writes the viewport
result = st_folium(build_map(layer, st.session_state.bbox), height=560,
returned_objects=["bounds"])
if result and result.get("bounds"):
b = result["bounds"]
fresh = (round(b["_southWest"]["lng"], 4), round(b["_southWest"]["lat"], 4),
round(b["_northEast"]["lng"], 4), round(b["_northEast"]["lat"], 4))
if fresh != st.session_state.bbox: # guard: identical bounds fire constantly
st.session_state.bbox = fresh
sync_query_params()
st.rerun()
The equality guard is the same one described in syncing dropdown filters with map boundaries, and it matters more in a multi-page app rather than less: a rerun loop that merely wastes cycles on a single page will, on a page-switch, also re-run the destination page’s load.
Jump to heading Advanced patterns
Panel’s equivalent. Panel serves each template from its own function, and pn.state.cache is process-global rather than per-session — the opposite default from Streamlit’s session state. Per-session values go in pn.state.session_info or in a parameterised class instance created per session; shared layers go in pn.state.cache or behind pn.cache, exactly as the Folium versus ipyleaflet comparison describes. Getting these the wrong way round produces the two failures in the diagram above, and produces them in Panel more easily than in Streamlit because the global cache is the more convenient of the two to reach for.
Deep links that skip the landing page. Once the viewport is in the query string, an analyst can bookmark a page mid-workflow, which means every page must be able to bootstrap itself from nothing but the URL. This is a good constraint: it forces the shared bootstrap to be genuinely complete, and it makes each page independently testable. It also means the restore has to run before any page body reads a key, which is the reason Step 1 puts the bootstrap above pages.run().
Keeping a selection meaningful across pages. A list of feature identifiers survives a page switch cheaply. A list of row positions does not, because the next page may load a differently filtered frame and position seventeen will point at something else entirely. Store identifiers, resolve them to rows on whichever page needs them, and treat a missing identifier as a selection that has been filtered out rather than as an error.
Jump to heading Verification and testing
The behaviours worth asserting are the ones a manual click-through misses.
# 1. The bootstrap is idempotent — running it twice never clobbers a choice.
st.session_state.region = "camden"
bootstrap_state()
assert st.session_state.region == "camden", "bootstrap overwrote a live value"
# 2. The layer accessor is shared, not per page.
a = load_layer("camden", 4326)
b = load_layer("camden", 4326)
assert a is not None and len(a) == len(b)
assert load_layer.clear is not None # it really is the cached wrapper
# 3. A malformed deep link degrades to the default rather than raising.
st.query_params["bbox"] = "not,a,bbox"
restore_from_query_params()
assert len(st.session_state.bbox) == 4, "a bad link should fall back, not crash"
Beyond the assertions, walk the application with the browser devtools network panel open and switch pages twice. The second visit to a page should issue no data request at all. If it does, the accessor is being called with arguments that differ between pages — usually an EPSG code passed as an integer on one page and a string on another, which produces two cache entries that hold identical frames.
Jump to heading Troubleshooting
KeyError on a session-state key, on the first load only. The read is above the widget that creates the key, and the bootstrap either does not cover it or runs after pages.run(). Add the key to DEFAULTS.
The layer reloads on every page switch. The accessor’s arguments differ between pages, or the accessor is defined inside a page module so each page gets its own decorated function with its own cache. Define it once in a shared module and import it.
The viewport resets when navigating back. The map page is initialising the map from a constant rather than from session state. The map’s starting bounds must be read from state on every render, not only on the first.
Memory climbs with the number of connected analysts. A frame is in session state. Grep for assignments of GeoDataFrame values into st.session_state — there should be none, and the fix is to store the arguments that identify it instead.
The URL updates but the page does not. st.query_params.update does not trigger a rerun on its own. Either accept that the URL is a mirror updated on the next natural rerun, or call st.rerun() explicitly after changing state that the page renders from.
Jump to heading Performance considerations
Page transitions are the moment a dashboard feels fast or slow, because they are the only interaction where the user expects a whole new screen and will therefore forgive a short delay — but only a short one. The budget worth holding to is that no page transition triggers a data load: everything a destination page needs should already be in a cache keyed by values that survived the transition.
Bound the shared cache with both a TTL and a max_entries, and size the entry count against pages rather than sessions. A four-page dashboard where each page can request a layer for any of twelve regions has at most twelve distinct entries, not forty-eight — provided every page routes through the same accessor. The moment a page grows its own loader, that number multiplies, which is a good reason to treat “one accessor per layer” as an architectural rule rather than a convention.
Finally, keep the shared bootstrap genuinely cheap. It runs on every page transition, and anything expensive in it — a database round trip for a lookup table, a permissions check that is not cached, a directory scan — is paid on every click, on top of whatever the destination page does. Cache the lookups it needs with the same discipline you would apply to a layer, and the transition stays at the few tens of milliseconds where it belongs.
Back to Core Dashboard Architecture & State Management.