13. Advanced Patterns
#
Complex State Management – Todo App
Includes localStorage hydration, filters, and live search.
Copy
<style>
.todo-app { max-width: 600px; margin: 0 auto; padding: 20px; }
.add-todo { display: flex; gap: 10px; margin-bottom: 20px; }
.add-todo input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
.filters { display: flex; gap: 10px; margin-bottom: 20px; }
.filters button { padding: 6px 12px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
.filters button.active { background: #007bff; color: white; border-color: #007bff; }
.todo-list { list-style: none; padding: 0; }
.todo-list li { display: flex; align-items: center; gap: 10px; padding: 10px; border-bottom: 1px solid #eee; }
.todo-list li.completed .todo-text { text-decoration: line-through; opacity: 0.6; }
.todo-text { flex: 1; }
.remove { background: #dc3545; color: white; border: none; border-radius: 4px; width: 24px; height: 24px; cursor: pointer; }
.stats { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; }
.stats p { margin: 5px 0; }
</style>
<h1>Todo App</h1>
<div class="add-todo">
<input
type="text"
value="{newTodoText}"
oninput="setNewTodoText(this.value)"
onkeyup="(event.key === 'Enter') && addTodo()"
placeholder="Add todo..."
/>
<button onclick="addTodo()" disabled="{!newTodoText}">Add</button>
</div>
<div class="search" style="display:flex; gap:10px; margin: 10px 0 20px;">
<input
type="text"
value="{search}"
oninput="setSearch(this.value)"
placeholder="Search todos…"
style="flex:1; padding:8px; border:1px solid #ddd; border-radius:4px;"
/>
<button
onclick="setSearch('')"
disabled="{!search}"
style="padding:6px 12px; border:1px solid #ddd; background:white; border-radius:4px; cursor:pointer;"
>Clear</button>
</div>
<div class="filters">
<button onclick="setFilter('all')" class="{filter === 'all' ? 'active' : ''}">All</button>
<button onclick="setFilter('active')" class="{filter === 'active' ? 'active' : ''}">Active</button>
<button onclick="setFilter('completed')" class="{filter === 'completed' ? 'active' : ''}">Completed</button>
</div>
<ul class="todo-list">
<template pp-for="todo in filteredTodos">
<li key="{todo.id}" class="{todo.completed ? 'completed' : ''}">
<input type="checkbox" checked="{todo.completed}" onchange="toggleTodo(todo.id)" />
<span class="todo-text">{todo.text}</span>
<button onclick="removeTodo(todo.id)" class="remove">×</button>
</li>
</template>
</ul>
<div class="stats">
<p>Total: {todos.length}</p>
<p>Active: {activeTodos.length}</p>
<p>Completed: {completedTodos.length}</p>
</div>
<script type="text/pp">
const [todos, setTodos] = pp.state([]);
const [newTodoText, setNewTodoText] = pp.state("");
const [filter, setFilter] = pp.state("all");
const [nextId, setNextId] = pp.state(1);
const [search, setSearch] = pp.state("");
const [activeTodos, setActiveTodos] = pp.state([]);
const [completedTodos, setCompletedTodos] = pp.state([]);
const [filteredTodos, setFilteredTodos] = pp.state([]);
const [hydrated, setHydrated] = pp.state(false);
function addTodo() {
const text = newTodoText.trim();
if (!text) return;
const newTodo = { id: nextId, text, completed: false };
setTodos([...todos, newTodo]);
setNewTodoText("");
setNextId(nextId + 1);
}
function toggleTodo(id) {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
function removeTodo(id) {
setTodos(todos.filter((todo) => todo.id !== id));
}
// Hydrate once from localStorage
pp.effect(() => {
try {
const saved = localStorage.getItem("todos");
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
setTodos(parsed);
if (parsed.length > 0) {
const maxId = Math.max(...parsed.map((t) => Number(t.id) || 0));
setNextId(maxId + 1);
}
}
}
} catch (e) {
console.warn("⚠️ Failed to parse saved todos:", e);
} finally {
setHydrated(true);
console.log("✅ Hydrated from storage");
}
}, []);
// Save whenever todos change (post-hydration)
pp.effect(() => {
if (!hydrated) return;
try {
localStorage.setItem("todos", JSON.stringify(todos));
console.log("💾 Saved todos:", todos);
} catch (e) {
console.warn("⚠️ Failed to save todos:", e);
}
}, [todos, hydrated]);
// Recompute derived lists when todos OR filter OR search change
pp.effect(() => {
const active = todos.filter((t) => !t.completed);
const completed = todos.filter((t) => t.completed);
setActiveTodos(active);
setCompletedTodos(completed);
// Apply base filter first
let base =
filter === "active" ? active : filter === "completed" ? completed : todos;
// Then apply search (case-insensitive, trims spaces)
const term = search.trim().toLowerCase();
if (term) {
base = base.filter((t) => (t.text || "").toLowerCase().includes(term));
}
setFilteredTodos(base);
console.log("🧮 Derived ->", { filter, search: term, resultCount: base.length });
}, [todos, filter, search]);
</script>