Concerns
Concerns 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 } from '@expressive/react';
const TAGS = ['all', 'math', 'code'];
export default () => (
<div className="container">
<h1>Separation of Concerns</h1>
<p>
This screen has three concerns - what is being asked for, what there is to
show, and what the reader picked - so it has three classes. None is a
feature of the library; the pattern is just that a concern is a class, and
the screen owns one of each.
</p>
<Screen />
<p>
Only <code>Results</code> needs all three. The filter bar cannot reach the
selection, and the detail pane cannot see the query - not by convention, but
because neither asked for it.
</p>
<small>
Every seam here is a type. <code>query.</code> completes to the fields and
methods that exist, a rename carries every reader with it, and a concern is
testable without a component in sight - none of which survives a string key
or a selector function.
</small>
</div>
);
class Query extends State {
text = '';
tag = 'all';
}
class Catalog extends State {
books = [
{ title: 'Analysis I', tag: 'math' },
{ title: 'Structure and Interpretation', tag: 'code' },
{ title: 'Concrete Mathematics', tag: 'math' },
{ title: 'The Art of Computer Programming', tag: 'code' }
];
find(text: string, tag: string) {
const match = text.trim().toLowerCase();
return this.books.filter(
(book) =>
(tag === 'all' || book.tag === tag) &&
book.title.toLowerCase().includes(match)
);
}
}
class Selection extends State {
title: string | null = null;
}
class Screen extends Component {
query = new Query();
catalog = new Catalog();
selection = new Selection();
render() {
return (
<section className="screen">
<Filters />
<Results />
<Detail />
</section>
);
}
}
class Filters extends Component {
query = get(Query);
render() {
const { query } = this;
return (
<div className="filters">
<input
value={query.text}
placeholder="Search titles"
onChange={(e) => (query.text = e.target.value)}
/>
<div className="tags">
{TAGS.map((tag) => (
<button
key={tag}
className={query.tag === tag ? 'tag on' : 'tag'}
onClick={() => (query.tag = tag)}>
{tag}
</button>
))}
</div>
</div>
);
}
}
class Results extends Component {
query = get(Query);
catalog = get(Catalog);
selection = get(Selection);
render() {
const { query, catalog, selection } = this;
const found = catalog.find(query.text, query.tag);
return (
<ul className="results">
{found.map(({ title }) => (
<li
key={title}
className={selection.title === title ? 'hit on' : 'hit'}
onClick={() => (selection.title = title)}>
{title}
</li>
))}
{!found.length && <li className="empty">nothing matches</li>}
</ul>
);
}
}
class Detail extends Component {
selection = get(Selection);
render() {
const { selection } = this;
return (
<footer className="detail">
{selection.title ?? 'no book selected'}
</footer>
);
}
}
App.css
.screen {
display: flex;
flex-direction: column;
gap: var(--s4);
width: 100%;
padding: var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
text-align: left;
}
.filters {
display: flex;
flex-direction: column;
gap: var(--s2);
}
.tags {
display: flex;
gap: var(--s2);
}
.tag {
padding: var(--s1) var(--s3);
font-size: var(--t-sm);
color: var(--muted);
}
.tag.on {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--accent);
}
.results {
gap: var(--s1);
}
.hit {
background: var(--bg);
border: 1px solid var(--border);
}
.hit.on {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--accent);
}
.empty {
color: var(--muted);
cursor: default;
}
.detail {
padding-top: var(--s3);
border-top: 1px solid var(--border);
font-size: var(--t-sm);
color: var(--muted);
}