Fixing a Map That Resets or Disappears on Every Interaction

Problem statement

The user zooms into a city, moves a slider, and the map is back at the national view. Or they draw an area of interest, change a filter, and the drawing is gone. Or they click a district to select it and the selection lasts exactly until the next interaction.

All three are the same thing: Streamlit rebuilds the component on every rerun, and anything the component was holding is not part of the app's state.

There is a fourth variant that looks different and shares the cause: the map re-runs the app continuously while being panned, because the component returns its state and any returned value triggers a rerun.

Quick answer

Own the state in the app, not in the component:

import pydeck as pdk
import streamlit as st

# 1. keep the view
if "view" not in st.session_state:
    st.session_state.view = pdk.ViewState(latitude=54.0, longitude=-2.0, zoom=5)

deck = pdk.Deck(layers=layers, initial_view_state=st.session_state.view)
st.pydeck_chart(deck)

# 2. keep the drawing
state = st_folium(build_map(), returned_objects=["last_active_drawing"])
if state and state.get("last_active_drawing"):
    st.session_state.aoi = shape(state["last_active_drawing"]["geometry"])

# 3. keep the selection
st.session_state.setdefault("selected", set())

# 4. stop panning from re-running the app
returned_objects=["last_active_drawing"]        # not the default, which includes bounds

The pattern is the same in each case: capture what the component returned, store it in session_state, and pass it back when the component is rebuilt.

Triage table of four things lost on rerun and where each should be stored.
The fourth looks different and shares the cause.

Step-by-step solution

1. Understand why it resets

Streamlit re-executes the script on every interaction. pdk.Deck(...) and folium.Map(...) are constructed fresh, and the front end receives a new component with its declared initial state.

Nothing has "reset". A new map has been created from the same code, which is the same view every time.

2. Keep the view in session_state

if "view" not in st.session_state:
    minx, miny, maxx, maxy = layer.total_bounds
    st.session_state.view = pdk.ViewState(
        latitude=(miny + maxy) / 2, longitude=(minx + maxx) / 2, zoom=6)

deck = pdk.Deck(layers=layers, initial_view_state=st.session_state.view)

For pydeck the view is set at construction and is not returned, so the app keeps it deliberately โ€” for example, updating it when a filter should re-frame the map:

if st.session_state.get("zoom_to_selection") and len(subset):
    minx, miny, maxx, maxy = subset.total_bounds
    st.session_state.view = pdk.ViewState(
        latitude=(miny + maxy) / 2, longitude=(minx + maxx) / 2, zoom=9)

3. Capture and re-add the drawing

from shapely.geometry import shape

state = st_folium(build_map(st.session_state.get("aoi")),
                  returned_objects=["last_active_drawing"])

if state and state.get("last_active_drawing"):
    st.session_state.aoi = shape(state["last_active_drawing"]["geometry"])

and in build_map, draw the stored shape back onto the new map:

if existing is not None:
    folium.GeoJson(existing.__geo_interface__,
                   style_function=lambda _: {"color": "#ef4444", "weight": 2,
                                             "fillOpacity": 0.05}).add_to(m)

Both halves are required. Storing it keeps the filter; re-adding it lets the user see what they filtered by.

4. Stop the component re-running the app on every pan

state = st_folium(m, returned_objects=["last_active_drawing"])

By default st_folium returns the centre, zoom, bounds, last click and drawings โ€” and all of those change while panning. Any returned value triggers a rerun, so the app re-runs continuously while the map moves.

Restricting the returned objects to what the app reads is the fix, and it is the difference between an app that feels responsive and one that feels possessed.

5. Keep selections as identifiers

st.session_state.setdefault("selected", set())

clicked = (state or {}).get("last_object_clicked")
if clicked and (properties := clicked.get("properties")):
    st.session_state.selected.symmetric_difference_update({str(properties["code"])})

Row indices change with every filter; a code does not. Storing identifiers is what makes a selection survive the data being re-filtered as well as the component being rebuilt.

6. Give the user a way to reset

st.sidebar.button("Reset view",
                  on_click=lambda: st.session_state.pop("view", None))
st.sidebar.button("Clear drawing",
                  on_click=lambda: st.session_state.pop("aoi", None))

Once state persists properly, the user needs a way out of it. on_click callbacks run before the rerun, which is why they can modify state that widgets depend on.

Flow showing an on_click callback setting state before the rerun.
That is why every reset in a Streamlit app is a callback rather than an if-block.

Code examples

Example 1 โ€” a map that keeps everything it should

import folium
import pydeck as pdk
import streamlit as st
from shapely.geometry import shape
from streamlit_folium import st_folium

DEFAULTS = {"latitude": 54.0, "longitude": -2.0, "zoom": 5}


def ensure_state():
    st.session_state.setdefault("view", pdk.ViewState(**DEFAULTS))
    st.session_state.setdefault("aoi", None)
    st.session_state.setdefault("selected", set())


def reset(*keys):
    def _reset():
        for key in keys:
            st.session_state.pop(key, None)
        ensure_state()
    return _reset


ensure_state()

with st.sidebar:
    st.button("Reset view", on_click=reset("view"))
    st.button("Clear drawing", on_click=reset("aoi"))
    st.button("Clear selection", on_click=reset("selected"))


