Courses/Streamlit: 20 Real Apps/Module 3: Beginner Projects
Module 3 · Lesson 330 minBeginner

Project 3: To-Do List

What you'll build
Build a persistent task manager — your first app with real CRUD operations and saved state.

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:
  • code
    pip install streamlit

    Step 1: Create the Script

    Create a file called todo_app.py:

    code
    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

    code
    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.json lives, or use an absolute path with pathlib.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=; reusing key="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 layoutst.columns() with width ratios builds compact row layouts.
  • What to Try Next

  • Add due dates with st.date_input and 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 sqlite3 module 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.

    Adapted from: To-Do List App using Python and Streamlit

    Checkpoint
    Add, complete, and delete tasks, then refresh the browser: everything survives.
    What you learned
    • File-based persistence with JSON
    • The load-modify-save-rerun cycle
    • Unique widget keys in loops