Fixing a Map That Resets or Disappears on Every Interaction

On this page

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: the interaction changed the map the app hands to the component, so the component built a new one from its starting view, and anything it was holding went with the old one.

There is a fourth variant that looks different and shares the cause: the map re-runs the app after every pan and zoom, because the component returns its state and a changed return value triggers a rerun.

Quick answer

Keep the map the same from run to run, and put everything that changes somewhere the component can update in place:

import streamlit as st
from streamlit_folium import st_folium

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


def on_map_click():                         # runs before the script re-runs
    feature = (st.session_state.map or {}).get("last_active_drawing")
    if feature:
        st.session_state.selected ^= {feature["properties"]["code"]}


st_folium(base_map(),                                        # 1. identical every run
          feature_group_to_add=selection_layer(st.session_state.selected),   # 2. the part that changes
          key="map", on_change=on_map_click,                 # 3. state lands before drawing
          returned_objects=["last_active_drawing"])          # 4. panning returns nothing

A Streamlit rerun does not by itself reset the map. The browser test below shows it: with an unchanged map, zoom and pan survive any number of reruns. What resets the view is handing st_folium a different map β€” a style that depends on the selection, a drawn shape added as a layer, a new centre β€” because the component then rebuilds it from its location and zoom_start.

Four things a map app loses when the map it passes changes, and where to keep them. keep the map the same; move what changeswhat is lostwhere it belongsthe zoom and centrenowhere β€” keep the map identicala drawingsession_state, viafeature_group_to_adda selectionsession_state as codes, viaon_changenothing β€” the app re-runs on panrestrict returned_objectsA rerun with the same map keeps the view; a changed map starts again spatialworkflow.io
The fourth looks different and shares the cause.

Keep the map constant and add what changes

1. Understand why it resets

Streamlit re-executes the script on every interaction, and folium.Map(...) is constructed afresh each time. st_folium compares what it is given with what it drew last: if the map is the same, the component β€” and the reader's zoom and pan β€” is left alone; if anything in it differs, the map is rebuilt at its declared starting view.

So the view is lost exactly when an interaction changes the map. Measured in the browser test at the end of this page: zoom in, pan, move a slider that the map does not use β€” the view stays at zoom 9 over the pan. Click a district in an app whose style colours the selection β€” the map is back at zoom 6 and the starting centre.

2. Keep the map constant, and add what changes as a layer

def base_map():
    m = folium.Map(location=[54.0, -2.0], zoom_start=6)
    folium.GeoJson(base_geojson(), style_function=lambda _: {"fillColor": "#0ea5e9", "weight": 0.5},
                   tooltip=folium.GeoJsonTooltip(fields=["name"])).add_to(m)
    return m

Nothing in it depends on state. The selection, the drawn area and anything else that changes go into a folium.FeatureGroup passed as feature_group_to_add, which the component swaps into the existing map without rebuilding it.

If the app should move the view β€” zoom to a selection, say β€” pass center= and zoom= to st_folium. Those also update the existing map rather than replacing it.

pydeck needs less care. st.pydeck_chart kept the reader's zoom across reruns in the same test, even when a rerun recoloured the layer, as long as initial_view_state was unchanged. Store a view in session_state only when the app itself needs to set one.

3. Capture and re-add the drawing

state = st_folium(base_map(), feature_group_to_add=aoi_layer(st.session_state.get("aoi")),
                  key=f"draw-{st.session_state.map_version}",
                  returned_objects=["last_active_drawing"])

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

Both halves are required. Storing it keeps the filter; re-adding it as a layer lets the user see what they filtered by, without rebuilding the map. Clearing it needs a new key, because the component otherwise hands the old drawing back on the next run β€” adding draw tools to a map app tests each of those steps in a browser.

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

st_folium(m, key="map", returned_objects=["last_active_drawing"])

