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.
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.
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.
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.
Session state, returned objects and identifiers
st.session_stateis the only user state that survives a rerun, besides widget values, caches, and whatever a component holds while its arguments stay the same.returned_objectsonst_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_clickcallbacks 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.
Internal links
- Reruns and state explained: why your map app redraws everything β the model behind all of this
- How to build a Streamlit app with an interactive map β the app structure
- How to let users draw an area of interest in a map app β persisting a drawing
- How to link a map and a chart so one selection drives both β persisting a selection
- How to cache spatial data in a map app β what caching keeps, and what it does not
- Fixing a map app that takes ten seconds to load β when reruns are also slow
- How to add filters and widgets that drive a map β forms and fewer reruns
- How to test a map app without a browser β asserting state transitions
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.