When using wrap_callback() to protect widget callbacks (e.g., on_click, on_change), errors caught in callbacks are rendered at the top of the page instead of near the widget that triggered them.
boundary = ErrorBoundary(
on_error=lambda exc: print(f"Error: {exc}"),
fallback=lambda _: st.error("An error occurred")
)
def trigger_error():
raise ValueError("Error!")
st.button("Click me", on_click=boundary.wrap_callback(trigger_error))Result: The error message appears at the top of the page, not below the button.
This is a Streamlit architectural limitation:
- Callbacks execute BEFORE script rerun: When you click a button with
on_click, Streamlit executes the callback first - Page structure doesn't exist yet: At callback execution time, the page layout (containers, widgets, etc.) hasn't been rendered yet
- Default rendering position: Therefore, any UI rendered in the callback (like
st.error) appears at the default location (top of page)
User clicks button
↓
1. Callback executes (page structure doesn't exist)
↓
2. Script reruns (page structure is created)
↓
3. Widgets are rendered
Instead of rendering UI in the callback, store error information in session_state and render it during the main script execution.
import streamlit as st
from st_error_boundary import ErrorBoundary
# Initialize session state
if "callback_error" not in st.session_state:
st.session_state.callback_error = None
def store_error(exc: Exception) -> None:
"""Store error in session_state instead of rendering."""
st.session_state.callback_error = str(exc)
def silent_fallback(_: Exception) -> None:
"""Don't render anything in callback - just store the error."""
pass
# Create boundary with deferred rendering
boundary = ErrorBoundary(on_error=store_error, fallback=silent_fallback)
def trigger_error():
raise ValueError("Error in callback!")
# Main app
st.title("My App")
st.button("Click me", on_click=boundary.wrap_callback(trigger_error))
# Render error AFTER the button (during script rerun)
if st.session_state.callback_error:
st.error(f"Error: {st.session_state.callback_error}")
if st.button("Clear Error"):
st.session_state.callback_error = None
st.rerun()- Callback execution:
store_errorsaves the exception tosession_state,silent_fallbackdoes nothing - Script rerun: The button is rendered first
- Error rendering: After the button, we check
session_state.callback_errorand render the error message
Result: The error message appears below the button, in the correct position.
See examples/demo.py for a complete working example comparing direct errors and callback errors.
- Full control over error message position
- Consistent with Streamlit's execution model
- Can customize error UI per widget/section
- This pattern requires manual error rendering code
- Adds boilerplate compared to simple
fallbackusage - Error state persists across reruns until explicitly cleared
Use deferred rendering when:
- Error position is important for UX
- You have multiple widgets and need errors to appear near each widget
- You want to customize error UI per section
Use standard wrap_callback() when:
- Error position doesn't matter
- You want simple, minimal code
- Errors are rare edge cases
If possible, restructure your code to avoid on_click/on_change callbacks:
# Instead of:
st.button("Click", on_click=handle_click)
# Use:
if st.button("Click"):
handle_click()This way, errors occur during main script execution and are naturally rendered in the correct position by the @boundary.decorate decorator.