Skip to content

Conversation

sfc-gh-jrieke
Copy link
Collaborator

@sfc-gh-jrieke sfc-gh-jrieke commented Sep 29, 2025

Summary

Make it possible for st.tabs, st.expander, and st.popover content to run lazily (only when opened/active), instead of always executing on every rerun.

Problem statement

Currently, tabs, expanders and popovers always execute their content, even if not visible:

with st.expander("Show details"):
    slow_function()  # runs even if expander is closed

tab1, tab2 = st.tabs(["One", "Two"])
with tab2:
    slow_function()  # runs even if Tab 2 is not active

This ensures instant visibility when toggled, but it slows apps significantly when hidden content is expensive to compute.

Requests:

Related:

Proposed solution

API

Option 1: Add on_change="ignore"|"rerun"|callback

This is the same API as for selections on charts/dataframes/maps. It's in fact a similar situation: all of these elements are static and we want to allow making them interactive (i.e. trigger a rerun of the app). This API also allows adding a callback (that is getting executed before the rest of the app).

exp = st.expander("Show details", on_change="rerun")
tab1, tab2 = st.tabs(["A", "B"], on_change="rerun")

What makes this complex is the return value: while for chart/dataframe/map selections, we return a dictionary when on_select is set, for tabs/expander/popover we need to keep returning the delta generator, so you can add elements to the container. But we need a way to communicate the open/closed state as well. Some possible solutions:

Option 1a: Boolean evaluation of delta generator

exp = st.expander("Show details", on_change="rerun")
if exp:  # True if open
    with exp:
        st.write("Expander is open")
tab1, tab2 = st.tabs(["A", "B"], on_change="rerun")
if tab1:  # True if active
    with tab1:
        st.write("Tab A active")
elif tab2:
    with tab2:
        st.write("Tab B active")

Pros: Clean, readable, minimal changes.
Cons: Slight “magic” in truthiness of delta generator.

Option 1b: Attribute on delta generator

exp = st.expander("Expand me", on_change="rerun")
if exp.open:
    with exp:
        st.write("Content")

tab1, tab2 = st.tabs(["A", "B"], on_change="rerun")
if tab1.open:
    ...

Pros: Explicit, type-safe.
Cons: Slightly wordier than Option 1, new pattern.

Option 1c: Session state value

st.tabs(["A", "B"], key="tabs", on_change="rerun")
if st.session_state.tabs == "A":
    ...

Pros: Consistent with widgets.
Cons: Verbose, need to add key everytime, need to understand keys and session state.

Option 2: Function argument

def show_expander(exp):
    exp.write("Heavy content")

st.expander("Show details", func=show_expander)

st.tabs({"A": show_tab_a, "B": show_tab_b})
  • Aligns with lazy-loading patterns, e.g. deferred st.download_button.
  • We don't really use that pattern anywhere else though; kind of in between widgets/visualizations (which use on_change) and fragment/dialog (which use a decorator).
  • Could the function automatically be a fragment, similar to st.dialog?

Option 3: Function decorator

@st.expander("Show details")
def show_expander():
    st.write("Heavy content")

show_expander()
  • Consistent with @st.fragment and @st.dialog
  • But how would this work for st.tabs, where we have multiple functions?
  • Should expander/tabs/popover content also be a fragment then? Are there any downsides to that?
  • Two ways to use st.expander etc then – this might be tricky to explain.

Behavior

  • When made dynamic, opening the expander/popover or switching tabs will trigger a rerun.
  • Only the content of the visible container is executed; everything else is skipped.

Checklist

TBD

@sfc-gh-tteixeira
Copy link
Collaborator

Within Option1, it will always be possible to use the pattern in Option 1c, right? Even if we go with 1a or 1b?

Because if you pass a callback, you'll need to use the key="foo" argument together with session_state to get the current value. So st.session_state.foo will have to contain the currently-selected item, and there's nothing stopping the developer from using it outside of the callback.

@jrieke
Copy link

jrieke commented Oct 4, 2025

Within Option1, it will always be possible to use the pattern in Option 1c, right? Even if we go with 1a or 1b?

Not right away, because st.tabs, st.expander, and st.popover don't have the key parameter today. But if we add it (which we might want to do anyway to address streamlit/streamlit#8239 and streamlit/streamlit#12342), then yes.

EDIT: Even though we'd probably need to add the key parameter anyway to make the state accessible in callbacks, so yes, in that case doing 1a or 1b would automatically allow 1c.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants