Control state of `st.expander`

Summary

st.expander retains its state after interaction with another element.

Steps to reproduce

Code snippet:

import streamlit as st

button = st.button('Button')

with st.expander('expander', expanded=False):
    st.write('Hello!')

Launch and click expander, then click the button.

Expected behavior:

Expected the expander to unexpand after the button press, due to expanded=False.

Actual behavior:

The expander remains expanded (it does unexpand when the page is refreshed, though). I understand it often makes sense to have the expansion state persist automatically, but I can’t seem to find an easy way to control the state after the first initialization.

Debug info

  • Streamlit version: 1.16.0
  • Python version: 3.11.0
  • pyenv
  • OS version: MacOS 12.5.1
  • Browser version: Safari 16.0

You can achieve this behavior using st.session_state.

import streamlit as st

# initialise a boolean attr in session state

if "button" not in st.session_state:
    st.session_state.button = False

# write a function for toggle functionality
def toggle():
    if st.session_state.button:
        st.session_state.button = False
    else:
        st.session_state.button = True

# create the button
st.button("Button", on_click=toggle)

with st.expander('expander', expanded=st.session_state.button):
    st.write('Hello!')

Thanks! Unfortunately this doesn’t quite solve my problem… toggling the expanded state with the button does seem to work, but I want the button to be one-directional (click it, and it unexpands the expander if it is expanded, but not vice versa).

You can either modify the toggle function so it only sets st.session_state.button = True which would remove the reverse functionality, or you can disable or hide the button after you get your one and only click that you wish to track.

Thanks! @mathcatsand could you clarify how/where streamlit tracks the expander’s state? E.g. how does streamlit maintain the identity of an expander between reruns?

It seems to me that buttons can only toggle expanders if the expanded keyword in the expander changes.

I.e. if an expander starts open, then a button can close it.
But if an expander starts closed, then is opened by clicking on it, a button can no longer close it.

In the example code below, the button can only close the expander the first time. But if one manually opens the expander, the button no longer works:

import streamlit as st

if "expander_state" not in st.session_state:
    st.session_state["expander_state"] = True

def toggle_closed():
    st.session_state["expander_state"] = False

st.button("close expander",on_click=toggle_closed)

with st.expander("test expander",expanded = st.session_state["expander_state"]):
    st.write("expander is open")

It seems like streamlit must have some background process for persisting the state of the expander, that is only triggered if the definition of the expander changes. E.g. a st.expander("expander", expanded=False) does not mean to always keep the expander False. The problem is that if a default-closed expander is opened manually, there is no way to track that expander state and close it, since setting st.expander("expander", expanded=False) with a button is interpreted by streamlit as the same as the old definition, and hence persists the open state.

The challenge is that one can’t ensure that the expander is only opened by a button that toggles the expander definition (indeed, the intuitive thing for people is to open the expander directly instead of via some other toggling button, which is also a clunky UX experience).

Thanks!

The backend has no knowledge of whether the expander is open or closed, only what state it was in the last time it was initialized/created. The open or closed “state” of an expander is entirely on the frontend. The only way you can force the state of an expander is by changing the expanded keyword, but:

  1. You can only force it to be closed if it was last initialized as expanded (though the user may have already closed it).
  2. You can only force it to be expanded if it was last initialized as closed (though the user may have already expanded it).
  3. Say the expander was initialized closed and the user has expanded it. Say you want to force it closed. You have to first load the page with it set to expanded and then immediately reload the page with it set to closed to guarantee this. That will force it to be rebuilt (definitely expanded) then rebuilt again (definitely closed).
1 Like

Thanks! That helps to clarify things. I tried that approach, and it kind of works. Sometimes it works after one click, sometimes I need to click 5+ (and that’s not with spamming clicks) times for the expander to close. The inconsistency is a bit confusing.

import streamlit as st
import time

st.write("The button now only intermittently works. Sometimes after one click. Sometimes after multiple clicks.")
if "expander_state" not in st.session_state:
    st.session_state["expander_state"] = False

