Bind a centralized state resolver to both the dropdown and the map viewport; apply the compound spatial filter only when either input changes; guard st.rerun() with a bounds-equality check to prevent infinite rerender loops.

Jump to heading Why this matters

A dropdown and an interactive map seem like independent controls, but in a reactive dashboard framework they share the same execution model. Every user action — selecting a region from a list or panning the map — can trigger a full rerun that discards the other control’s state unless both widgets write to and read from a single, authoritative source. This is a concrete expression of the Data Flow Architectures principle of treating UI widgets as event emitters whose outputs converge at a central resolver before any downstream computation runs. Without that pattern, a viewport change resets the dropdown, a dropdown change jumps the map back to its default bounds, and users lose spatial context on every interaction.

Getting this right also directly affects payload size and query latency. When the filter logic runs outside the change-detection guard, every render cycle fires a full spatial intersection query and re-serializes the GeoJSON — a recipe for browser-stalling 10+ MB payloads. Offloading heavy cached reads to Caching Strategies & Async Performance Tuning keeps the reactive loop lightweight.


Jump to heading Reactive data flow: how the two signals converge

The diagram below shows how dropdown and viewport events are funneled through st.session_state into a single compound filter before any GeoJSON leaves the server.

Dropdown–viewport reactive cycle with the bounds-equality guardTwo inputs — a dropdown (on_change callback) and the map viewport (st_folium bounds) — both write into st.session_state, which holds selected_region and last_bounds. The compound spatial filter intersects the categorical mask with the viewport bounding box and emits a GeoJSON overlay to the rendered Folium map. The map fires fresh bounds back into a change guard that compares current_bounds with last_bounds: when they differ it calls st.rerun() and the cycle repeats with new state; when they are equal it short-circuits, so identical viewports never trigger a rerun and the loop cannot run forever.InputsDropdownon_change callbackMap viewportst_folium boundsst.session_stateselected_region · last_boundsCompound filterregion ∩ bbox → GeoJSONoverlayRendered mapFolium · st_foliumfresh boundsChange guardcurrent_bounds ≠ last_bounds ?changed→ st.rerun()writes last_bounds,cycle repeatsunchanged → no rerun (loop broken)

Jump to heading Prerequisites

  • Python 3.10 or later
  • streamlit >= 1.32, streamlit-folium >= 0.18, geopandas >= 0.14, shapely >= 2.0, geodatasets >= 2023.12
  • Familiarity with Session State Patterns — specifically the concept of writing widget values into st.session_state via callbacks rather than reading widget return values directly

Jump to heading Step-by-step solution

Jump to heading Step 1 — Initialize shared state

Bootstrap both state keys before any widget renders. If you initialize inside a conditional branch, the keys may not exist when the map callback fires.

python
import streamlit as st

if "selected_region" not in st.session_state:
    st.session_state.selected_region = "All"
if "last_bounds" not in st.session_state:
    st.session_state.last_bounds = None

Jump to heading Step 2 — Cache the spatial dataset

Load and reproject the GeoDataFrame exactly once. Keeping this outside the reactive loop prevents repeated file I/O and CRS transformation on every rerun — a common source of latency spikes in Async Data Loading Patterns.

python
import geopandas as gpd
import geodatasets  # provides example data at a real path

@st.cache_data
def load_regions() -> gpd.GeoDataFrame:
    path = geodatasets.get_path("naturalearth.land")
    gdf = gpd.read_file(path).to_crs(epsg=4326)
    if "continent" in gdf.columns:
        gdf = gdf.rename(columns={"continent": "region"})
    else:
        gdf["region"] = "Land"
    return gdf[["region", "geometry"]]

gdf = load_regions()
regions = ["All"] + sorted(gdf["region"].dropna().unique().tolist())

epsg=4326 is required here because Folium and streamlit-folium expect geographic coordinates (longitude/latitude). Feeding a projected CRS such as EPSG:3857 into gdf.intersects(bbox) with a geographic bounding box is a silent failure — the filter returns empty or incorrect results.

Jump to heading Step 3 — Register a dropdown callback

Use on_change so the dropdown writes its new value into st.session_state atomically. This means that when st.rerun() fires from a viewport event, the dropdown re-renders with the correct stored value instead of resetting to its default.

