Dynamic Filters in Sidebar with Batch Application

Hi,

I’m attempting to create a filters pane within a sidebar in my Streamlit app. I have the following two filters:

  1. Product category.
  2. Product.

I desire two key features:

A. Dynamic Product Filter: I want to populate the product filter based on the user’s selection in the product category. For example, if a user selects “electronics,” then products should be limited to “computer,” “laptop,” “mobile phone.” If the user changes the product category to “clothing,” then the products should be limited to “shirts,” “pants,” “socks.”

B. Batch Application of Filters: Another key requirement is that the filters are applied in a batch. From what I have found, this can be achieved using st.form. I have wrapped them in an st.form and applied via an st.form_submit_button.

I believe the use of st.form is mandatory here as I need the visuals on my page to be updated only when the user is done selecting all the filters. However, the product filter needs to be dynamically calculated based on what the user has selected in the product category filter in real-time.

Things I’ve Tried:

  1. Wrapping only the product filter in st.form. This allows the filter to be recalculated using an on_change event for the product category filter but causes all my code to re-run instead of just recalculating my product dropdown.
  2. Using the on_change event while both filters are wrapped in st.form, but st.form prevents on_change for any other objects that are not st.form_submit_button resulting in an error: StreamlitInvalidFormCallbackError : Within a form, callbacks can only be defined on st.form_submit_button . Defining callbacks on other widgets inside a form is not allowed.

Sample Code:

import streamlit as st

# Sample data
product_data = {
    'Electronics': ['Laptop', 'Smartphone', 'Tablet'],
    'Clothing': ['Shirt', 'Pants', 'Jacket'],
    'Groceries': ['Fruits', 'Vegetables', 'Dairy']
}

def main():
    st.title("Product Filter App")

    with st.sidebar:
        with st.form(key='filter_form'):
            # Product Category Filter
            category = st.selectbox('Select Product Category', options=list(product_data.keys()))

            # Product Filter
            products = product_data.get(category, [])
            product = st.selectbox('Select Product', options=products)

            # Submit Button
            submit_button = st.form_submit_button(label='Apply Filters')

    if submit_button:
        st.write(f"Selected Category: {category}")
        st.write(f"Selected Product: {product}")

if __name__ == "__main__":
    main()

I’m running my app locally with Streamlit v1.40.2.

Any help or suggestions would be greatly appreciated!

I’ve solved the requirements:

  1. Dynamic filtering: Products filter updates based on the selected category
  2. Batch application: Changes only apply to the main display after clicking “Apply”

The Challenge

In my original post, I was struggling to combine these two features because:

  • Using st.form prevents dynamic updates between form elements
  • Without forms, any widget interaction triggers a full app rerun

Solution Overview

My solution uses session state to maintain two separate filter states:

  • Temporary filters: What the user is currently selecting (updates in real-time)
  • Applied filters: What’s actually affecting the main content (updates only on “Apply”)
  • st.fragment(): Isolates reruns to certain sections of code using a function decorator

This approach gives us the best of both worlds - immediate dropdown updates with deferred application to the main content.

Key Components of the Solution

1. Dual Session State

# Initialize session state
def initialize_filters():
    if 'applied_filters' not in st.session_state:
        st.session_state.applied_filters = {
            'category': list(product_data.keys())[0],
            'product': product_data[list(product_data.keys())[0]][0]
        }
    if 'temp_filters' not in st.session_state:
        st.session_state.temp_filters = {
            'category': list(product_data.keys())[0],
            'product': product_data[list(product_data.keys())[0]][0]
        }

2. Apply filters

def apply_filters():
    """Copy temp filters to applied filters"""
    st.session_state.applied_filters = st.session_state.temp_filters.copy()

3. st.fragment()

