How AI is applied across API Evangelist and APIs.io. Read my AI disclosure →
API Evangelist API Evangelist
Discovery
Learnings
Guidance
Toolbox
Alignment
API Evangelist LLC

10 Principles for Keeping the Vibe while Coding Streamlit Apps

calendar_today December 18, 2025 person Chanin Nantasenamat domain streamlit
Image generated by Author using the ideogram-ai/ideogram-v2-turbo model on Replicate.

Vibe coding has changed how we build apps. You describe what you want, and after a few seconds, AI generates the code. After a few rounds of iteration and you have a working Streamlit dashboard app. It’s genuinely magical.

But here’s the catch, without understanding how Streamlit actually works under the hood, that rapid progress can hit a wall. Your app suddenly behaves strangely. Data disappears. Buttons don’t seem to work. Performance grinds to a halt.

After spending considerable time on the Streamlit Community Forum and reviewing countless questions from beginners (some of whom were vibe coding their first apps), I started noticing patterns. The same confusions kept surfacing. The same “why isn’t this working?!” moments.

The good news? Most of these issues stem from a handful of core misunderstandings about how Streamlit actually works under the hood. Once you internalize these concepts, the framework clicks into place and you can vibe code with confidence instead of crossing your fingers every time you hit save.

Rather than cataloging every possible mistake (that list would be quite extensive), I’ve distilled these into 10 core principles. Master these, and you’re on your way to building Streamlit apps with confidence.

Let’s dive in!

1. Understand that your script reruns completely on every interaction

This is the concept. The one that explains 80% of the unexpected behavior that you’ll encounter. If you only internalize one thing from this entire post, make it this:

Every button click, slider move, or text input triggers a complete rerun of your script from top to bottom. Not a partial update. Not a diff. The whole thing, starting from import streamlit as st and all the way down.

# ❌ This resets to 0 on every interaction
counter = 0
if st.button("Add"):
counter += 1
st.write(counter) # Always shows 0 or 1

# ✅ Use session state for persistence
if "counter" not in st.session_state:
st.session_state.counter = 0
if st.button("Add"):
st.session_state.counter += 1
st.write(st.session_state.counter) # Accumulates correctly

Here are some common symptoms that we may see if not understanding this concept:

  • Variables “reset” unexpectedly
  • Buttons seem to “not work”
  • Data disappears after clicking something else
  • Chat history vanishes

The fix: st.session_state

Session state is a dictionary that persists across reruns for each user session. The key pattern to memorize:

# Always initialize before using
if "counter" not in st.session_state:
st.session_state.counter = 0

This check ensures you only set the default once (on the first run), and subsequent reruns use the existing value. Skip this check, and you’ll reset your variable on every interaction.

When to use session state:

  • Counters, toggles, and flags
  • User inputs you need to preserve
  • Chat message history
  • Multi-step form data
  • Any value that should survive a rerun

Once this pattern becomes second nature, most “mysterious” Streamlit behavior suddenly makes perfect sense.

2. Cache expensive operations, always

Remember that rerun behavior from Principle 1? Here’s where it becomes a performance nightmare if you’re not careful.

If your code takes time to run (loading files, API calls, ML models), it will run on every single interaction unless you cache it. Your users don’t want to wait 10 seconds every time they adjust a slider.

# ❌ Loads 500MB file on every click
data = pd.read_csv("large_file.csv")

# ✅ Cache it - runs once, reuses result
@st.cache_data
def load_data():
return pd.read_csv("large_file.csv")

data = load_data()

The two decorators:

  • @st.cache_data: For data (DataFrames, lists, API responses). Returns a copy.
  • @st.cache_resource: For resources (DB connections, ML models). Returns the same object.

Add TTL for data that updates frequently:

@st.cache_data(ttl=300)  # Refresh every 5 minutes
def get_live_data():
return api.fetch()

Why does this work? The decorator stores the function’s return value in memory, keyed by the function’s input arguments. On subsequent calls with the same arguments, Streamlit returns the cached result instead of re-executing the function. This means your 10-second data load happens only once, on that initial run, and not on every interaction.

3. Never hardcode secrets: use st.secrets