By default st_folium returns the centre, zoom, bounds, last click and drawings, and a changed return value re-runs the script. Measured below: with the default, the app ran twice on load, once more for a zoom and once more for a drag; restricted, neither ran it at all.

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())


def on_map_click():
    feature = (st.session_state.map or {}).get("last_active_drawing")
    if feature:
        st.session_state.selected ^= {str(feature["properties"]["code"])}

Read the feature from last_active_drawing. last_object_clicked looks like the right key and holds only {"lat": …, "lng": …}, so a handler that looks for properties in it never selects anything. 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 map being redrawn.

6. Give the user a way to reset

st.sidebar.button("Reset view", on_click=lambda: st.session_state.update(map_version=st.session_state.map_version + 1))
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. With a constant map the view belongs to the component, so resetting it means giving the component a new key, which remounts it at the starting view. on_click callbacks run before the rerun, which is why they can modify state that widgets depend on β€” assigning to a widget's key in the script body after the widget exists raises StreamlitWidgetAlreadyInstantiatedError.

Why a reset button must use an on_click callback. callbacks run before the rerunbutton clickedon_click firesstate is setbefore widgets existscript re-runswidgets read the newstatesetting it in thebody insteadraisesAssigning a widget’s session_state key after the widget is created is an error spatialworkflow.io
That is why every reset in a Streamlit app is a callback rather than an if-block.

A map that keeps its view, drawings and selection

The data used on this page

The UK's local authority districts (ONS, December 2024, generalised), saved with a code and name for each β€” the layer the apps below draw.

import os

import geopandas as gpd
import requests

ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
       "Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query")
if not os.path.exists("districts.gpkg"):
    reply = requests.get(ONS, timeout=120, params={
        "where": "1=1", "outFields": "LAD24CD,LAD24NM", "outSR": 4326, "f": "geojson"})
    reply.raise_for_status()
    (gpd.read_file(reply.text).rename(columns={"LAD24CD": "code", "LAD24NM": "name"})
        [["code", "name", "geometry"]].to_file("districts.gpkg"))
print(len(gpd.read_file("districts.gpkg")), "districts")
361 districts

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

%%writefile keeps_app.py
import folium
import geopandas as gpd
import streamlit as st
from folium.plugins import Draw
from shapely.geometry import shape
from streamlit_folium import st_folium

st.session_state.setdefault("selected", set())
st.session_state.setdefault("aoi", None)
st.session_state.setdefault("map_version", 0)


@st.cache_data
def load():
    return gpd.read_file("districts.gpkg").to_crs(4326)


@st.cache_data
def base_geojson():
    return load().assign(layer="base").__geo_interface__


def base_map():
    """Identical on every run, so the reader's view is never reset."""
    m = folium.Map(location=[54.0, -2.0], zoom_start=6, tiles="OpenStreetMap")
    folium.GeoJson(base_geojson(),
                   style_function=lambda _: {"fillColor": "#0ea5e9", "color": "white",
                                             "weight": 0.5, "fillOpacity": 0.4},
                   tooltip=folium.GeoJsonTooltip(fields=["name"])).add_to(m)
    Draw(export=False, draw_options={"polyline": False, "circle": False,
                                     "circlemarker": False, "marker": False}).add_to(m)
    return m


def changing_layers(selected, aoi):
    """Everything that depends on state, swapped in without rebuilding the map."""
    group = folium.FeatureGroup(name="state")
    chosen = load()[load()["code"].isin(selected)]
    if len(chosen):
        folium.GeoJson(chosen.assign(layer="selected"),
                       style_function=lambda _: {"fillColor": "#ef4444", "color": "white",
                                                 "weight": 1.5, "fillOpacity": 0.75}).add_to(group)
    if aoi is not None:
        folium.GeoJson(aoi.__geo_interface__,
                       style_function=lambda _: {"color": "#ef4444", "weight": 2,
                                                 "fillOpacity": 0.05}).add_to(group)
    return group