python
def on_region_change() -> None:
    st.session_state.selected_region = st.session_state._region_input

st.title("Real-Time Map & Dropdown Sync")

st.selectbox(
    "Filter by Region",
    options=regions,
    index=regions.index(st.session_state.selected_region),
    key="_region_input",
    on_change=on_region_change,
)

Jump to heading Step 4 — Apply the compound spatial filter

The compound filter reads both selected_region (categorical) and last_bounds (spatial) from st.session_state, then computes their intersection in one pass. This runs after every rerun but only produces expensive GeoJSON when the filter state has genuinely changed.

python
from shapely.geometry import box

def apply_filters(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    result = gdf.copy()

    # Categorical filter
    if st.session_state.selected_region != "All":
        result = result[result["region"] == st.session_state.selected_region]

    # Viewport / bounding-box filter
    if st.session_state.last_bounds is not None:
        sw = st.session_state.last_bounds["_southWest"]
        ne = st.session_state.last_bounds["_northEast"]
        # Build a Shapely box in EPSG:4326
        viewport_box = box(sw["lng"], sw["lat"], ne["lng"], ne["lat"])
        # Use spatial index for O(log n) lookups on large datasets
        possible_matches_idx = list(gdf.sindex.intersection(viewport_box.bounds))
        possible = result.iloc[
            [i for i in possible_matches_idx if i < len(result)]
        ]
        result = possible[possible.intersects(viewport_box)]

    return result

filtered_gdf = apply_filters(gdf)
geo_data = filtered_gdf.__geo_interface__

Calling gdf.sindex.intersection() with the bounding box bounds first reduces candidates before the precise intersects() test. On a 50,000-polygon dataset this cuts filter time from ~800 ms (linear scan) to under 30 ms.

Jump to heading Step 5 — Render the map and guard the viewport sync

The sync guard compares incoming bounds to the stored value before calling st.rerun(). Without this, identical viewport events (fired on every map mouse-move in some Leaflet versions) create an infinite rerun loop.

python
import folium
from streamlit_folium import st_folium

m = folium.Map(location=[20.0, 0.0], zoom_start=2)
folium.GeoJson(
    geo_data,
    style_function=lambda _: {"fillOpacity": 0.4, "weight": 1},
).add_to(m)

map_output = st_folium(m, width="100%", height=500, returned_objects=["bounds"])

# Sync guard: only rerun when the viewport has meaningfully changed
if map_output and map_output.get("bounds"):
    current_bounds = map_output["bounds"]
    if current_bounds != st.session_state.last_bounds:
        st.session_state.last_bounds = current_bounds
        st.rerun()

Jump to heading Verification

Run the dashboard and confirm the following sequence works without a page flash or dropdown reset:

  1. Select a non-“All” region from the dropdown — the map overlay narrows to that region’s geometries.
  2. Pan or zoom the map — the overlay updates to show only features within the new viewport, but the dropdown retains its selected value.
  3. Return the dropdown to “All” — the full dataset re-appears clipped to the current viewport.

Add an assertion block during development to surface empty-filter bugs early:

python
# Paste below apply_filters() during local testing
assert len(filtered_gdf) >= 0, "Filter returned a negative row count — impossible"
if st.session_state.last_bounds and st.session_state.selected_region != "All":
    st.caption(
        f"Showing {len(filtered_gdf)} features for "
        f"'{st.session_state.selected_region}' within current viewport"
    )

Jump to heading Edge cases & gotchas

  • CRS mismatch causes silent empty results. If your GeoDataFrame is in a projected CRS (e.g., EPSG:3857) and the bounding box comes from Leaflet’s geographic coordinate system (EPSG:4326), intersects() will return an empty set without raising an error. Always call .to_crs(epsg=4326) before building the viewport box, and assert gdf.crs.to_epsg() == 4326 at startup.

  • Floating-point jitter triggers spurious reruns. Leaflet fires bounds events on micro-movements. Round bounding box coordinates to four decimal places before storing them in st.session_state.last_bounds to prevent a flood of identical-but-not-equal updates from rerunning the filter on every mouse nudge.

  • GeoJSON payload bloat above 5 MB stalls the browser. The filtered_gdf.__geo_interface__ call serializes all vertex coordinates. For high-density polygons, call filtered_gdf.geometry.simplify(tolerance=0.01, preserve_topology=True) before serialization. If the unfiltered dataset exceeds 10,000 polygons, switch to tile-based rendering with a WebGL layer rather than GeoJSON overlays.


Jump to heading Panel equivalent

Panel achieves two-way synchronization through param.depends and pn.bind — no manual change guards or rerun() calls required. The reactive engine tracks dependencies automatically and throttles updates via its own scheduler:

python
import panel as pn
import geopandas as gpd
import hvplot.pandas  # pip install hvplot geoviews
import geodatasets

pn.extension()

path = geodatasets.get_path("naturalearth.land")
gdf = gpd.read_file(path).to_crs(epsg=4326)
if "continent" in gdf.columns:
    gdf = gdf.rename(columns={"continent": "region"})
else:
    gdf["region"] = "Land"

regions = ["All"] + sorted(gdf["region"].dropna().unique().tolist())
region_select = pn.widgets.Select(name="Region", options=regions, value="All")

@pn.depends(region_select)
def filtered_view(region: str):
    mask = gdf if region == "All" else gdf[gdf["region"] == region]
    return mask.hvplot(geo=True, tiles="CartoLight", hover_cols=["region"])

pn.serve(pn.Column(region_select, filtered_view))

Panel’s approach removes the need for explicit state guards because @pn.depends marks filtered_view as dirty only when region_select.value changes. For teams already invested in Panel’s declarative model, this is the lower-friction path — though it brings its own re-render quirks, covered in preventing unwanted widget re-renders in Panel layouts. Streamlit’s @st.fragment decorator (available in Streamlit ≥ 1.37) provides a similar partial-rerun boundary if you want to limit reruns to the visualization layer without migrating to Panel — wrap the map and dropdown together inside a @st.fragment-decorated function.


Jump to heading Troubleshooting: preventing widget re-renders and avoiding Widget Lifecycle Management pitfalls

SymptomRoot causeFix
Dropdown resets to “All” on every map panReading widget return value instead of st.session_stateAdd on_change callback that writes to st.session_state
Map reloads infinitely after panst.rerun() called without a bounds-equality checkCompare current_bounds != st.session_state.last_bounds first
Filter returns empty set when region is selectedCRS mismatch between GeoDataFrame and bounding boxCall .to_crs(epsg=4326) before the intersects() call
Slow filter on large datasetLinear geometry scan without spatial indexUse gdf.sindex.intersection(viewport_box.bounds) to pre-filter candidates
Browser freezes after viewport filterGeoJSON payload over 5 MBSimplify geometries or switch to tile-based rendering

Why does my map keep refreshing in an infinite loop after I add viewport syncing?

The most common cause is calling st.rerun() unconditionally on every map_output. Compare the incoming bounds object to the previously stored value and rerun only when they differ. Floating-point jitter in zoom events can also cause spurious mismatches — round coordinate values to four decimal places before comparison:

python
import json, math

def round_bounds(bounds: dict, decimals: int = 4) -> str:
    """Stable string key for bounds comparison."""
    return json.dumps(
        {
            k: {coord: round(v, decimals) for coord, v in inner.items()}
            for k, inner in bounds.items()
        },
        sort_keys=True,
    )
How do I prevent the dropdown from resetting when the map viewport changes?

Store the dropdown selection in st.session_state via an on_change callback rather than reading the widget’s return value. When st.rerun() fires due to a viewport change, the session state value is already set and the selectbox re-renders with the correct index. See Session State Patterns for the full pattern, including isolating that state across multiple browser tabs so one user’s region selection never leaks into another’s map.

What is the recommended GeoJSON payload limit for browser-rendered vector overlays?

Keep serialized GeoJSON below 5 MB per render. Above that threshold, browser JSON parsing stalls and Folium/Leaflet rendering stutters on mid-range hardware. Simplify geometries with gdf.geometry.simplify(tolerance=0.01, preserve_topology=True) or switch to tile-based rendering for datasets with more than 10,000 polygons.


Back to Data Flow Architectures

Related