def toggle_closed():
    st.session_state["expander_state"] = True
    #open first, then force rerun and closing at the end of script

st.button("do misc. then close expander",on_click=toggle_closed)

with st.expander("test expander",expanded = st.session_state["expander_state"]):
    st.write("expander is open")

if st.session_state["expander_state"] == True:
    st.session_state["expander_state"] = False
    # time.sleep(0.05) <-- For some reason this fixes the problem!? 0.05 was as short as I could push it. When I went down to 0.01 sometimes the inconsistent button behavior would show up again.
    st.experimental_rerun()

Another question: how does streamlit know how to connect one instance of an expander (or any widget) to itself between runs? Is it just by the order in which expanders are defined? E.g. the second expander will always check against how the second expander was defined in a previous run. For a regular widget I assumed that it might be the session_state key, but expanders, as containers, don’t have keys. Thanks!

EDIT: I found that sleeping for a short bit before the experimental_rerun() seemed to fix things. However, I had to sleep for > 0.05s for the behavior to be consistent.

I do believe it effectively works out to position/order for the layout units.

For things with optional keys like input widgets, there is a definitive identifier based on that key or an implicit key generated by its creation parameters if no key is explicitly assigned. However, as you note, the expander has no key, so it’s something different.

As for reliability, note that the front end can be a tad bit slower than the backend. If you move through some rendering so fast that the front doesn’t finish before processing to the next one, it can kind of skip over it. Try adding time.sleep(.5) after rendering the page opposite of what you want and before reloading it again with the final desired state. If that works, try reducing the time. time.sleep(.1) or time.sleep(.2) is what I commonly use, but larger times are good for testing/certainty.

Edit: And I saw your edit right after I posted. Looks like you stumbled across the time trick yourself.

1 Like

Ah, thank you! That clarifies things a lot. Good to know that the “time trick” is the way to go!

On a related note, I noticed that some times forms close my expanders as desired, and sometimes they don’t (with the clear_on_submit = True and expanded=False) in both cases, which has confused me for a while. Could the inconsistency there also be tied to some kind of front-end/back-end disconnect?

I’m not sure exactly how you have your forms and expanders set up, so I’m not sure. Can you share a minimal, executable example of what you mean with the form?

Thanks! Here’s an example:

def pause():
    st.write("wait 1 s")
    import time
    time.sleep(1)
with st.form("test_form",clear_on_submit=True):
    with st.expander("test expander in form",expanded = False):
        st.write("form expander is open")
        st.text_area("enter some optional text","")
    st.form_submit_button("reset (this is a form submit button)", on_click = pause)

In this example, when I run it, the expander closes after the first submission, but not on subsequent resubmissions.

I haven’t been able to reproduce the case where the expander consistently closes after form submission (what I would like to reproduce), but that is in a larger code that is difficult to copy here. In that code I have 3 widgets in the expander, and the expander (usually) closes after every form submission.

Just stumbled upon this conversation. Got the same problem but for somehow static data. In case your use case fits this solution, here it is :

import streamlit as st

# init a session_state key for the expander to True (expanded)
if "expander_state" not in st.session_state:
    st.session_state["expander_state"] = True

# create an empty container to be first filled then emptied upon toggle button click
empty_container = st.empty()

# method to create an expander inside of the empty container
def get_expander(empty_component):
    with empty_component.expander("Expander Label", expanded=True):
        st.subheader("Expander Subheader")
        # st.write(st.session_state)

# method to clear the container
def clear_expander(empty_component):
    empty_component.empty()

# what will trigger one state or the other on re-run
if st.session_state["expander_state"]:
    get_expander(empty_container)
else:
    clear_expander(empty_container)

# toggle / save expander_state 
def toggle_expander_state():
    st.session_state["expander_state"] = not st.session_state["expander_state"] 

# actual button to trigger expander button state change
st.button("unexpand", on_click=toggle_expander_state)

This is my first working example, you may want to clean it