def on_map_change():
    feature = (st.session_state[f"map-{st.session_state.map_version}"] or {}).get("last_active_drawing")
    if not feature:
        return
    if "code" in (feature.get("properties") or {}):         # a click on a district
        st.session_state.selected ^= {feature["properties"]["code"]}
    else:                                                    # a shape the user drew
        st.session_state.aoi = shape(feature["geometry"])


def reset(*keys):
    def _reset():
        for key in keys:
            st.session_state[key] = {"selected": set(), "aoi": None}[key]
        st.session_state.map_version += 1                    # a new key: the view and any drawing go too
    return _reset


with st.sidebar:
    st.slider("threshold", 0, 10, 5, key="threshold")        # a widget the map does not use
    st.button("Reset view", on_click=reset())
    st.button("Clear drawing", on_click=reset("aoi"))
    st.button("Clear selection", on_click=reset("selected"))

st_folium(base_map(), key=f"map-{st.session_state.map_version}", height=560, width=None,
          feature_group_to_add=changing_layers(st.session_state.selected, st.session_state.aoi),
          returned_objects=["last_active_drawing"], on_change=on_map_change)
st.caption(f"{len(st.session_state.selected)} selected Β· "
           f"{'an area is drawn' if st.session_state.aoi is not None else 'no area drawn'}")

The map, its tooltip and its draw control never change. The selection and the drawn area live in session_state and reach the map through feature_group_to_add; the click and the drawing both arrive through on_change, before the script draws anything. The layer tags keep a click on a selected district distinguishable from a click on the same district unselected, which is what lets a second click deselect it. Every reset is a callback, and every reset that should also clear the view changes the key.

Example 2 β€” a state inspector while developing

%%writefile state_panel.py
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. The browser test below counts runs the same way, from outside the app.

Map zoom after each action in three versions of a Streamlit map app. zoom after each action (starts at 6)zoom + dragsliderclickrunsExample 1βœ“9βœ“9βœ“93style follows selectionβœ“9βœ“9βœ•6 β€” reset3+ default returnsβœ“9βœ“9βœ•6 β€” reset!7The rerun is harmless; changing the map is what resets it spatialworkflow.io
Measured below. The slider re-ran every app without moving any map.

Test it in a browser

Example 1 and two variants of it, each served with streamlit run and driven in headless Chromium: one whose district style depends on the selection (so the map changes when a district is clicked), and one that also leaves returned_objects at its default. After each action the test reads the map's zoom from Leaflet itself and counts script runs:

%%writefile counted.py
import os
with open("runs.log", "a") as log:
    log.write("run\n")
exec(open(os.environ["APP"]).read())
import re, socket, subprocess, sys, time, urllib.request
from playwright.sync_api import sync_playwright

app = open("keeps_app.py").read()
restyled = app.replace(
    'style_function=lambda _: {"fillColor": "#0ea5e9", "color": "white",',
    'style_function=lambda f: {"fillColor": "#ef4444" if f["properties"]["code"] in st.session_state.selected else "#0ea5e9", "color": "white",')
variants = {"keeps_app.py": app, "restyled_app.py": restyled,
            "default_return_app.py": restyled.replace('returned_objects=["last_active_drawing"], ', '')}
for name, source in variants.items():
    assert name == "keeps_app.py" or source != app
    open(name, "w").write(source)

VIEW = """() => { const m = Object.values(window).find(v => v && typeof v.getZoom === 'function' && v.getCenter);
                  return m ? `zoom ${m.getZoom()} at ${m.getCenter().lat.toFixed(2)}, ${m.getCenter().lng.toFixed(2)}` : 'β€”' }"""