This one isn’t about convenience. It’s about not accidentally publishing your API keys to GitHub for the world to see.

Yes, bots can actively scan for this. And yes, your keys will get compromised within minutes.

This principle has no exceptions. “Just for testing” is how secrets can end up in the commit history forever.

# ❌ NEVER do this
api_key = "sk-1234567890"

# ✅ Use .streamlit/secrets.toml (with gitignored!)
api_key = st.secrets["openai"]["api_key"]

Here’s what to do instead:

  • Create .streamlit/secrets.toml for local development
  • Add .streamlit/secrets.toml to .gitignore before your first commit
  • Add secrets via the UI in the secrets management when deploying to Community Cloud

4. Handle empty and error states gracefully

Your app will encounter None values. It will receive empty lists. APIs will fail. Files won’t upload.

The difference between a frustrating app and a delightful one is often just how gracefully it handles these situations. Never assume that data exists; always check before processing.

# ❌ Crashes if no file was uploaded
uploaded = st.file_uploader("Upload")
df = pd.read_csv(uploaded)

# ✅ Check first, guide the user
uploaded = st.file_uploader("Upload")
if uploaded is not None:
df = pd.read_csv(uploaded)
st.dataframe(df)
else:
st.info("👆 Upload a CSV to get started")

Apply this pattern everywhere:

  • File uploads → check with is not None
  • API calls → wrap in try / except
  • Filtered data → check if result is empty
  • Selectbox options → verify that list isn’t empty

Here’s an example for select boxes:

# Empty options can crash selectbox
options = [x for x in items if x.startswith("A")]
if options:
selected = st.selectbox("Choose", options)
else:
st.warning("No matching items found")

Why does this matters? Streamlit apps often start in an “empty” state before the user provides input. Your code needs to handle this gracefully. Think of it as defensive coding: assume nothing exists until you’ve verified that it does.

5. Use forms when you need to batch inputs

Here’s a scenario: you have a form with name, email, and phone fields. Without Streamlit forms, your app might start processing (triggering a rerun of the app) after the user starts typing their name, before they’ve even gotten to their email. As a result, the app may rerun multiple times by the time that you’re finished with filling in the input fields.

Forms let users fill out multiple fields and submit them together as a batch, thereby giving you control over when the processing actually happens.

# ❌ Processing might trigger on partial input
name = st.text_input("Name")
email = st.text_input("Email")

if name:
process1(name)
if email:
process2(email)

# ✅ Forms batch until explicit submit
with st.form("user_form"):
name = st.text_input("Name")
email = st.text_input("Email")
submitted = st.form_submit_button("Submit")

if submitted:
if name and email: # Only process if both are filled
process1(name)
process2(email)
else:
st.error("Please fill out all fields")

Use forms when:

  • You have multiple related inputs
  • You want to prevent premature processing
  • You’re building data entry interfaces

Forms create a boundary that prevents reruns until the user explicitly clicks submit. All widgets inside the form collect input without triggering the script, then submit sends everything at once. This gives you a clean “before” and “after” moment instead of continuous partial updates.

6. Add unique keys to widgets in loops

DuplicateWidgetID is one of those error messages that makes perfect sense once you understand it, and is completely baffling before that moment.

When creating widgets dynamically (in loops or conditionally), Streamlit needs unique identifiers to tell them apart. No key? Streamlit guesses. Multiple widgets with the same guess? Chaos.

# ❌ DuplicateWidgetID error
for item in items:
st.checkbox(item)

# ✅ Add unique keys
for i, item in enumerate(items):
st.checkbox(item, key=f"checkbox_{i}")

Also remember:

  • Capture widget return values: value=st.slider("...")
  • Widgets with keys automatically sync to st.session_state

Let’s take a look at an example:

# ✅ Captures the return value from a stored variable
temperature = st.slider("Temperature", 0, 100, 72)
st.write(f"Current: {temperature}°F")

# ✅ A key is automatically stored in session_state
st.slider("Humidity", 0, 100, 50, key="humidity")
st.write(f"Humidity: {st.session_state.humidity}%")

Direct capture: The slider returns its current value, which we store in the temperature variable. This is the simplest approach when you just need to use the value immediately.

