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.
This ensures your UI never gets out of sync with your data.
Full Example: Todo App
Demonstrates CRUD, LocalStorage, and Filtering.
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>