@st.fragment
def filters_fragment():
    """Fragment containing filter widgets"""
    # Product Category Filter
    category = st.selectbox(
        'Select Product Category', 
        options=list(product_data.keys()),
        key="category_filter",
        index=list(product_data.keys()).index(st.session_state.temp_filters['category']) 
            if st.session_state.temp_filters['category'] in product_data.keys() else 0
    )
    
    # Update temp filters with current category
    if "category_filter" in st.session_state:
        st.session_state.temp_filters['category'] = st.session_state.category_filter
        
    # Product Filter - options depend on selected category
    products = product_data.get(st.session_state.temp_filters['category'], [])
    product = st.selectbox(
        'Select Product', 
        options=products,
        key="product_filter",
        index=products.index(st.session_state.temp_filters['product']) 
            if st.session_state.temp_filters['product'] in products else 0
    )
    
    # Update temp filters with current product
    if "product_filter" in st.session_state:
        st.session_state.temp_filters['product'] = st.session_state.product_filter
    
    # Display filter status
    st.markdown("---")
    if st.session_state.temp_filters != st.session_state.applied_filters:
        st.info("You have unapplied filter changes")
    else:
        st.success("Filters are applied")

4. Main Content Using Applied Filters Only

def main():
    st.title("Product Filter App")


    # Initialize session state
    initialize_filters()

    
    # Sidebar with filters fragment
    with st.sidebar:
        st.header("Filters")
        # Call the fragment function inside the sidebar
        filters_fragment()

        # Apply filters button
        if st.button("Apply Filters", key="apply_filters"):
            apply_filters()

    # Main content area:
    st.header("Selected Products")
    
    # Display content based on applied filters (not temp filters)
    applied_category = st.session_state.applied_filters['category']
    applied_product = st.session_state.applied_filters['product']
    
    st.subheader("Applied Filters")
    st.write(f"Category: {applied_category}")
    st.write(f"Product: {applied_product}")
    
    # Display a sample visualization based on applied filters
    st.subheader("Product Details")
    st.info(f"Showing detailed information for {applied_product} in {applied_category} category")
    
    # Demo metrics
    metrics1, metrics2, metrics3 = st.columns(3)
    with metrics1:
        st.metric("Price", "$299" if applied_category == "Electronics" else "$49")
    with metrics2:
        st.metric("Rating", "4.8/5.0" if applied_product == "Smartphone" else "4.2/5.0")
    with metrics3:
        st.metric("Stock", "In Stock")
        
    # Show both temp and applied filters for demonstration
    with st.expander("Debug - Filter States"):
        st.write("Temporary Filters:", st.session_state.temp_filters)
        st.write("Applied Filters:", st.session_state.applied_filters)

if __name__ == "__main__":
    product_data = {
        'Electronics': ['Laptop', 'Smartphone', 'Tablet'],
        'Clothing': ['Shirt', 'Pants', 'Jacket'],
        'Groceries': ['Fruits', 'Vegetables', 'Dairy']
    }
    main()

Why This Approach Works

  1. Real-time dropdown updates: When the category changes, the product dropdown immediately updates because the fragment only reruns the filter widgets, not the whole app.
  2. Deferred application: Main content only updates when the “Apply” button is clicked, copying from temp_filters to applied_filters.

Why Fragments Matter Here

Fragments are the perfect solution for this use case because:

  1. They allow isolated reruns of just the filtering section when category selection changes, avoiding full app reruns.
  2. They maintain state continuity - the fragment keeps track of its internal state using st.session_state between reruns.
  3. They provide clear separation of concerns - filtering logic stays contained in one function.
  4. They offer flexible placement - the fragment can be used in the sidebar or anywhere else in the app without changing its internal logic. Just be aware you should not use the st.sidebar.input_widget() syntax when wrapping the function the the decorator or you’ll get an error: Fragments cannot write widgets to outside containers.

Final Thoughts

This pattern can be extended to handle more complex filtering scenarios, such as:

  • Multiple interdependent filter groups
  • Filters with different types (dropdowns, sliders, checkboxes)
  • Filter presets or saved configurations

Hope this is useful for anyone struggling with this problem too. :grin:

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.