Nested
Nested demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.
App.tsx
import './App.css';
import State, { Component, get, has } from '@expressive/react';
export default () => (
<div className="container">
<h1>Nested State</h1>
<p>
A state-typed field is state the parent <em>owns</em>: built with it,
destroyed with it. <code>new Doc()</code> is the ownership form -{' '}
<code>Doc.new()</code> would make a private instance with no context at all.
</p>
<p>
Ownership also places it. Nothing here provides a Doc or a History, yet the
toolbar two levels down finds both - a child joins whatever context its
parent belongs to, so providing the parent provides its children.
</p>
<Workspace />
<small>
Close the editor and both go with it: reopen to an empty sheet and no
history. Neither child had to unregister or clean up after itself - their
lifetime was their owner's all along.
</small>
</div>
);
class Doc extends State {
text = '';
get words() {
const words = this.text.trim();
return words ? words.split(/\s+/).length : 0;
}
}
class History extends State {
entries = has<string>();
record(text: string) {
this.entries.push(text);
}
undo() {
return this.entries.pop();
}
}
class Workspace extends Component {
open = true;
render() {
const { open } = this;
return (
<>
{open && <Editor />}
<button onClick={() => (this.open = !open)}>
{open ? 'Close' : 'Open'} editor
</button>
</>
);
}
}
class Editor extends Component {
doc = new Doc();
history = new History();
render() {
return (
<section className="editor">
<Toolbar />
<Sheet />
</section>
);
}
}
const Toolbar = () => (
<header className="toolbar">
<Undo />
<Words />
</header>
);
class Undo extends Component {
doc = get(Doc);
history = get(History);
revert() {
const previous = this.history.undo();
if (previous !== undefined) this.doc.text = previous;
}
render() {
const { history } = this;
return (
<button disabled={!history.entries.size} onClick={() => this.revert()}>
Undo {history.entries.size || ''}
</button>
);
}
}
class Words extends Component {
doc = get(Doc);
render() {
return <small>{this.doc.words} words</small>;
}
}
class Sheet extends Component {
doc = get(Doc);
history = get(History);
write(text: string) {
this.history.record(this.doc.text);
this.doc.text = text;
}
render() {
return (
<textarea
rows={3}
value={this.doc.text}
placeholder="Write something, then undo it…"
onChange={(e) => this.write(e.target.value)}
/>
);
}
}
App.css
.editor {
display: flex;
flex-direction: column;
gap: var(--s3);
width: 100%;
padding: var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
}
.toolbar button {
font-size: var(--t-sm);
}
.toolbar button:disabled {
color: var(--muted);
border-color: var(--border);
cursor: not-allowed;
}
.editor textarea {
resize: vertical;
font-family: var(--font-mono);
font-size: var(--t-sm);
}