Plotly charts require 2-3 clicks to fire a selection event

I am trying to create a dashboard that automatically refreshes each time the user clicks on a chart, by filtering all other charts. For that, I built a mixin as follows:

class ClickableChartMixin:
    def show(self) -> dict:
        return st.plotly_chart(self.fig, key=self.title, on_select="rerun", config={"displayModeBar": False})

It returns the plotly event, should I need it, but also sets the chart’s key in the session state, in order to retrieve the event data from it.

Two (sample for brevity) classes that implement it:

class BinnedRatingBarchart(ClickableChartMixin):
    def __init__(self, df: pd.DataFrame, title: str):
        rating_counts = (
            df['Valutazione_bin']
            .value_counts()
            .reindex(RATING_LABELS, fill_value=0)
        )
        fig = go.Figure(go.Bar(
            x=rating_counts.index.tolist(),
            y=rating_counts.values,
            text=rating_counts.values,
            textposition='outside',
            marker_color=RATING_COLORS,
            marker_line_color="#4F4F4F",
            marker_line_width=1,
            customdata=[["Valutazione_bin"]] * len(rating_counts),
            hovertemplate="<extra></extra>",
        ))
        fig.update_layout(
            hovermode=False, dragmode=False,
            margin=dict(l=0, r=0, t=10, b=0),
        )
        fig.update_xaxes(showgrid=False, fixedrange=True, tickangle=-90)
        fig.update_yaxes(showgrid=False, visible=False, fixedrange=True)

        self.fig = fig
        self.title = title


class BinnedPriceBarchart(ClickableChartMixin):
    def __init__(self, df: pd.DataFrame, title: str):
        price_counts = (
            df['Price_cat']
            .value_counts()
            .reindex(PRICE_LABELS, fill_value=0)
        )
        fig = go.Figure(go.Bar(
            x=price_counts.index.tolist(),
            y=price_counts.values,
            text=price_counts.values,
            textposition='outside',
            marker_color=px.colors.sample_colorscale("oryel", len(PRICE_LABELS)),
            marker_line_color="#4F4F4F",
            marker_line_width=1,
            customdata=[["Price_cat"]] * len(price_counts),
            hovertemplate="<extra></extra>",
        ))
        fig.update_layout(
            hovermode=False, dragmode=False,
            margin=dict(l=0, r=0, t=10, b=0),
        )
        fig.update_xaxes(showgrid=False, fixedrange=True, tickangle=-90)
        fig.update_yaxes(showgrid=False, visible=False, fixedrange=True)

        self.fig = fig
        self.title = title

Now, here’s the main function:

def dashboard(df: pd.DataFrame):
    df_filtered = df.copy()
    # Set the charts titles: these will also be used as element keys,
    # which can be used to retrieve the plotly events.
    binned_rating_title = "Binned Ratings"
    binned_price_title = "Binned Price"

    # To each chart, we map a dataframe column
    CHART_FILTER_MAP = [
        (binned_rating_title,       "Valutazione_bin"),
        (binned_price_title,        "Price_cat"),
    ]

    # Check which filters have been applied, i.e. which charts have been clicked
    filters = {}
    for chart_key, dimension in CHART_FILTER_MAP:
        points = (st.session_state
            .get(chart_key, {})     # retrieve a clicked chart's state
            .get("selection", {})   # then the selected dimension
            .get("points", [])      # then the selected points
        )
        if points:
            value = points[0].get("x", None)  # get the actual clicked value
            if value is not None:
                filters[dimension] = f"{dimension} == '{value}'"

    # Apply all the filters encountered before
    for query in filters.values():
        df_filtered = df_filtered.query(query)

    with st.container(horizontal=True):
        BinnedRatingBarchart(df_filtered, title=binned_rating_title).show()
        BinnedPriceBarchart(df_filtered, title=binned_price_title).show()

Functionally, it works. But I have to click on a chart at least twice before the others get filtered. Both for “setting” the filter and to “clear” it.

Environment: local Windows, streamlit 1.59 and python 3.12

Hey there, thanks for sharing your code and welcome to the Streamlit community! :balloon: Your approach is solid and your code is clear—nice job! The issue you’re seeing (needing to click twice to set or clear a filter) is a common pattern when using Streamlit’s chart selection events and session state.

It seems likely that this happens because Streamlit’s event data from st.plotly_chart(..., on_select="rerun") is only available after the rerun triggered by the selection. On the first click, the event is registered and the app reruns, but the session state isn’t updated until after the rerun, so your filter logic doesn’t see the new selection until the next run. This is expected behavior per the Streamlit chart selection API.

To work around this, you can directly use the return value of st.plotly_chart in your show() method, and pass it back to your dashboard logic, rather than relying solely on session state. This way, you always have the latest selection event in the current run. Here’s a quick example of how you might adjust your mixin and dashboard:

class ClickableChartMixin:
    def show(self) -> dict:
        return st.plotly_chart(self.fig, key=self.title, on_select="rerun", config={"displayModeBar": False})

def dashboard(df: pd.DataFrame):
    df_filtered = df.copy()
    binned_rating_title = "Binned Ratings"
    binned_price_title = "Binned Price"

    # Show charts and capture events
    with st.container(horizontal=True):
        rating_event = BinnedRatingBarchart(df_filtered, title=binned_rating_title).show()
        price_event = BinnedPriceBarchart(df_filtered, title=binned_price_title).show()

    # Use the latest event data directly
    filters = {}
    if rating_event and rating_event.get("selection", {}).get("points"):
        value = rating_event["selection"]["points"][0].get("x")
        if value is not None:
            filters["Valutazione_bin"] = f"Valutazione_bin == '{value}'"
    if price_event and price_event.get("selection", {}).get("points"):
        value = price_event["selection"]["points"][0].get("x")
        if value is not None:
            filters["Price_cat"] = f"Price_cat == '{value}'"

    # Apply filters
    for query in filters.values():
        df_filtered = df_filtered.query(query)

    # Optionally, re-render charts with filtered data
    # (You may need to adjust your logic to avoid infinite reruns)

This approach should make your dashboard respond to a single click, as the event data is immediately available after the rerun. If you want to keep using session state for persistence, you’ll need to accept the double-click pattern due to Streamlit’s rerun model.

Let us know if you have a minimum reproducible example or a repo to share—community members might have more tips! And if anyone else has solved this elegantly, please jump in! :grinning_face_with_smiling_eyes:

Sources:

Hi,

I understand the approach, but it won’t work in practice. You filter the dataframe at the end, once all charts have already been created. Then at each rerun, df_filtered = df.copy() copies the source dataframe, as if no filters were ever applied.

Use a callback to save the values from the chart in session state (SS), and set a pending rerun flag as well in SS. When dashboard() runs during the normal script rerun apply the filters only if the rerun pending flag is set. In this case build df_filtered, reset the rerun pending flag, and call st.rerun().

The callback does not pass any parameters, so I can’t set anything in the session state until the event is returned.

What’s in the SS object obtained using the key?