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);
}