def serve(app):
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        port = probe.getsockname()[1]
    server = subprocess.Popen([sys.executable, "-m", "streamlit", "run", "counted.py", "--server.headless", "true",
                               "--server.port", str(port), "--browser.gatherUsageStats", "false"],
                              env=dict(os.environ, APP=app), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    url = f"http://127.0.0.1:{port}"
    for _ in range(120):
        try:
            urllib.request.urlopen(url + "/_stcore/health", timeout=1)
            break
        except OSError:
            time.sleep(0.5)
    return server, url


def runs():
    return sum(1 for _ in open("runs.log")) if os.path.exists("runs.log") else 0


def view(page):
    for frame in page.frames:
        try:
            found = frame.evaluate(VIEW)
            if found != "β€”":
                return found
        except Exception:
            pass
    return "β€”"


for name in variants:
    if os.path.exists("runs.log"):
        os.remove("runs.log")
    server, url = serve(name)
    try:
        with sync_playwright() as playwright:
            browser = playwright.chromium.launch()
            page = browser.new_page(viewport={"width": 1300, "height": 900})
            page.goto(url)
            page.wait_for_selector("iframe", timeout=90_000)
            time.sleep(8)
            box = page.locator("iframe").first.bounding_box()
            x, y = box["x"] + 450, box["y"] + 300
            steps = [
                ("page loaded", lambda: None),
                ("zoom in three steps", lambda: page.mouse.move(x, y) or page.mouse.wheel(0, -300)),
                ("drag the map", lambda: (page.mouse.move(x, y), page.mouse.down(),
                                          page.mouse.move(x + 80, y + 50, steps=10), page.mouse.up())),
                ("move the slider", lambda: page.get_by_role("slider").first.press("ArrowRight")),
                ("click a district", lambda: page.mouse.click(x, y)),
                ("press Reset view", lambda: page.get_by_role("button", name="Reset view").click()),
            ]
            print(name)
            for label, act in steps:
                act()
                time.sleep(8)
                caption = page.locator('[data-testid="stCaptionContainer"]').all_inner_texts()
                print(f"  {label:20} {view(page):26} {runs()} runs  {caption[-1] if caption else ''}")
            browser.close()
    finally:
        server.terminate()
        server.wait(timeout=30)
keeps_app.py
  page loaded          zoom 6 at 53.99, -2.00     1 runs  0 selected Β· no area drawn
  zoom in three steps  zoom 9 at 53.77, -0.12     1 runs  0 selected Β· no area drawn
  drag the map         zoom 9 at 53.86, -0.36     1 runs  0 selected Β· no area drawn
  move the slider      zoom 9 at 53.86, -0.36     2 runs  0 selected Β· no area drawn
  click a district     zoom 9 at 53.86, -0.36     3 runs  1 selected Β· no area drawn
  press Reset view     zoom 6 at 53.99, -2.00     4 runs  1 selected Β· no area drawn
restyled_app.py
  page loaded          zoom 6 at 53.99, -2.00     1 runs  0 selected Β· no area drawn
  zoom in three steps  zoom 9 at 53.77, -0.12     1 runs  0 selected Β· no area drawn
  drag the map         zoom 9 at 53.86, -0.36     1 runs  0 selected Β· no area drawn
  move the slider      zoom 9 at 53.86, -0.36     2 runs  0 selected Β· no area drawn
  click a district     zoom 6 at 53.99, -2.00     3 runs  1 selected Β· no area drawn
  press Reset view     zoom 6 at 53.99, -2.00     4 runs  1 selected Β· no area drawn
default_return_app.py
  page loaded          zoom 6 at 53.99, -2.00     2 runs  0 selected Β· no area drawn
  zoom in three steps  zoom 9 at 53.77, -0.12     3 runs  0 selected Β· no area drawn
  drag the map         zoom 9 at 53.86, -0.36     4 runs  0 selected Β· no area drawn
  move the slider      zoom 9 at 53.86, -0.36     5 runs  0 selected Β· no area drawn
  click a district     zoom 6 at 53.99, -2.00     7 runs  1 selected Β· no area drawn
  press Reset view     zoom 6 at 53.99, -2.00     9 runs  1 selected Β· no area drawn

Read the zoom column. In Example 1 the view survives the slider and the click, and only Reset view β€” which changes the key on purpose β€” takes it back to the start. In the restyled variant the slider is harmless, because it does not change the map, but the click does: the selection changes the style, the component gets a different map, and the reader is back at zoom 6. With returned_objects left at its default, the same app also runs on load, on the zoom and on the drag, because every one of them changes a value the component returns.

pydeck, in the same harness. The deck's colour depends on the slider, so moving it changes the layer; the view is compared by screenshot β€” if the rerun had reset it, moving the slider back would reproduce the first picture, not the zoomed one:

%%writefile deck_app.py
import geopandas as gpd
import pydeck as pdk
import streamlit as st

st.sidebar.slider("threshold", 0, 10, 5, key="threshold")


@st.cache_data
def points():
    centres = gpd.read_file("districts.gpkg").to_crs(4326).representative_point()
    return [{"lon": p.x, "lat": p.y} for p in centres]


colour = [14, 165, 233] if st.session_state.threshold == 5 else [239, 68, 68]
layer = pdk.Layer("ScatterplotLayer", points(), get_position=["lon", "lat"], get_radius=8000,
                  get_fill_color=colour)
st.pydeck_chart(pdk.Deck(layers=[layer], map_style=None,
                         initial_view_state=pdk.ViewState(latitude=54.0, longitude=-2.0, zoom=5)), height=500)
import hashlib

server, url = serve("deck_app.py")
try:
    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(args=["--use-gl=swiftshader", "--enable-unsafe-swiftshader"])
        page = browser.new_page(viewport={"width": 1300, "height": 900})
        page.goto(url)
        chart = page.locator('[data-testid="stDeckGlJsonChart"]').first
        chart.wait_for(timeout=90_000)
        time.sleep(8)
        picture = lambda: hashlib.md5(chart.screenshot()).hexdigest()
        start = picture()
        box = chart.bounding_box()
        page.mouse.move(box["x"] + 400, box["y"] + 250)
        page.mouse.wheel(0, -400)
        time.sleep(4)
        zoomed = picture()
        slider = page.get_by_role("slider").first
        slider.press("ArrowRight")                         # rerun that recolours the layer
        time.sleep(6)
        slider.press("ArrowLeft")                          # and back to the original colour
        time.sleep(6)
        after = picture()
        print("after two reruns the deck shows:",
              "the zoomed view" if after == zoomed else "the starting view" if after == start else "something else")
        browser.close()
finally:
    server.terminate()
    server.wait(timeout=30)
after two reruns the deck shows: the zoomed view

Why the component forgets its view

Why the component keeps its view only while the map is the same

Streamlit's front end receives the component's arguments on every rerun. When they are the same as last time, it leaves the rendered map alone, and the reader's zoom and pan are part of that rendered map. When they differ β€” and a folium map whose style, layers or starting view changed is a different argument β€” the map is rendered again from what it was given, which is the starting view.

The consequence is a simple rule: keep the map you pass identical, route everything that changes through feature_group_to_add, center and zoom, and capture any state the user creates inside the component on its way out.

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, the centre and the zoom β€” which change with every pan and zoom.

Since a changed return value triggers a rerun, the app runs the whole script after each pan and each zoom β€” in the test, once per drag and once per zoom, on top of an extra run as the page loads. Restricting the returned set to what the app actually reads removes all of them.

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.

A two-line rerun counter that diagnoses the panning loop. pan the map and watch the numberunrestricted returned_objectsrerun #47…#48 …#49 …#50 while draggingclimbs while panningrestrictedrerun #6changes only on a completed actionstable while panningTwo lines in the sidebar, and the diagnosis takes a second spatialworkflow.io
If the number climbs, returned_objects is unrestricted.

Session state, returned objects and identifiers

  • st.session_state is the only user state that survives a rerun, besides widget values, caches, and whatever a component holds while its arguments stay the same.
  • 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 kept the reader's view across reruns in the test, even when a layer changed; it does not return the view, so the app sets one only when it must.
  • 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.