Syncing Deck.gl Layers with Streamlit State Variables
Syncing Deck.gl layers with Streamlit state variables requires mapping pydeck layer properties to st.session_state keys and reconstructing the layer definitions on every script execution. Because Streamlit operates on a stateless, top-to-bottom execution model, you must capture user interactions (clicks, hovers, or UI controls), persist them in session state, and pass those values directly into pydeck.Layer constructors. The synchronization loop follows three predictable steps: initialize state, bind callbacks to state mutations, and render the Deck object with state-driven parameters.
How the State Synchronization Loop Works
Streamlit’s reactive architecture means the entire script re-runs whenever an interaction occurs. When a user clicks or hovers over a map feature, pydeck serializes the event payload and sends it to the Streamlit server. Your callback parses this payload, updates st.session_state, and triggers a re-run. During the next execution, your layer definitions read the updated state and generate new JSON payloads for the WebGL renderer. This pattern is foundational for building Spatial Component Integration & Interactive Maps that require real-time filtering, highlighting, or dynamic styling without full page reloads.
The Streamlit session state documentation outlines how state persists across reruns, which directly applies to maintaining map selections. For complex visualizations using Deck.gl Advanced Layers, such as ArcLayer, HexagonLayer, or PointCloudLayer, the same state-sync principle applies: replace hardcoded properties with st.session_state references and cache data transformations to avoid redundant computation.
Complete Implementation
The following snippet demonstrates a production-ready pattern. It initializes state, caches spatial data, processes click events, and dynamically updates layer styling without blocking re-renders.
import streamlit as st
import pydeck as pdk
import pandas as pd
import numpy as np
# 1. Initialize session state variables
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
# 2. Cache spatial dataset to prevent redundant I/O on reruns
@st.cache_data
def load_data():
np.random.seed(42)
n = 800
return pd.DataFrame({
"id": range(n),
"lat": np.random.uniform(34.0, 34.25, n),
"lon": np.random.uniform(-118.5, -118.15, n),
"metric": np.random.randint(5, 95, n)
})
df = load_data()
# 3. Apply state-driven transformations
def apply_state_styles(data: pd.DataFrame) -> pd.DataFrame:
df_styled = data.copy()
highlight_id = st.session_state.highlighted_id
# Vectorized color assignment: highlighted point gets orange, others blue
colors = np.where(
df_styled["id"] == highlight_id,
[[255, 100, 0, 255]],
[[0, 150, 255, 200]]
)
# np.where with object arrays requires explicit construction
df_styled["color"] = [
[255, 100, 0, 255] if row_id == highlight_id else [0, 150, 255, 200]
for row_id in df_styled["id"]
]
# Dynamic radius scaling
df_styled["radius"] = (df_styled["metric"] / 100.0) * 1000 * st.session_state.radius_scale
return df_styled
styled_df = apply_state_styles(df)
# 4. Construct pydeck layer & deck
layer = pdk.Layer(
"ScatterplotLayer",
styled_df,
get_position=["lon", "lat"],
get_fill_color="color",
get_radius="radius",
pickable=True,
opacity=st.session_state.opacity,
)
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}"},
)
# 5. Render with click selection
st.title("Deck.gl State Sync Demo")
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")
# st.pydeck_chart returns selection data when on_select is configured
selection = st.pydeck_chart(deck, on_select="rerun", selection_mode="single-object")
# 6. Handle click events from returned selection
if selection and selection.get("selection") and selection["selection"].get("objects"):
objects = selection["selection"]["objects"].get("ScatterplotLayer", [])
if objects:
clicked_id = objects[0].get("id")
# Toggle selection
if st.session_state.highlighted_id == clicked_id:
st.session_state.highlighted_id = None
else:
st.session_state.highlighted_id = clicked_id
if st.session_state.highlighted_id is not None:
st.sidebar.metric("Selected ID", st.session_state.highlighted_id)
Performance Optimization & Best Practices
Syncing Deck.gl layers with Streamlit state variables can introduce latency if data transformations scale poorly. Follow these patterns to maintain sub-second re-renders:
- Cache Heavy Computations: Use
@st.cache_datafor static datasets and@st.cache_resourcefor heavy model outputs. Never recompute spatial joins or aggregations on every rerun. - Vectorize State Updates: Replace
df.apply()with list comprehensions orpandas.Series.map()for color/radius logic. For very large DataFrames, usenumpy.wherewith pre-allocated arrays. - Limit Payload Size: Deck.gl expects lightweight JSON. Strip unused columns before passing DataFrames to
pydeck.Layer. Usedf[["id", "lat", "lon", "metric"]]to reduce serialization overhead. - Debounce UI Controls: Sliders trigger immediate reruns. The
key=parameter ensures Streamlit correctly identifies each widget across reruns, preventing state collisions.
The official pydeck documentation details layer-specific props and WebGL optimization flags. Leveraging updateTriggers in advanced configurations can force selective re-rendering instead of full layer reconstruction.
Common Pitfalls & Fixes
| Symptom | Root Cause | Resolution |
|---|---|---|
| Clicks don’t update highlight | on_select not set or pickable=False | Pass on_select="rerun" to st.pydeck_chart; ensure pickable=True on the layer |
| State resets on rerun | Missing if "key" not in st.session_state guard | Always initialize state before reading it |
| Map flickers or lags | Full DataFrame re-serialization | Cache data, vectorize styling, and drop unused columns |
| Selection index out of bounds | Filtered DataFrame vs. original index | Reset the DataFrame index with .reset_index(drop=True) before passing to pydeck |
By treating st.session_state as the single source of truth and reconstructing layers deterministically, you eliminate race conditions and ensure consistent WebGL rendering across all interactions.