Advanced Patterns

Combine state, effects, and computed logic to build real-world applications.

Architecture: Derived State

In complex apps, avoid creating separate state variables for things that can be calculated. Instead, use an effect to derive state automatically.

The Pattern: Raw Data (`todos`) + Modifiers (`search`, `filter`) = View (`filteredTodos`).
This ensures your UI never gets out of sync with your data.

Full Example: Todo App

Demonstrates CRUD, LocalStorage, and Filtering.

View Live Demo
todo-app.html
<div class="max-w-md mx-auto p-6 border rounded-xl shadow-sm bg-card">
    <h1 class="text-2xl font-bold mb-4">Tasks</h1>

    <!-- Input Section -->
    <div class="flex gap-2 mb-4">
        <input
            type="text"
            value="{newTodoText}"
            oninput="setNewTodoText(event.target.value)"
            onkeyup="event.key === 'Enter' && addTodo()"
            placeholder="What needs doing?"
            class="flex-1 border p-2 rounded bg-background" />
        <button
            onclick="addTodo()"
            disabled="{!newTodoText}"
            class="px-4 py-2 bg-primary text-primary-foreground rounded disabled:opacity-50">
            Add
        </button>
    </div>

    <!-- Controls -->
    <div class="flex justify-between items-center mb-4">
        <input
            type="text"
            value="{search}"
            oninput="setSearch(event.target.value)"
            placeholder="Search..."
            class="text-sm border p-1 px-2 rounded w-1/2" />

        <div class="space-x-1 text-sm">
            <button onclick="setFilter('all')" class="{filter === 'all' ? 'font-bold underline' : ''}">All</button>
            <button onclick="setFilter('active')" class="{filter === 'active' ? 'font-bold underline' : ''}">Active</button>
            <button onclick="setFilter('completed')" class="{filter === 'completed' ? 'font-bold underline' : ''}">Done</button>
        </div>
    </div>

    <!-- List Rendering -->
    <ul class="space-y-2">
        <template pp-for="todo in filteredTodos">
            <li key="{todo.id}" class="flex items-center gap-3 p-2 border-b hover:bg-accent/50 transition-colors">
                <input
                    type="checkbox"
                    checked="{todo.completed}"
                    onchange="toggleTodo(todo.id)"
                    class="h-4 w-4" />
                <!-- Edit input (shown when editing this todo) -->
                <input
                    type="text"
                    class="flex-1 border p-1 px-2 rounded todo-edit-input {editingId === todo.id ? '' : 'hidden'}"
                    pp-ref="{captureEditRef(todo.id)}"
                    value="{editingText}"
                    oninput="setEditingText(event.target.value)"
                    onkeydown="handleEditKeydown(event, todo.id)"
                    onblur="saveEdit()"
                    aria-label="Edit todo" />

                <span class="flex-1 {todo.completed ? 'line-through text-muted-foreground' : ''} {editingId === todo.id ? 'hidden' : ''}"
                    ondblclick="startEdit(todo.id, todo.text)"
                    title="Double-click to edit">
                    {todo.text}
                </span>
                <button onclick="editTodo(todo.id, todo.text)" class="text-blue-500 hover:text-blue-700 mr-2" aria-label="Edit todo">✎</button>
                <button onclick="removeTodo(todo.id)" class="text-red-500 hover:text-red-700">X</button>
            </li>
        </template>
    </ul>

    <!-- Footer Stats -->
    <div class="mt-4 text-xs text-muted-foreground flex justify-between">
        <span>{activeTodos.length} active tasks</span>
        <span>{completedTodos.length} completed</span>
    </div>
</div>

<script>
    // 1. State Declaration
    const [todos, setTodos] = pp.state([]);
    const [filter, setFilter] = pp.state("all");
    const [search, setSearch] = pp.state("");
    const [newTodoText, setNewTodoText] = pp.state("");
    // Editing state
    const [editingId, setEditingId] = pp.state(null);
    const [editingText, setEditingText] = pp.state("");
    // Refs for edit inputs (map of id -> ref)
    const editRefs = {};

    function captureEditRef(id) {
        if (!editRefs[id]) editRefs[id] = pp.ref(null);
        return function(el) {
            // store DOM node on the ref
            editRefs[id].current = el || null;
        };
    }

    // Derived State (Calculated automatically)
    const [filteredTodos, setFiltered] = pp.state([]);
    const [activeTodos, setActive] = pp.state([]);
    const [completedTodos, setCompleted] = pp.state([]);

    // Hydration Lock (Prevents saving empty array on initial load)
    const [isHydrated, setHydrated] = pp.state(false);

    // 2. Actions
    function addTodo() {
        if (!newTodoText.trim()) return;
        setTodos([...todos, {
            id: Date.now(),
            text: newTodoText,
            completed: false
        }]);
        setNewTodoText("");
    }

    function toggleTodo(id) {
        setTodos(todos.map(t => t.id === id ? {
            ...t,
            completed: !t.completed
        } : t));
    }

    function removeTodo(id) {
        setTodos(todos.filter(t => t.id !== id));
    }

    // Edit button handler: start edit and focus the input using refs
    function editTodo(id, currentText) {
        // Start editing (sets editingId & editingText)
        startEdit(id, currentText);

        // Focus the input for this id once it's rendered
        setTimeout(() => {
            const ref = editRefs[id];
            const el = ref && ref.current ? ref.current : null;
            if (el) {
                el.focus();
                el.select();
            }
        }, 0);
    }

    // Editing actions
    function startEdit(id, currentText) {
        setEditingId(id);
        setEditingText(currentText);
    }

    function cancelEdit() {
        setEditingId(null);
        setEditingText("");
    }

    function saveEdit() {
        if (editingId && editingText.trim()) {
            const newText = editingText.trim();
            const original = todos.find(t => t.id === editingId);
            if (original && original.text !== newText) {
                setTodos(todos.map(t => t.id === editingId ? { ...t, text: newText } : t));
            }
        }
        cancelEdit();
    }

    function handleEditKeydown(event, todoId) {
        if (event.key === 'Enter') {
            event.preventDefault();
            saveEdit();
        } else if (event.key === 'Escape') {
            event.preventDefault();
            cancelEdit();
        }
    }

    // 3. Effects

    // A. Load from Storage (Runs once)
    pp.effect(() => {
        const saved = localStorage.getItem("pp-todos");
        if (saved) setTodos(JSON.parse(saved));
        setHydrated(true);
    }, []);

    // B. Save to Storage (Runs when todos change)
    pp.effect(() => {
        if (isHydrated) {
            localStorage.setItem("pp-todos", JSON.stringify(todos));
        }
    }, [todos, isHydrated]);

    // C. Calculate Derived Views (Filtering Logic)
    pp.effect(() => {
        const active = todos.filter(t => !t.completed);
        const completed = todos.filter(t => t.completed);

        setActive(active);
        setCompleted(completed);

        // Apply Filter then Search
        let result = filter === 'active' ? active :
            filter === 'completed' ? completed :
            todos;

        if (search) {
            result = result.filter(t => t.text.toLowerCase().includes(search.toLowerCase()));
        }

        setFiltered(result);
    }, [todos, filter, search]);

    // Focus edit input when editing starts (use refs instead of querySelector)
    pp.effect(() => {
        if (editingId) {
            setTimeout(() => {
                const ref = editRefs[editingId];
                const el = ref && ref.current ? ref.current : null;
                if (el) {
                    el.focus();
                    el.select();
                }
            }, 0);
        }
    }, [editingId]);
</script>
Count Next: Infinite Scroll