Session state already survives a page switch — the map resets because the page rebuilds it from a constant. Read the bounding box from state on every render, write it back only from the map page behind an equality guard, and mirror it into the query string so a refresh survives too.

Jump to heading Why this matters

The complaint arrives in the same words every time: “I pan to my area, click over to the table, come back, and I’m looking at the whole country again.” It is the single most reported defect in multi-page spatial dashboards, and it is almost never a state-persistence bug. Streamlit keeps st.session_state for the life of the session, across every page in it. What does not survive is the folium.Map object, the pydeck.Deck, and every local variable that built them — and if the code that builds them reads a hard-coded centre and zoom, the view resets no matter how faithfully the state was preserved.

Fixing it properly means separating three things that are easy to conflate: the viewport value, which is small and belongs in session state; the map object, which is derived and should be rebuilt from that value on every render; and the viewport event, which only the map page produces and which needs a guard so it does not feed itself. This guide wires all three, and then adds the URL so the view survives a browser refresh as well.

The state was never lost — the map was rebuilt from the wrong thingAn analyst pans to a borough, switches to the table page, and returns. In the upper trace, session state carries the panned bounding box through both transitions untouched, but the map page constructs its map from a hard-coded centre and zoom, so the returning view is the national default and the analyst concludes the dashboard forgot. The stored bounding box is still there, unread. In the lower trace the map is constructed from the same session-state value on every render, so returning restores the borough exactly. The two traces differ by one line of code and not at all in what was persisted, which is why this reads as a persistence bug and is not one.MAP BUILT FROM A CONSTANT→ Table→ Mapsession_state.bboxthe panned borough — carried through both transitions, and never readrendered viewboroughnational default — folium.Map(location=[54, -2], zoom_start=6)MAP BUILT FROM SESSION STATEsession_state.bboxthe same value — now read on every renderrendered viewboroughborough — restored exactlyOne line of difference, and none of it in what was persisted — which is why this is so consistently misdiagnosed.

Jump to heading Prerequisites

  • streamlit>=1.36, streamlit-folium>=0.20 or pydeck, and the multi-page setup from Multi-Page App Navigation.
  • A shared bootstrap that runs above pages.run(). Everything below depends on it.

Jump to heading Step-by-step solution

Jump to heading Step 1 — Give the viewport a default, above every page

python
# app.py — runs before any page body, including on a deep link
import streamlit as st

DEFAULT_BBOX = (-0.5103, 51.2868, 0.3340, 51.6919)   # EPSG:4326

def bootstrap() -> None:
    st.session_state.setdefault("bbox", DEFAULT_BBOX)
    st.session_state.setdefault("epsg", 4326)

st.set_page_config(layout="wide")
bootstrap()
st.navigation([st.Page("pages/map_view.py"), st.Page("pages/table_view.py")]).run()

setdefault rather than an assignment is what makes this safe to run on every page: it writes only when the key is absent, so it can never overwrite a viewport the analyst just panned to.

Jump to heading Step 2 — Build the map from state, never from a constant

python
# pages/map_view.py
import folium
from streamlit_folium import st_folium

def build_map(gdf, bbox):
    m = folium.Map(tiles="CartoDB positron")
    m.fit_bounds([[bbox[1], bbox[0]], [bbox[3], bbox[2]]])   # (s,w) then (n,e)
    folium.GeoJson(gdf.__geo_interface__, name="layer").add_to(m)
    return m

layer = load_layer(st.session_state.epsg)          # cached, shared across pages
result = st_folium(build_map(layer, st.session_state.bbox),
                   height=560, width="100%",
                   returned_objects=["bounds"])

fit_bounds rather than location and zoom_start is deliberate. A bounding box is what the other pages and the URL carry, and converting it to a centre and zoom loses information — two different extents can share a centre and a rounded zoom level, so a round trip through them does not return the same view.

Note the argument order Leaflet uses: fit_bounds takes south-west then north-east as [lat, lon] pairs, while the bounding box is stored in the GeoJSON convention of west, south, east, north. Getting this wrong produces a map somewhere off the coast of West Africa, which is the traditional symptom of swapped latitude and longitude.

Jump to heading Step 3 — Write the viewport back, behind an equality guard

python
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:
        st.session_state.bbox = fresh     # store FIRST
        st.rerun()                        # then rerun

Two details carry the whole behaviour. Rounding to four decimals — about eleven metres — collapses the micro-movements Leaflet reports during a hover into a single value, so a stationary pointer stops producing “new” bounds. And storing before the rerun means the rerun’s own bounds event compares equal and terminates the cycle; storing after would leave the guard permanently one step behind and loop forever.

Jump to heading Step 4 — Mirror it into the URL

python
def sync_url() -> None:
    st.query_params["bbox"] = ",".join(f"{v:.4f}" for v in st.session_state.bbox)

def restore_url() -> None:
    raw = st.query_params.get("bbox")
    if not raw:
        return
    try:
        values = tuple(float(v) for v in raw.split(","))
    except ValueError:
        return                                     # a mangled link → the default
    if len(values) == 4 and -180 <= values[0] < values[2] <= 180:
        st.session_state.bbox = values