Using a key: When you add a key parameter, Streamlit automatically stores the widget’s value in st.session_state under that key name. You can access it as st.session_state.humidity anywhere in your app. This is useful when you need to access the value in multiple places or modify it programmatically.

Streamlit identifies widgets by generating an ID from their type, label, and position in the code. In a loop, multiple widgets can end up with identical IDs and thus give rise to an error! The key parameter overrides this auto-generated ID with your own unique identifier, letting Streamlit distinguish between them. As a bonus, any widget with a key automatically syncs its value to st.session_state, making it accessible throughout your app.

7. Show progress for long operations

A frozen screen is a source of anxiety for users. “Did it crash? Should I refresh? Is my data lost?” These are not the questions you want people asking.

The fix is simple: show them something is happening. Streamlit makes this almost very easy.

# ❌ User sees frozen screen, thinks app crashed
result = slow_api_call()
st.write(result)

# ✅ Show a spinner for single operations
with st.spinner("Generating response..."):
result = slow_api_call()
st.write(result)

# ✅ Use st.status for multi-step operations
with st.status("Processing...", expanded=True) as status:
st.write("Step 1: Loading data...")
data = load_data()

st.write("Step 2: Analyzing...")
analysis = analyze(data)

st.write("Step 3: Generating report...")
report = generate_report(analysis)

status.update(label="Complete!", state="complete")

For LLM apps, streaming responses can provide users with real-time feedback.

Here’s how we could implement streaming using OpenAI:

from openai import OpenAI
import streamlit as st

client = OpenAI(api_key=st.secrets["openai"]["api_key"])

stream = client.chat.completions.create(
model="gpt-4o",
max_tokens=1024,
messages=[{"role": "user", "content": "Write a poem about snowflakes."}],
stream=True
)

st.write_stream(stream) # Shows tokens as they arrive

And here’s how with Anthropic:

from anthropic import Anthropic
import streamlit as st

client = Anthropic(api_key=st.secrets["anthropic"]["api_key"])

# Stream responses for real-time feedback
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Write a poem about snowflakes."}]
) as stream:
st.write_stream(stream.text_stream) # Shows tokens as they arrive

Why does this matter? Users are surprisingly patient when they can see progress. A 10-second wait with a spinner feels faster than a 5-second frozen screen. It’s not about speed, it’s about communication.

8. Use @st.fragment for partial reruns

This is the “I wish I’d known this sooner” feature for anyone building apps with both expensive visualizations and interactive elements.

By default, the entire script reruns on every interaction. Got a chart that takes 5 seconds to render? Every widget interaction would cause the user to wait for another 5 more seconds as the app reruns. Fragments let you break this cycle by isolating sections that should rerun independently from the rest.

# ❌ Expensive operation reruns on every interaction
st.title("Dashboard")

# This takes 5 seconds
plot_expensive_chart(data)

# Every button click reruns the expensive chart!
if st.button("Increment Counter"):
st.session_state.count = st.session_state.get("count", 0) + 1
st.write(f"Count: {st.session_state.get('count', 0)}")

# ✅ Fragment isolates the counter from the expensive chart
st.title("Dashboard")

plot_expensive_chart(data) # Only runs on full page rerun

@st.fragment
def counter_section():
if st.button("Increment Counter"):
st.session_state.count = st.session_state.get("count", 0) + 1
st.write(f"Count: {st.session_state.get('count', 0)}")

counter_section() # This section reruns independently

Use fragments when:

  • Mixing expensive calculations with lightweight interactions
  • Any section that updates frequently while others stay static

Why this works: The @st.fragment decorator creates an isolated execution scope that allows only a specific function to re-execute, not the entire script. Everything outside the fragment stays untouched, thereby preserving your expensive computations.

9. Structure your project for deployment from the start

“I’ll organize it later” is the lie we tell ourselves right before our app.py hits 2,000 lines and we can’t find anything.

Future you will thank present you for taking 5 minutes to set up a proper project structure. Don’t wait until deployment to organize your code.