def build_map(aoi, selected):
    m = folium.Map(location=[st.session_state.view.latitude,
                             st.session_state.view.longitude],
                   zoom_start=st.session_state.view.zoom,
                   tiles="cartodbpositron")

    folium.GeoJson(
        geojson_of(districts, version),
        style_function=lambda f: {
            "fillColor": "#ef4444" if f["properties"]["code"] in selected
                         else "#0ea5e9",
            "color": "white", "weight": 0.5, "fillOpacity": 0.5},
        tooltip=folium.GeoJsonTooltip(fields=["name"]),
    ).add_to(m)

    if aoi is not None:
        folium.GeoJson(aoi.__geo_interface__,
                       style_function=lambda _: {"color": "#ef4444", "weight": 2,
                                                 "fillOpacity": 0.05}).add_to(m)
    from folium.plugins import Draw
    Draw(export=False).add_to(m)
    return m


state = st_folium(build_map(st.session_state.aoi, st.session_state.selected),
                  height=560, width=None,
                  returned_objects=["last_active_drawing", "last_object_clicked"])

if state:
    if drawing := state.get("last_active_drawing"):
        st.session_state.aoi = shape(drawing["geometry"])
    if clicked := state.get("last_object_clicked"):
        if properties := clicked.get("properties"):
            st.session_state.selected.symmetric_difference_update(
                {str(properties["code"])})

Example 2 โ€” a state inspector while developing

import streamlit as st


def state_panel():
    """What survives a rerun, visible while you build the app."""
    with st.sidebar.expander("Session state", expanded=False):
        for key, value in sorted(st.session_state.items()):
            if key.startswith("_"):
                continue
            summary = (f"{type(value).__name__} ยท {len(value)}"
                       if hasattr(value, "__len__")
                       else type(value).__name__)
            st.text(f"{key:16} {summary}")

Watching the state panel while clicking around is the fastest way to see what is and is not being kept โ€” and it makes the model concrete in a way that reading about it does not.

Example 3 โ€” the rerun counter that proves the diagnosis

import streamlit as st

st.session_state["runs"] = st.session_state.get("runs", 0) + 1
st.sidebar.caption(f"rerun #{st.session_state['runs']}")

Two lines. Pan the map and watch the counter: if it climbs while you pan, returned_objects is not restricted and that is the problem to fix first.

Explanation

Why the component cannot keep its own state

Streamlit's front end receives a new component definition on every rerun. It has no way to know that the new deck is "the same map" as the old one, so it applies the initial view it was given.

The framework could in principle diff components, and does not. The consequence is a simple rule: any state the user creates inside a component must be captured on its way out and passed back in.

Why returned_objects is the fix for the panning loop

st_folium reports the map's state so the app can react to it, and the default set includes the bounds and the centre โ€” which change on every frame of a pan.

Since a component returning a value triggers a rerun, the app re-runs continuously while the map moves, rebuilding the map, which returns state, which re-runs the app. Restricting the returned set to what the app actually reads breaks the cycle.

Why identifiers survive and indices do not

A selection stored as row positions is valid only against the exact frame that produced it. Apply a filter, sort a column, or reload the data and the positions point at different rows.

Codes are stable across all of that, and they join to other tables, fit in a URL and mean something in a log line. It is the difference between a selection that survives a filter change and one that silently becomes wrong.

Why on_click callbacks matter for resets

Setting a widget's value in session_state after the widget has been created raises an error. A reset button that assigns state in the script body therefore fails.

on_click callbacks run before the next rerun, so state assigned there is applied when the widgets are created. That is why every reset in the examples uses a callback rather than an if button: block.

Two panels showing a rerun counter climbing while panning and staying stable.
If the number climbs, returned_objects is unrestricted.

Edge cases or notes

  • st.session_state is the only user state that survives a rerun, besides widget values and caches.
  • returned_objects on st_folium โ€” restrict it, or panning re-runs the app.
  • Store selections as identifiers, never as row positions.
  • Re-add stored drawings to each rebuilt map, or the user cannot see their filter.
  • on_click callbacks run before the rerun โ€” that is where resets belong.
  • Widget keys bind to session_state, which is how a reset changes a widget.
  • pydeck does not return its view; the app must decide when to change it.
  • A rerun counter in the sidebar diagnoses the panning loop in two lines.

FAQ

Why does my Streamlit map reset its zoom on every interaction?

Because the component is rebuilt from the same code, with the same initial view. Store the view in st.session_state and pass it back when constructing the deck or map.

Why does my drawing disappear?

It exists only in the value the component returned at the moment it was made. Capture it into session_state immediately, and re-add it to the map you build next.

Why does the app re-run when I pan the map?

st_folium returns map state by default, including the bounds, and any returned value triggers a rerun. Set returned_objects to only what you use.

Why does my selection break after I change a filter?

Because it is stored as row positions, which change when the data is re-filtered. Store stable identifiers instead.

Why does my reset button raise an error?

Because it assigns to a widget's session_state key after the widget was created. Do it in an on_click callback, which runs before the rerun.

How do I know whether the map is re-running the app?

Add a rerun counter to the sidebar and pan the map. If the number climbs, returned_objects is unrestricted.