On this page
- The UI Problem That Doesn’t Need a Frontend Engineer
- What You’ll Need
- How Streamlit Actually Executes Your Code
- Configuration and Environment Variables
- Session State: Getting It Right
- Form Inputs With State Binding and Conditional Locking
- File Upload Processing Without Duplicate Work
- Auto-Refresh via Polling
- API Integration
- Caching, Security, and a Few Pitfalls
The UI Problem That Doesn’t Need a Frontend Engineer
You’ve built a solid Flask backend. The signal processing algorithms work. But now you need a user interface, and the options aren’t great. A full React app is hundreds of lines of boilerplate before you write a single business-logic line. Tkinter works locally but can’t be shared over a network. A traditional web frontend requires HTML, CSS, JavaScript, a bundler, and deployment infrastructure.
Streamlit is a different bet: build interactive web applications in pure Python. The patient_streamlit.py file for the CPAP monitor is 646 lines of Python that give you form inputs with validation, file upload with real-time processing, auto-refreshing data displays, interactive Matplotlib charts, multi-tab navigation, and session state management — all deployable with streamlit run.
This is the right tool for internal tools, data-heavy dashboards, and rapid prototypes. It’s not the right tool for public-facing apps with complex interactions or mobile requirements.
What You’ll Need
Knowledge Base:
- Python basics (functions, dictionaries, loops)
- Flask/REST API concepts (GET/POST requests)
- Basic understanding of state management
Environment:
pip install streamlit requests matplotlib pandas
streamlit --version # Should be 1.20+
How Streamlit Actually Executes Your Code
The most important thing to understand about Streamlit: your script runs top-to-bottom on every user interaction. Every button click, every input change — the entire Python script re-executes. Streamlit optimizes this by tracking widget state and only re-rendering changed components, but the mental model is a full re-run.
This is why session state exists. Without it, every re-run would reset all your variables to their initial values.
Configuration and Environment Variables
Every Streamlit app needs st.set_page_config() as its first Streamlit command:
import streamlit as st
import requests
import os
from cpap_measurements import analysis_driver
SERVER = os.environ.get("API_URL", "http://127.0.0.1:5000")
st.set_page_config(
page_title="Sleep Lab Patient Monitoring System",
layout="wide",
initial_sidebar_state="expanded"
)
I use os.environ.get("API_URL", "http://127.0.0.1:5000") instead of hardcoding the Flask server URL. The default works locally; set API_URL=http://api:5000 in Docker, or API_URL=https://myapi.com in production. The Streamlit app doesn’t need to change between environments.
Session State: Getting It Right
The initialization pattern matters. This is wrong:
# WRONG — resets on every re-run
st.session_state.counter = 0
if st.button("Increment"):
st.session_state.counter += 1
Every re-run hits line 1 first, resetting the counter before the button click is processed. The counter always shows 0.
The correct pattern:
# CORRECT — only initializes if the key doesn't exist yet
if 'counter' not in st.session_state:
st.session_state.counter = 0
if st.button("Increment"):
st.session_state.counter += 1
On the first run, 'counter' not in st.session_state is True, so it initializes to 0. On every subsequent run, the check is False — the existing value is preserved. The button click happens after the initialization check, so it increments the value that already exists.
For the CPAP monitor, the state I track:
if 'patient_locked' not in st.session_state:
st.session_state.patient_locked = False
if 'cpap_data' not in st.session_state:
st.session_state.cpap_data = None
if 'last_poll_time' not in st.session_state:
st.session_state.last_poll_time = 0
if 'mrn' not in st.session_state:
st.session_state.mrn = ""
Form Inputs With State Binding and Conditional Locking
def main():
col1, col2 = st.columns(2)
with col1:
patient_name = st.text_input(
"Patient Name",
value=st.session_state.patient_name,
key="name_input"
)
st.session_state.patient_name = patient_name
mrn = st.text_input(
"Medical Record Number (MRN)*",
value=st.session_state.mrn,
disabled=st.session_state.patient_locked,
key="mrn_input",
help="Required field"
)
if not st.session_state.patient_locked:
st.session_state.mrn = mrn
The disabled=st.session_state.patient_locked flag prevents changes after the first upload. I only update the session state value when the field isn’t locked — otherwise the previous value would persist correctly but the UI would show the locked value, which is fine.
There are two ways to bind widgets to session state. The manual approach (shown above) gives you conditional update control. The automatic approach with key= handles everything but doesn’t let you conditionally update. Use whichever fits the situation.
File Upload Processing Without Duplicate Work
File uploads trigger re-runs. Without protection, an expensive processing step runs on every re-run after a file is uploaded:
# BAD — processes the file on every re-run
uploaded_file = st.file_uploader("Upload file")
if uploaded_file:
data = expensive_processing(uploaded_file) # Runs repeatedly
Fix this by tracking which file was last processed:
if 'last_processed_file' not in st.session_state:
st.session_state.last_processed_file = None
uploaded_file = st.file_uploader("Upload CPAP Data File", type=['csv', 'txt'])
if uploaded_file is not None:
file_id = uploaded_file.name + str(uploaded_file.size)
if st.session_state.last_processed_file != file_id:
with st.spinner("Processing CPAP data..."):
cpap_data = process_cpap_file(uploaded_file)
if cpap_data:
st.session_state.cpap_data = cpap_data
st.session_state.last_processed_file = file_id
st.rerun()
else:
st.info(f"Already loaded: {uploaded_file.name}")
The file processing function saves to a temp file, calls the signal processing library, creates a Matplotlib figure, converts to base64 for API transport, and cleans up the temp file — all inside a try/except that handles errors gracefully.
Auto-Refresh via Polling
Streamlit doesn’t have native WebSocket support or a built-in polling mechanism. The approach is manual: check if enough time has passed, fetch new data if so, and trigger a re-run.
with st.sidebar:
auto_refresh = st.checkbox("Auto-refresh CPAP Pressure", value=True)
if auto_refresh:
refresh_interval = st.slider("Refresh interval (seconds)", 10, 60, 30)
import time
current_time = time.time()
if current_time - st.session_state.last_poll_time > refresh_interval:
if st.session_state.room_number:
new_pressure = query_server_for_pressure(st.session_state.room_number)
if new_pressure and new_pressure != st.session_state.cpap_pressure:
st.session_state.cpap_pressure = new_pressure
st.sidebar.success(f"Updated pressure: {new_pressure} cmH2O")
st.session_state.last_poll_time = current_time
st.rerun()
Don’t use a while True loop with sleep inside Streamlit. That would busy-loop the Python process at 100% CPU for the entire session. Check once per script run: if time hasn’t elapsed, the script finishes and Streamlit waits for user interaction. If time has elapsed, poll, update state, and call st.rerun() to immediately run the script again.
API Integration
def upload_to_server(mrn, room_number, patient_name, cpap_pressure, cpap_data):
if not mrn:
return False, "Missing Patient Medical Record Number"
if not room_number:
return False, "Missing Room Number"
patient = {
"patient_name": patient_name,
"patient_mrn": int(mrn),
"room_number": int(room_number),
"CPAP_pressure": cpap_pressure,
"breath_rate": cpap_data['breath_rate'] if cpap_data else "Not measured",
"apnea_count": cpap_data['apnea_count'] if cpap_data else "Not measured",
"flow_image": cpap_data['image_b64'] if cpap_data else ""
}
try:
r = requests.post(f"{SERVER}/add_patient", json=patient, timeout=10)
if r.status_code == 200:
return True, r.text
else:
return False, f"Server error: {r.text}"
except Exception as e:
return False, f"Connection error: {str(e)}"
if st.button("Upload to Server", type="primary"):
with st.spinner("Uploading data..."):
success, message = upload_to_server(
st.session_state.mrn,
st.session_state.room_number,
st.session_state.patient_name,
st.session_state.cpap_pressure,
st.session_state.cpap_data
)
if success:
st.success(f"{message}")
st.session_state.patient_locked = True
else:
st.error(f"{message}")
Caching, Security, and a Few Pitfalls
For expensive operations that don’t need to re-run with every re-run:
@st.cache_data
def load_reference_data(file_path):
return pd.read_csv(file_path) # Only runs once per unique argument
@st.cache_resource
def get_ml_model():
return load_model("weights.pkl") # Shared across all sessions
For unsafe_allow_html=True with st.markdown(): never inject user input directly into HTML. A user entering <script>alert('xss')</script> as their name would execute that script in every browser that renders it. Escape user-provided values with html.escape() before embedding them in HTML strings, or just use Streamlit’s built-in components that escape automatically.
Widget keys must be unique across the entire app. If you create the same widget key in two different tabs, Streamlit raises DuplicateWidgetID. Namespace your keys: "tab1_name" and "tab2_name" instead of both using "name".
Session state is per-browser-session, not per-tab. Different tabs in different windows get different sessions. The same user in two tabs of the same browser window shares a session — if both tabs modify session state, they’ll interfere with each other. Data that needs to be shared across sessions belongs in a database or Redis, not st.session_state.
The default file upload limit is 200MB. Raise it in .streamlit/config.toml:
[server]
maxUploadSize = 500
Streamlit has no built-in authentication. For production use, either put it behind an nginx reverse proxy with HTTP Basic Auth, use the streamlit-authenticator library for session-based auth, or deploy to Streamlit Cloud with OAuth. A hardcoded password check in the script is fine for a single internal user in a trusted environment — not for anything exposed to the internet.