Chapter 7: Derived State
We’ve been using Readable.map to transform signal values. Now let’s go deeper into derived state and add filtering to our todo app.
Readable.map
Readable.map creates a derived value from a signal:
const count = yield* Signal.make(5);
// Derived value - updates when count changes
const doubled = Readable.map(count, n => n * 2);
// Use it in the UI
$.span({}, $.of(doubled)) // Displays "10", updates automatically
The derived value:
- Updates automatically when the source changes
- Is read-only (you can’t
.set()it directly) - Is lazy - only computes when actually used
Adding a Filter
Let’s add filter buttons: All, Active, Completed. First, add a filter Signal:
type Filter = "all" | "active" | "completed";
const App = () =>
Effect.gen(function* () {
const todos = yield* Signal.make<Todo[]>([...]);
const newTodoText = yield* Signal.make("");
const filter = yield* Signal.make<Filter>("all");
// ...
});
Computing Filtered Todos
Now we need a derived value that filters todos based on the current filter. We could use Readable.map, but we need data from two signals (todos and filter).
Using Readable.zipWith
For combining multiple signals, use Readable.zipWith:
import { $, collect, each, Readable, Signal, when } from "@effex/dom";
// Inside App component:
const filteredTodos = Readable.zipWith(todos, filter, (todoList, currentFilter) => {
switch (currentFilter) {
case "active":
return todoList.filter(t => !t.completed);
case "completed":
return todoList.filter(t => t.completed);
default:
return todoList;
}
});
Readable.zipWith takes:
- Two source signals/readables
- A function that receives both values and computes the result
When either source changes, the derived value recomputes.
Rendering the Filtered List
Replace the each(todos, ...) with each(filteredTodos, ...):
each(filteredTodos, {
container: () => $.ul({ class: "todo-list" }),
key: (todo) => todo.id,
render: (todo) => TodoItem({ todo, onToggle: toggleTodo }),
})
Adding Filter Buttons
Add the filter buttons in the footer:
$.footer({ class: "footer" },
collect(
$.span(
{ class: "todo-count" },
$.of(Readable.map(todos, t => {
const remaining = t.filter(todo => !todo.completed).length;
return `${remaining} item${remaining === 1 ? "" : "s"} left`;
}))
),
$.div({ class: "filters" },
collect(
$.button(
{
class: Readable.map(filter, f => f === "all" ? "filter-btn selected" : "filter-btn"),
onClick: () => filter.set("all"),
},
$.of("All")
),
$.button(
{
class: Readable.map(filter, f => f === "active" ? "filter-btn selected" : "filter-btn"),
onClick: () => filter.set("active"),
},
$.of("Active")
),
$.button(
{
class: Readable.map(filter, f => f === "completed" ? "filter-btn selected" : "filter-btn"),
onClick: () => filter.set("completed"),
},
$.of("Completed")
),
)
),
)
),
Each button:
- Has a reactive class that highlights when selected
- Sets the filter on click
Add Filter Styles
.filters {
display: flex;
gap: 8px;
margin-top: 12px;
}
.filter-btn {
padding: 4px 8px;
border: 1px solid transparent;
background: none;
cursor: pointer;
}
.filter-btn:hover {
border-color: #ddd;
}
.filter-btn.selected {
border-color: #b83f45;
}
The Complete Footer
Here’s the full footer section:
$.footer({ class: "footer" },
collect(
$.span(
{ class: "todo-count" },
$.of(Readable.map(todos, t => {
const remaining = t.filter(todo => !todo.completed).length;
return `${remaining} item${remaining === 1 ? "" : "s"} left`;
}))
),
$.div({ class: "filters" },
collect(
$.button(
{
class: Readable.map(filter, f => f === "all" ? "filter-btn selected" : "filter-btn"),
onClick: () => filter.set("all"),
},
$.of("All")
),
$.button(
{
class: Readable.map(filter, f => f === "active" ? "filter-btn selected" : "filter-btn"),
onClick: () => filter.set("active"),
},
$.of("Active")
),
$.button(
{
class: Readable.map(filter, f => f === "completed" ? "filter-btn selected" : "filter-btn"),
onClick: () => filter.set("completed"),
},
$.of("Completed")
),
)
),
)
),
Try It Out
- Add some todos
- Complete a few
- Click the filter buttons
- Watch the list update instantly!
When to Use What
| Situation | Use |
|---|---|
| Transform one signal | Readable.map(signal, fn) |
| Combine two signals | Readable.zipWith(s1, s2, fn) |
| Combine multiple signals | Readable.zip(s1, s2, s3, ...) |
| Just need the value once | yield* signal.get |
Derived vs Computed in Other Frameworks
If you’ve used other frameworks:
- Vue:
Readable.zipWithis likecomputed() - Solid: It’s like
createMemo() - Svelte: It’s like
$:reactive statements
The key difference: Effex derivations are explicit and type-safe. You always know what depends on what.
Key Takeaways
Readable.maptransforms a single signalReadable.zipWithcombines two signals- Derived values update automatically when sources change
- Derived values are read-only
- Use derived state for computed/filtered views of your data