has() Pool
has() Pool demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.
App.tsx
import './App.css';
import { Component, has } from '@expressive/react';
// `has(Item)` is an owned pool. `add` spawns a member and returns it;
// members carry their own identity, so dropping the pool into the tree
// is the whole render - no keys, no spread, no <Row>, no use().
export default class TodoList extends Component {
todos = has(Item);
draft = '';
// Pools resolve at activation, so seed members from the new() hook.
protected new() {
this.add('Learn Expressive');
}
add(text: string = this.draft) {
if (!text) return;
this.todos.add({ text });
this.draft = '';
}
clearDone() {
for (const item of [...this.todos])
if (item.done) this.todos.delete(item);
}
get remaining() {
return this.todos.filter((item) => !item.done).length;
}
render() {
const { todos, draft, remaining } = this;
return (
<div className="container todo">
<h1>Owned Collections</h1>
<p>
<code>has(Item)</code> is a pool that spawns and owns its members.
Each todo is its own Component, so dropping the pool into the tree
is the whole render.
</p>
<div className="card">
<form
onSubmit={(e) => {
e.preventDefault();
this.add();
}}>
<input
value={draft}
placeholder="Add a task…"
onChange={(e) => (this.draft = e.target.value)}
/>
<button type="submit" aria-label="add">+</button>
</form>
<ul>{todos}</ul>
<footer>
<small>{remaining} of {todos.size} left</small>
<button className="ghost" onClick={() => this.clearDone()}>
Clear done
</button>
</footer>
</div>
</div>
);
}
}
// Each todo is a Component - it owns its fields, its behavior, and its
// own markup. With #247 an instance renders directly as an element, so
// the parent never writes a row wrapper or wires props.
class Item extends Component {
text = '';
done = false;
toggle() {
this.done = !this.done;
}
// A member that destroys itself is evicted from the pool automatically.
remove() {
this.set(null);
}
render() {
return (
<li className={this.done ? 'done' : ''}>
<button className="check" onClick={this.toggle} aria-label="toggle" />
<span onClick={this.toggle}>{this.text}</span>
<button className="remove" onClick={this.remove} aria-label="remove">×</button>
</li>
);
}
}
App.css
.todo {
max-width: 26rem;
}
.card {
width: 100%;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
text-align: left;
}
.card > form {
display: flex;
gap: var(--s2);
margin: 0;
padding: var(--s3);
border-bottom: 1px solid var(--border);
}
.card > form input {
border: none;
background: transparent;
padding: var(--s2);
}
.card > form input:focus {
outline: none;
border: none;
}
.card > form button {
flex: 0 0 auto;
width: 2.25rem;
padding: 0;
font-size: var(--t-xl);
line-height: 1;
color: var(--accent);
background: var(--accent-soft);
border: none;
}
.card > form button:hover {
color: var(--accent-fg);
background: var(--accent);
}
.card ul {
gap: 0;
}
.card li {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3);
border-radius: 0;
border-bottom: 1px solid var(--border);
cursor: default;
transition: background 0.15s;
}
.card li:hover {
background: var(--surface-2);
}
.card li span {
flex: 1;
cursor: pointer;
}
.check {
position: relative;
flex: 0 0 auto;
width: 1.2rem;
height: 1.2rem;
padding: 0;
border: 1.5px solid var(--border-2);
border-radius: 50%;
background: var(--bg);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.check:hover {
border-color: var(--accent);
}
.done .check {
border-color: var(--accent);
background: var(--accent);
}
.done .check::after {
content: '';
position: absolute;
top: 3px;
left: 6px;
width: 4px;
height: 8px;
border: solid var(--accent-fg);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.done {
opacity: 0.6;
}
.done span {
text-decoration: line-through;
}
.remove {
flex: 0 0 auto;
width: 1.5rem;
height: 1.5rem;
padding: 0;
font-size: var(--t-lg);
line-height: 1;
color: var(--muted);
background: transparent;
border: none;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.card li:hover .remove {
opacity: 1;
}
.remove:hover {
color: var(--fg);
border: none;
}
.card footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
padding: var(--s3);
}
.card footer .ghost {
padding: var(--s1) var(--s3);
font-size: var(--t-sm);
color: var(--muted);
background: transparent;
border: 1px solid var(--border);
}
.card footer .ghost:hover {
color: var(--fg);
border-color: var(--border-2);
}