Mini-tutorial: Initializing widget values and getting them to stick without double presses

Hi,

Have you ever found it frustrating to use widgets with initialized values, getting those values to survive through script reruns and noticed how sometimes you have to double click to get the value to stick? We all have!

This seems to be a common issue amongst new Streamlit users, so I wrote a mini-tutorial app to explain how widgets are used with initialized values and how to make them stick using session state and callbacks.

There are three ways: (1) the most basic where the initial value is not given but the widget is always reset, (2) where it’s initialized but there are issues getting the return value to stick, and finally (3) overcoming all issues with session state and callbacks. (I think the issue in the second case is a Streamlit bug :thinking: or at the very least is counter-intuitive).

You can find my mini-tutorial app code here.

HTH,
Arvindra

8 Likes

Hi @asehmi ,

Thanks for putting this tutorial together, super appreciated.

Did you, the Streamlit team or anyone finally figure out why the 2nd case is happening? It is still happening with Streamlit 1.17.0 :sweat_smile:

@marduk
Not that I know, sorry. Perhaps ask @blackary, who may provide a better explanation than I have given in the tutorial, as to why this case exists.

1 Like

I would expect a double click needed in case 2.

  1. Say the widget is in sync: it has it’s initial value and is outputting its initial value.
st.session_state.A2 = st.number_input(
    label="What is A2?",
    min_value=0, max_value=100,
    value=st.session_state.A2,
    key='num_A2'
)
  1. You click on the widget, so st.session_state.num_A2 is updated instantly and page reloads.
  2. After reloading, st.session_state.A2 is updated, as an output from the widget (namely after the widget is mounted).
  3. On the next click, st.session_state.num_A2 is again updated and the page reloads.
  4. Now although we have a value in st.session_state.num_A2 which should be correct, the widget sees a new initial value in st.session_state.A2 that it didn’t see before, hence Streamlit goes “New Widget!”
  5. Now since the real value we want is stored in a key associated to a widget that Streamlit thinks has been destroyed, what do we get? “Streamlit doesn’t connect back to widgets of the same key if it thinks it’s a different widget.”
    • This is an ongoing issue with multipage use cases. Slap in some key preservation at the top (see below), and presto! You’ve just convinced Streamlit that the value associated to the key does not need to be discarded in the cleanup process.
    • “Well that widget is gone. Let’s get rid of that key value. Oh look, a new widget. No, I don’t already have this key. Let’s make one.”
    • “What widget? This key isn’t associated to a widget. It was just manually written at the top of the page. Oh look! A new widget and I already have a key of the same name. I’ll connect that up right now.”

My so-called “key preservation” for the top of the script:

if 'num_A2' in st.session_state:
    st.session_state.num_A2 = st.session_state.num_A2
7 Likes

Nice trick @mathcatsand, but it sure is a mind bender, isn’t it!? :upside_down_face:

1 Like

I recommend people try Streamlit all the time but I have to say that session state is one of the most frustrating thing about using it. The Streamlit learning curve is fairly low until you get to any kind of state management. At a minimum I feel like the docs need a lot more examples.

That being said, for anyone searching for something like how do I persist text in st.text_input or ss.text_area or how do I persist input values across page navigation here is an example using #3 example of @asehmi tutorial.

app.py

import streamlit as st

if 'ss_text' not in st.session_state:
    st.session_state.ss_text = "ON RENDER"
		
def _set_ss_text():
	st.session_state.ss_text = st.session_state.key_ss_text
	
st.session_state.ss_text = st.text_input(
		label="Persistent text_input",
		value=st.session_state.ss_text,
		on_change=_set_ss_text,
		key='key_ss_text'
)
st.write(f"You set ss_text to: `{st.session_state.ss_text}`")

pages/other_page.py

import streamlit as st

if 'ss_text' not in st.session_state:
    st.session_state.ss_text = "ON RENDER"

st.title("Other Page 👋")

st.write(f"In the **app** page you set ss_text to: `{st.session_state.ss_text}`")

result
231229-935-chrome

Funny part is I don’t entirely understand why this works but after spending a few hours trying to get it to work I’m not even going to question it.

EDIT: Here is a slightly more compact version of the above app.py that uses a lambda function to handle the on_change:

import streamlit as st

# Initialize session state for 'ss_text' when the page first renders
if 'ss_text' not in st.session_state:
    st.session_state.ss_text = "ON RENDER"

# Use a lambda function to update 'ss_text' upon change
my_text = st.text_input(
    label="Persistent text_input",
    value=st.session_state.ss_text,
    on_change=lambda: setattr(st.session_state, 'ss_text', st.session_state.key_ss_text),
    key='key_ss_text'
)

st.write(f"You set ss_text to: `{my_text}`")

“I don’t entirely understand why this works … I’m not even going to question it”

The rules are simple: 1. Widget values are not in session state if they are not visible on the page; their values are actually deleted if they are not re-rendered in the top-down rerun. 2. The simplest way to persist a widget value that is about to go out of scope on a page rerun is to save its state before the upcoming rerun happens. The only way to intercept a rerun event is in a callback as callbacks are processed before the rerun. In the callback simply copy the widget value by its key into your own session state variable, which acts as a backing store for the widget value. 3. Session state is specific to a user session. I’m not sure if state is shared across multiple open browser tabs in the same session. (You should be able to check easily.)

This can lead to callback pollution, so I have a couple of utilities that abstracts the callback update method, similar to your use of a lambda. I also hate seeing st.session_state everywhere so assign that to a state variable at the top of my program. Finally, I put all state initialization and callbacks in one place (sometimes a separate file in a large app) and rely on the IDE shortcuts to navigate to them when needed.