Call restore_url() in the bootstrap before setdefault, and sync_url() wherever the viewport changes. The validation is not decoration: query strings get truncated by chat clients and rewritten by mail scanners, and a malformed one should land on the default rather than on a traceback — or, worse, on a bounding box with west greater than east, which produces an empty map and no error at all.

Three arrivals, one bootstrapThe bootstrap runs the same three steps regardless of how the analyst arrived. First the query string is restored, which populates the bounding box when the arrival was a shared link or a refresh and does nothing otherwise. Then defaults are applied with setdefault, which fills only keys that are still missing — so it supplies the national extent on a genuinely fresh session and leaves a restored or previously panned value alone. Finally the page body reads the value, by which point it is correct for all three arrivals: a deep link shows the sender's view, a navigation within the session shows the analyst's own, and a first visit shows the default. Reversing the first two steps breaks the deep link, because the default would overwrite the restored value before anything read it.THE BOOTSTRAP, AND WHY THE ORDER IS FIXED1 · restore_url()populates from a link, or does nothing2 · setdefault()fills only what is still missing3 · the page reads itWhat each arrival getsa shared link → step 1 supplies the sender's extent, step 2 leaves it alonea page switch mid-session → the key already exists, so both steps are no-ops and the analyst's own view is kepta fresh session → step 1 finds nothing, step 2 supplies the national defaultSwap steps 1 and 2 and the deep link breaks: the default would be written before the link had a chance to be read. Three arrivals a viewport has to surviveA viewport is exercised by three different journeys and it is common for a dashboard to handle two of them. A page switch within a session is handled by session state alone and is the one everybody tests. A browser refresh discards session state entirely, so only the query string can carry the view — a dashboard that handles the first and not this one loses the analyst's place every time they reload after a deploy. A link pasted to a colleague has neither session state nor a prior page, so it exercises the restore path in isolation, and it is the journey that finds the bug where defaults are applied before the URL is read. Testing all three takes a minute and they fail independently.THREE JOURNEYS, TESTED SEPARATELYpage switch within a sessioncarried by session state; the one that always gets testedbrowser refreshsession state is gone — only the query string can carry the viewa link pasted to a colleagueno session state and no prior page; finds the ordering bug in the bootstrapThey fail independently, so passing the first says nothing about the other two.

Jump to heading Verification

Three checks, none of which a manual click-through reliably catches.

python
# 1. The bootstrap never clobbers a live viewport.
st.session_state.bbox = (-0.20, 51.45, -0.05, 51.55)
bootstrap()
assert st.session_state.bbox == (-0.20, 51.45, -0.05, 51.55)

# 2. A mangled link degrades to the default rather than raising.
st.query_params["bbox"] = "1,2,broken"
restore_url()
assert len(st.session_state.bbox) == 4

# 3. An inverted box is rejected, not stored.
st.query_params["bbox"] = "0.33,51.28,-0.51,51.69"      # west > east
before = st.session_state.bbox
restore_url()
assert st.session_state.bbox == before, "an inverted bbox was accepted"

Then walk it: pan, switch page, switch back, and confirm the extent. Refresh the browser and confirm it again. Copy the URL into a private window and confirm a third time. Each of the three exercises a different mechanism, and it is common for two to work while the third does not.

Jump to heading Edge cases and gotchas

  • The first bounds event arrives before the map has settled. Some component versions emit the default extent once on mount, which will overwrite a restored deep-link view. Ignore bounds events on the very first render of a session by keeping a viewport_ready flag in state.
  • Two map pages, one bounding box. If a second page also renders a map, decide which one owns writes. Two writers with independent guards will fight, each seeing the other’s value as a change.
  • Zoom is not recoverable from bounds alone. A bounding box fixes the extent, not the zoom level, and a component may choose a zoom whose extent is slightly larger. This is usually fine and occasionally not — if exact zoom matters, store it alongside the box rather than deriving it.
  • st.query_params does not trigger a rerun. Writing it updates the URL for the next natural rerun. Do not add an explicit rerun just to refresh the URL, or you have built the loop the guard was preventing.

Jump to heading FAQ

Why not store the folium map object in session state?

Because it is derived state, and storing it makes it the thing that has to be kept in sync with the data, the filters and the theme rather than simply being rebuilt from them. It is also large. Rebuilding a map from a cached layer and four floats takes milliseconds; keeping a stale one correct takes forever.

My map jumps slightly on every page return. Why?

fit_bounds picks the nearest zoom level that contains the box, which is usually a hair wider than the extent you left. Each round trip through bounds and back can therefore drift outward by a fraction of a zoom step. Store the zoom explicitly alongside the bounding box if the drift is visible, and apply both.

Should the table page also update the viewport?

Only if selecting a row is meant to move the map, and even then it should write the bounding box rather than manipulate a map object it does not own. Keeping exactly one writer per piece of state is what makes the equality guard sufficient.

Back to Multi-Page App Navigation.