How to Sync Deck.gl Layers with Streamlit Session State Variables
Treat st.session_state as the single source of truth: persist each interaction (click, hover, slider) into a state key, then rebuild your pydeck.Layer definitions from that state on every rerun so the WebGL render is always a deterministic function of state.
Jump to heading Why This Matters
Streamlit re-executes your entire script top-to-bottom on every interaction, while Deck.gl renders on the GPU in the browser from a JSON payload. Those two models only stay in sync if you stop storing visual state inside the layer and start storing it in session state. This is the core of building Deck.gl Advanced Layers that support real-time highlighting, filtering, and dynamic styling without a full page reload — the same reactive-data-pipeline discipline that underpins the wider Spatial Component Integration & Interactive Maps architecture. Get the loop right once and ScatterplotLayer, ArcLayer, HexagonLayer, and PointCloudLayer all behave identically: read state, derive styling, reconstruct, render.
Jump to heading Prerequisites
- Python 3.9+ with
streamlit>=1.28(the version that shipsst.pydeck_chart(..., on_select=...)) pydeck>=0.8,pandas>=2.0, andnumpy>=1.24;geopandas>=0.14if your source is a GeoDataFrame- A working understanding of session state patterns — specifically that state must be initialized before it is read and survives across reruns
Jump to heading How the State Synchronization Loop Works
When a user clicks or hovers a feature, the pydeck chart serializes the picked object and returns it to the Streamlit server, which triggers a rerun. On that rerun your code reads the updated st.session_state, recomputes the styled DataFrame, and emits a fresh JSON payload for the WebGL renderer. Because the layer is rebuilt from state rather than mutated in place, there is no hidden client-side state to drift out of sync — the render is a pure function of st.session_state.
Jump to heading Step-by-Step Solution
Jump to heading Step 1 — Initialize every state key before reading it
Reading a key that was never set raises KeyError, and re-assigning a default on each run silently wipes the user’s selection. Guard every variable once at the top of the script.
import streamlit as st
# Persisted selection + style controls — set once, survive every rerun
if "highlighted_id" not in st.session_state:
st.session_state.highlighted_id = None
if "opacity" not in st.session_state:
st.session_state.opacity = 0.85
if "radius_scale" not in st.session_state:
st.session_state.radius_scale = 1.0
Jump to heading Step 2 — Cache the spatial dataset so reruns stay cheap
Re-reading or re-projecting your source on every interaction is the most common cause of lag. Decorate the loader with @st.cache_data so it runs once and returns the same frame on subsequent reruns. The example uses a plausible Los Angeles bounding box in WGS84 (EPSG:4326); swap in gpd.read_file(...).to_crs("EPSG:4326") for a real GeoDataFrame.
import pandas as pd
import numpy as np
@st.cache_data
def load_sensor_points() -> pd.DataFrame:
"""Load and normalize spatial points once; cached across reruns."""
rng = np.random.default_rng(42)
n = 800
return pd.DataFrame({
"id": range(n),
# WGS84 lon/lat inside an LA-area bounding box
"lon": rng.uniform(-118.50, -118.15, n),
"lat": rng.uniform(34.00, 34.25, n),
"metric": rng.integers(5, 95, n),
})
df = load_sensor_points()
Jump to heading Step 3 — Derive layer styling from state with vectorized NumPy
Compute the color and radius columns from st.session_state in one vectorized pass. Avoid df.apply() row loops — they dominate render time on large frames.
def apply_state_styles(data: pd.DataFrame) -> pd.DataFrame:
styled = data.copy()
highlight_id = st.session_state.highlighted_id
# Vectorized RGBA: highlighted point -> orange, others -> blue
is_hot = (styled["id"] == highlight_id).to_numpy()
hot = np.array([255, 100, 0, 255], dtype=np.uint8)
cold = np.array([0, 150, 255, 200], dtype=np.uint8)
rgba = np.where(is_hot[:, None], hot, cold)
styled["color"] = rgba.tolist()
# State-driven radius in metres
styled["radius"] = (styled["metric"] / 100.0) * 1000 * st.session_state.radius_scale
return styled
styled_df = apply_state_styles(df)
Jump to heading Step 4 — Reconstruct the pydeck Deck from state
Every styling input the layer needs now comes from state: the per-row color/radius columns and the scalar opacity. Strip to the columns Deck.gl actually consumes to keep the serialized payload small.
import pydeck as pdk
layer = pdk.Layer(
"ScatterplotLayer",
styled_df[["id", "lon", "lat", "metric", "color", "radius"]],
get_position=["lon", "lat"],
get_fill_color="color",
get_radius="radius",
pickable=True, # required for click/hover selection
opacity=st.session_state.opacity, # scalar pulled from state
)
view_state = pdk.ViewState(latitude=34.12, longitude=-118.32, zoom=10, pitch=0)
deck = pdk.Deck(
layers=[layer],
initial_view_state=view_state,
tooltip={"text": "ID: {id}\nMetric: {metric}"},
)
Jump to heading Step 5 — Render, expose controls, and feed selections back into state
The sliders write directly into the same state keys read in Step 3, and on_select="rerun" makes the chart return the picked object so you can write its id back to state. Use the key= argument on each widget — see widget lifecycle management for why stable keys prevent state collisions across reruns.
st.title("Deck.gl ↔ Streamlit State Sync")
col1, col2 = st.columns(2)
with col1:
st.session_state.opacity = st.slider(
"Opacity", 0.1, 1.0, st.session_state.opacity, key="opacity_slider")
with col2:
st.session_state.radius_scale = st.slider(
"Radius scale", 0.5, 3.0, st.session_state.radius_scale, key="radius_slider")
selection = st.pydeck_chart(deck, on_select="rerun", selection_mode="single-object")
# Toggle the highlight from the returned selection payload
objects = (selection or {}).get("selection", {}).get("objects", {})
picked = objects.get("ScatterplotLayer", [])
if picked:
clicked_id = picked[0].get("id")
st.session_state.highlighted_id = (
None if st.session_state.highlighted_id == clicked_id else clicked_id
)
if st.session_state.highlighted_id is not None:
st.sidebar.metric("Selected ID", st.session_state.highlighted_id)
Jump to heading Verification
Confirm the loop is deterministic: the styled frame must depend only on state, so setting a highlight id and re-deriving styles should always produce exactly one orange feature.
# Drive state directly and assert the derived styling is a pure function of it
st.session_state.highlighted_id = 17
st.session_state.radius_scale = 2.0
styled = apply_state_styles(df)
orange = [c for c in styled["color"] if c == [255, 100, 0, 255]]
assert len(orange) == 1, "Exactly one feature should be highlighted"
assert styled.loc[styled["id"] == 17, "color"].iloc[0] == [255, 100, 0, 255]
print("State sync verified: styling is a deterministic function of session state.")
# Expected: State sync verified: styling is a deterministic function of session state.
In the running app, click a point and watch it flip to orange while the sidebar metric updates the selected ID; click it again and it returns to blue — both transitions complete in a single rerun.
Jump to heading Edge Cases and Gotchas
- Selection index vs. original index — if you filter the DataFrame before passing it to
pydeck, the picked object’s position no longer matches the original frame. Match on the stableidcolumn (as above) rather than positional index, or call.reset_index(drop=True)before rendering. - Tab isolation —
st.session_stateis per-tab, so a highlight made in one tab will not appear in another. To share selections across tabs, persisthighlighted_idin a process-level or external store; see managing Streamlit session state across multiple user tabs. - CRS mismatch — Deck.gl expects
[longitude, latitude]in WGS84 (EPSG:4326). If your source is in a projected CRS such asEPSG:3857, call.to_crs("EPSG:4326")before extractinglon/lat, or every point will land in the ocean off West Africa.
Jump to heading Performance Notes
Keep reruns under a second by minimizing what crosses the wire to the browser on each interaction:
- Cache heavy work —
@st.cache_datafor static frames,@st.cache_resourcefor connections and models. Never recompute spatial joins or aggregations inside the render loop; offload them to Caching Strategies & Async Performance Tuning when they are expensive. - Vectorize styling — use
numpy.whereon pre-allocated RGBA arrays instead ofdf.apply()for colour and radius logic. - Trim the payload — Deck.gl serializes the whole frame to JSON, so pass only the columns the layer reads (
df[["id", "lon", "lat", "metric", "color", "radius"]]). - Use
updateTriggersfor selective re-render — in advanced configurations,updateTriggerslets Deck.gl recompute only the accessor that changed (e.g.get_fill_color) instead of rebuilding the entire layer.
Jump to heading Common Pitfalls and Fixes
| Symptom | Root cause | Resolution |
|---|---|---|
| Clicks don’t update the highlight | on_select not set or pickable=False | Pass on_select="rerun" to st.pydeck_chart; set pickable=True on the layer |
| State resets on every rerun | Missing if "key" not in st.session_state guard | Always initialize state before reading it (Step 1) |
| Map flickers or lags | Full DataFrame re-serialized each run | Cache data, vectorize styling, drop unused columns |
| Selection index out of bounds | Filtered frame vs. original index | Match on the id column or .reset_index(drop=True) before rendering |
Jump to heading FAQ
Why do my Deck.gl clicks not update the highlighted feature?
Two settings must both be present: the layer needs pickable=True, and st.pydeck_chart must be called with on_select="rerun". Without on_select the chart never returns a selection payload, so the rerun that would write the picked id into st.session_state never fires and the highlight column is recomputed from a stale (or None) selection.
Why does my map flicker or lag on every interaction?
The whole DataFrame is being re-serialized into a new WebGL payload on each rerun. Cache the dataset with @st.cache_data, compute the color and radius columns with vectorized NumPy instead of df.apply(), and drop unused columns before passing the frame to pdk.Layer so the JSON sent to the browser stays small.
Does this state-sync pattern work across multiple browser tabs?
No. st.session_state is scoped per tab, so a selection made in one tab does not appear in another. To share map selections across tabs, persist the highlighted id in a process-level variable or an external key-value store keyed by user, then read it on each rerun — see the dedicated guide on managing session state across tabs.
Back to Deck.gl Advanced Layers
Related
- Deck.gl Advanced Layers — parent guide on GPU-accelerated layer types and WebGL optimization
- Tooltip & Click Event Handling — capturing precise map interactions across Streamlit and Panel
- Dynamic Spatial Filtering — driving spatial queries from map and widget state
- Session State Patterns — the state model this synchronization loop depends on