Introduction
A to-do list is the classic first CRUD app, and Streamlit makes it a satisfying weekend build — you get a real web interface with checkboxes and buttons in under 80 lines of Python. If you have already built the age calculator, this is the natural next step because it introduces state that changes over time, not just state that responds to widgets.
The interesting problem here is persistence: a Streamlit rerun wipes plain variables, so the app stores tasks in a JSON file on disk and reloads them on every rerun. That one design decision turns a demo into a tool you can actually keep using.
Features
- Add tasks — text input with instant append on button press.
- Complete tasks — native checkboxes with strikethrough styling for done items.
- Delete tasks — per-task remove button.
- Persistent storage — tasks survive reruns and browser refreshes via a JSON file.
- Progress bar — a live completion percentage at the top of the page.
Prerequisites
- Python 3.8+ — from python.org.
- Streamlit — install with pip:
pip install streamlit
Step 1: Create the Script
Create a file called todo_app.py:
import streamlit as st
import json
import os
from datetime import date
TASKS_FILE = "tasks.json"
def load_tasks():
if os.path.exists(TASKS_FILE):
with open(TASKS_FILE, "r") as f:
return json.load(f)
return []
def save_tasks(tasks):
with open(TASKS_FILE, "w") as f:
json.dump(tasks, f, indent=2)
st.set_page_config(page_title="To-Do List", page_icon="✅")
st.title("✅ My To-Do List")
tasks = load_tasks()
# Add new task
new_task = st.text_input("New task", placeholder="What needs to be done?")
if st.button("Add Task") and new_task.strip():
tasks.append({"task": new_task.strip(), "done": False, "added": str(date.today())})
save_tasks(tasks)
st.rerun()
# Progress
if tasks:
done_count = sum(1 for t in tasks if t["done"])
st.progress(done_count / len(tasks), text=f"{done_count} of {len(tasks)} tasks complete")
# Render tasks
for i, t in enumerate(tasks):
col_check, col_label, col_del = st.columns([0.08, 0.82, 0.10])
with col_check:
done = st.checkbox("", value=t["done"], key=f"check_{i}")
with col_label:
if done:
st.markdown(f":gray[~~{t['task']}~~]")
else:
st.markdown(t["task"])
with col_del:
if st.button("🗑", key=f"del_{i}"):
tasks.pop(i)
save_tasks(tasks)
st.rerun()
if done != t["done"]:
tasks[i]["done"] = done
save_tasks(tasks)
st.rerun()
else:
st.info("No tasks yet — add your first one above!")
Step 2: Run the App
streamlit run todo_app.py
The app opens in your browser. Add a task, tick it off, delete another — then refresh the page and watch everything survive.
How It Works
The app follows a load → modify → save → rerun cycle. Every interaction reads the JSON file into a Python list, mutates it, writes it back, and calls st.rerun() to refresh the UI immediately. Without the explicit rerun, the interface would lag one interaction behind the file — a direct consequence of Streamlit’s top-to-bottom script model.
Checkboxes are the subtle part. Each checkbox gets a unique key so Streamlit can track its state across reruns, and the app compares the checkbox’s current value against the stored done flag to detect completion. The strikethrough effect is just markdown wrapped in :gray[... emphasis ...] — no CSS required.
The progress bar uses st.progress(), which takes a fraction from 0.0 to 1.0 — the same metric-card thinking behind the Pandas dashboard, just pointed at productivity instead of data.
Common Errors & Fixes
- Tasks disappear after refresh — the JSON file is being written to a different working directory. Run Streamlit from the folder where
tasks.jsonlives, or use an absolute path withpathlib.Path(__file__).parent. st.rerun()inside a loop causes weird skips — you are mutating the list while iterating. Collect changes first, apply them after the loop, then rerun once.- Duplicate checkboxes share state — every widget needs a unique
key=; reusingkey="check"makes Streamlit treat them as one widget. - JSON decode error on startup — the file was truncated by a crash mid-write. Write to a temp file and
os.replace()it into place for atomic saves.
Key Concepts
- File-based persistence — JSON is perfect for small, single-user state.
st.rerun()— forces an immediate script re-run so the UI matches the data.- Widget keys — unique identities that let Streamlit track state across reruns.
- Column layout —
st.columns()with width ratios builds compact row layouts.
What to Try Next
- Add due dates with
st.date_inputand highlight overdue tasks in red. - Add priority levels (high/medium/low) and sort the list before rendering.
- Store tasks in SQLite with the built-in
sqlite3module for multi-thousand-task scale. - Add a filter row with
st.selectbox— all / active / completed views.
FAQ
Can multiple people use this app at once?
Not safely with a single JSON file — concurrent writers overwrite each other. For shared lists, move to SQLite with WAL mode or a small database, and give each user their own task list.
How is this different from session state?
st.session_state dies when the browser session ends; a JSON file survives restarts and reboots. Use session state for UI-only values and files or databases for real data.
Can I deploy this so my tasks live in the cloud?
Yes — but on Streamlit Community Cloud the filesystem is ephemeral and resets on redeploy. Persist tasks in an external store (Supabase, Firebase, or a small hosted database) instead.