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 ships st.pydeck_chart(..., on_select=...))
  • pydeck>=0.8, pandas>=2.0, and numpy>=1.24; geopandas>=0.14 if 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.

Deck.gl ↔ Streamlit session-state synchronization loopA click, hover, or slider interaction in the browser sends a selection payload to the Streamlit server, which triggers a full script rerun. The rerun mutates st.session_state, derives a styled DataFrame from that state with vectorized NumPy, then rebuilds the pydeck Layer and Deck. The Deck is serialized to a fresh WebGL JSON payload and rendered back in the browser, closing the loop so the render is a pure function of session state.Browser · WebGL (GPU)Streamlit server · script rerun1 · User interactionclick · hover · slider on the Deck.gl chartselection payload2 · Script rerun fireson_select="rerun" · top-to-bottom replay3 · st.session_statehighlighted_id · opacity · radius_scale4 · Derive styled framevectorized NumPy → color · radius colsrebuild pdk.Layer + pdk.Deck5 · New WebGL payloadJSON re-serialized · GPU re-renderrender reflectscurrent 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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
# 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 stable id column (as above) rather than positional index, or call .reset_index(drop=True) before rendering.
  • Tab isolationst.session_state is per-tab, so a highlight made in one tab will not appear in another. To share selections across tabs, persist highlighted_id in a process-level or external store; see managing Streamlit session state across multiple user tabs.
  • CRS mismatchDeck.gl expects [longitude, latitude] in WGS84 (EPSG:4326). If your source is in a projected CRS such as EPSG:3857, call .to_crs("EPSG:4326") before extracting lon/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_data for static frames, @st.cache_resource for 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.where on pre-allocated RGBA arrays instead of df.apply() for colour and radius logic.
  • Trim the payloadDeck.gl serializes the whole frame to JSON, so pass only the columns the layer reads (df[["id", "lon", "lat", "metric", "color", "radius"]]).
  • Use updateTriggers for selective re-render — in advanced configurations, updateTriggers lets 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

SymptomRoot causeResolution
Clicks don’t update the highlighton_select not set or pickable=FalsePass on_select="rerun" to st.pydeck_chart; set pickable=True on the layer
State resets on every rerunMissing if "key" not in st.session_state guardAlways initialize state before reading it (Step 1)
Map flickers or lagsFull DataFrame re-serialized each runCache data, vectorize styling, drop unused columns
Selection index out of boundsFiltered frame vs. original indexMatch 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