my_app/
├── app.py # Entry point
├── requirements.txt # Pin versions!
├── .gitignore # Include secrets.toml
├── .streamlit/
│ ├── config.toml # Theme settings
│ └── secrets.toml # Local secrets (gitignored)
├── pages/ # Multipage app
│ └── 1_📊_Dashboard.py
└── utils/ # Shared code
└── data.py

Why pin versions? An unpinned requirements.txt is a ticking time bomb. Your app works perfectly today, but six months from now when you redeploy or reinstall dependencies, a new library version might introduce breaking changes. Pinning versions (pandas==2.1.0 instead of just pandas) ensures your app behaves the same way every time (like putting your app in a time capsule).

Deployment checklist:

  • requirements.txt with pinned versions (pandas==2.1.0)
  • Relative file paths only (use assets/ as oppossed to /Users/chanin/dashboard_app/assets)
  • Secrets in secrets.toml, not hard-coded in the code
  • Test your deployment early and often

10. Use Streamlit’s built-in features before custom solutions

Before you spend an hour crafting custom CSS for a metrics display, check if Streamlit already has a widget for that.

It probably does and LLMs may be trained on an older version of Streamlit

The framework has grown significantly, and many common UI patterns now have first-class support. Using built-ins means less code to maintain, better performance, and a more consistent look.

+----------------------+------------------------------------------+
| Need | Use This |
+----------------------+------------------------------------------+
| Show loading state | with st.spinner("Loading...") |
| Display KPIs | st.metric(label, value, delta) |
| Organize content | st.columns(), st.tabs(), st.expander() |
| Database connections | st.connection() |
| Long operations | st.status() for multi-step progress |
| Chat interfaces | st.chat_message(), st.chat_input() |
| Stream LLM responses | st.write_stream() |
| Partial reruns | @st.fragment |
+----------------------+------------------------------------------+

Let’s say that you want to display metrics in your dashboard app, instead of manually using st.write() you could instead leverage st.metric(), which comes with the metric name and values as well as any recent positive/negative changes.

# ❌ Manual KPI formatting
st.write(f"Revenue: ${revenue:,}")
st.write(f"Change: {change:+.1% }")

# ✅ Use st.metric
st.metric("Revenue", f"${revenue:,}", f"{change:+.1% }")

If Streamlit doesn’t have it built-in, check these before building custom solutions:

  1. Streamlit Extras: A curated collection of additional components including card layouts, annotated text, clickable images, and more. Maintained by members of the Streamlit data science team.
  2. Streamlit Components Gallery: Third-party extensions for bringing in additional functionality to your Streamlit app!

The hierarchy: Built-in → Streamlit Extras → Third-party → Custom code. Each step down means more maintenance burden, so only go custom when you truly need something unique.

Wrapping Up

There you have it, 10 principles that address the root causes of most Streamlit beginner struggles. The beautiful thing about these principles is that once they click, they become second nature. You’ll start writing Streamlit code that “just works” because you understand why it works.

Vibe coding isn’t about blindly accepting AI suggestions. It’s about knowing enough to guide the conversation productively. The AI handles the syntax; you bring the architectural intuition.

If you’re looking to put these principles into practice, check out the upcoming 30 Days of AI in January 2026. This challenge focuses on teaching learners to build LLM-powered apps, taking you from connecting to an LLM, building basic chat interfaces to features for production-ready AI apps. It’s a free learning challenge, one day at a time.

https://medium.com/media/48373b274280e87e948022aef1910248/href<p>Happy Streamlit-ing! 🎈</p><h3>Resources</h3><p>If you’re ready to dive deeper into the specifics, here are additional resources to help you.</p><ol><li>Reruns & Session State: Adding Statefulness to Apps</li><li>Caching: Caching Overview</li><li>Secrets: Secrets Management</li><li>Error Handling: Basic Concepts of Streamlit</li><li>Forms: Using forms</li><li>Widget Keys: Widget Behavior</li><li>Progress Indicators: st.spinner, st.status, st.progress</li><li>Fragments: Working with Fragments</li><li>Project Structure: App Dependencies, Multipage Apps</li><li>Built-in Features: API Reference, Streamlit Extras</li></ol><hr /><p>10 Principles for Keeping the Vibe while Coding Streamlit Apps was originally published in Streamlit on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>

open_in_new Read original post