generated from coulomb/repo-seed
323 lines
7.8 KiB
Markdown
323 lines
7.8 KiB
Markdown
# Tutorial 6: Shared State
|
||
|
||
Now that your components can communicate through events, the **sixth tutorial** introduces the next natural step in professional UI architecture: **shared state** (synchronizing multiple components via a central store).
|
||
|
||
You’ll learn how to implement a **lightweight reactive store** with Lit, integrate it with your existing `<hello-world>` and `<hello-dashboard>` components, and keep everything **test-driven**.
|
||
|
||
---
|
||
|
||
# ⚡ Tutorial 6 — Shared State
|
||
|
||
### “The Store Pattern” — Synchronizing Multiple Components
|
||
|
||
---
|
||
|
||
## 🎯 Goal
|
||
|
||
We’ll create a **store** that holds the global list of greeter names.
|
||
All `<hello-world>` components will automatically update when the store changes.
|
||
The `<hello-dashboard>` will use the store to add, remove, and rename greeters — without manually passing props or listening to custom events.
|
||
|
||
**Architecture overview:**
|
||
|
||
```
|
||
+------------------------+
|
||
| HelloDashboard |
|
||
| (manages store ops) |
|
||
| |
|
||
| ┌────────────┐ |
|
||
| | HelloWorld | <── store.subscribe()
|
||
| └────────────┘ |
|
||
| ┌────────────┐ |
|
||
| | HelloWorld | |
|
||
| └────────────┘ |
|
||
+------------------------+
|
||
│
|
||
▼
|
||
Shared Store
|
||
(holds array of greeters)
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 1. Step 1 — Write the failing test first
|
||
|
||
Create `src/store/hello-store.test.js`:
|
||
|
||
```javascript
|
||
import { helloStore } from "./hello-store.js";
|
||
|
||
describe("helloStore", () => {
|
||
it("starts with one default greeter", () => {
|
||
const state = helloStore.getState();
|
||
expect(state.greeters).to.deep.equal(["World"]);
|
||
});
|
||
|
||
it("can add a new greeter", () => {
|
||
helloStore.addGreeter("Alice");
|
||
const state = helloStore.getState();
|
||
expect(state.greeters).to.include("Alice");
|
||
});
|
||
|
||
it("notifies subscribers on change", (done) => {
|
||
const unsubscribe = helloStore.subscribe((state) => {
|
||
expect(state.greeters).to.include("Bob");
|
||
unsubscribe();
|
||
done();
|
||
});
|
||
helloStore.addGreeter("Bob");
|
||
});
|
||
});
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
All tests fail (expected).
|
||
|
||
---
|
||
|
||
## ⚙️ 2. Step 2 — Implement the store
|
||
|
||
Create a new file:
|
||
`src/store/hello-store.js`
|
||
|
||
```javascript
|
||
class HelloStore {
|
||
constructor() {
|
||
this.state = { greeters: ["World"] };
|
||
this.listeners = new Set();
|
||
}
|
||
|
||
getState() {
|
||
return this.state;
|
||
}
|
||
|
||
_notify() {
|
||
for (const cb of this.listeners) cb(this.state);
|
||
}
|
||
|
||
subscribe(callback) {
|
||
this.listeners.add(callback);
|
||
callback(this.state); // immediate call with current state
|
||
return () => this.listeners.delete(callback);
|
||
}
|
||
|
||
addGreeter(name) {
|
||
this.state = {
|
||
...this.state,
|
||
greeters: [...this.state.greeters, name]
|
||
};
|
||
this._notify();
|
||
}
|
||
|
||
renameGreeter(oldName, newName) {
|
||
const updated = this.state.greeters.map((n) =>
|
||
n === oldName ? newName : n
|
||
);
|
||
this.state = { ...this.state, greeters: updated };
|
||
this._notify();
|
||
}
|
||
}
|
||
|
||
export const helloStore = new HelloStore();
|
||
```
|
||
|
||
Re-run:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
✅ All tests should now pass.
|
||
|
||
---
|
||
|
||
## 🧩 3. Step 3 — Update `<hello-dashboard>` to use the store
|
||
|
||
Modify `src/components/hello-dashboard/hello-dashboard.js`:
|
||
|
||
```javascript
|
||
import { LitElement, html, css } from "lit";
|
||
import "../hello-world/hello-world.js";
|
||
import { helloStore } from "../../store/hello-store.js";
|
||
|
||
export class HelloDashboard extends LitElement {
|
||
static styles = css`
|
||
.container {
|
||
font-family: system-ui, sans-serif;
|
||
padding: 1rem;
|
||
text-align: center;
|
||
}
|
||
.counter {
|
||
margin: 0.5rem 0 1rem 0;
|
||
font-weight: bold;
|
||
color: #007acc;
|
||
}
|
||
button {
|
||
background: #007acc;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 0.4rem 1rem;
|
||
cursor: pointer;
|
||
}
|
||
`;
|
||
|
||
constructor() {
|
||
super();
|
||
this.greeters = [];
|
||
}
|
||
|
||
connectedCallback() {
|
||
super.connectedCallback();
|
||
this.unsubscribe = helloStore.subscribe((state) => {
|
||
this.greeters = state.greeters;
|
||
});
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
this.unsubscribe?.();
|
||
super.disconnectedCallback();
|
||
}
|
||
|
||
_addGreeter() {
|
||
const newName = `User${this.greeters.length + 1}`;
|
||
helloStore.addGreeter(newName);
|
||
}
|
||
|
||
render() {
|
||
return html`
|
||
<div class="container">
|
||
<h2>Hello Dashboard</h2>
|
||
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
||
|
||
${this.greeters.map(
|
||
(name) => html`<hello-world name=${name}></hello-world>`
|
||
)}
|
||
|
||
<button @click=${this._addGreeter}>Add Greeting</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
customElements.define("hello-dashboard", HelloDashboard);
|
||
```
|
||
|
||
---
|
||
|
||
## 🧠 4. Step 4 — Update `<hello-world>` to use store for renaming
|
||
|
||
Update `_onInput()` in `src/components/hello-world/hello-world.js`:
|
||
|
||
```javascript
|
||
import { helloStore } from "../../store/hello-store.js";
|
||
|
||
_onInput(event) {
|
||
const oldName = this.name;
|
||
this.name = event.target.value;
|
||
helloStore.renameGreeter(oldName, this.name);
|
||
}
|
||
```
|
||
|
||
Now each greeter updates the shared store when its input changes — all other components subscribed to the store will react automatically.
|
||
|
||
---
|
||
|
||
## 🧪 5. Step 5 — Integration test (optional)
|
||
|
||
Add `src/integration/dashboard-store.test.js`:
|
||
|
||
```javascript
|
||
import "../components/hello-dashboard/hello-dashboard.js";
|
||
import { helloStore } from "../store/hello-store.js";
|
||
|
||
describe("HelloDashboard and HelloStore integration", () => {
|
||
it("renders as many greeters as store entries", async () => {
|
||
helloStore.addGreeter("Eve");
|
||
const el = document.createElement("hello-dashboard");
|
||
document.body.appendChild(el);
|
||
await el.updateComplete;
|
||
|
||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||
expect(greeters.length).to.equal(helloStore.getState().greeters.length);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
✅ All pass again.
|
||
|
||
---
|
||
|
||
## 🌐 6. Step 6 — Try it live
|
||
|
||
Add to your `index.html`:
|
||
|
||
```html
|
||
<hello-dashboard></hello-dashboard>
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
* Click “Add Greeting” → new greeters appear instantly.
|
||
* Type into any greeter → all components referencing that name update automatically.
|
||
|
||
Congratulations — you’ve just implemented **a reactive state system** entirely in vanilla JS + Lit!
|
||
|
||
---
|
||
|
||
## 📘 7. Key Concepts
|
||
|
||
| Concept | Description |
|
||
| -------------------------- | ----------------------------------------------------- |
|
||
| **Centralized state** | One store manages the data for all components |
|
||
| **Observer pattern** | Components subscribe to updates, re-render on change |
|
||
| **Reactive sync** | Changes propagate automatically without prop drilling |
|
||
| **TDD-first store design** | Ensures predictable, testable state behavior |
|
||
| **Agent-friendliness** | Clear separation of state, presentation, and tests |
|
||
|
||
---
|
||
|
||
## ✅ 8. Summary
|
||
|
||
At this point, your **TestDrive-UI** ecosystem includes:
|
||
|
||
| Layer | Responsibility |
|
||
| ----------------- | ---------------------------- |
|
||
| `hello-world` | Simple reactive component |
|
||
| `hello-dashboard` | Container and coordinator |
|
||
| `hello-store` | Shared, observable state |
|
||
| Tests | Guard behavior at each level |
|
||
|
||
---
|
||
|
||
## 🔮 9. Next Tutorial (optional direction)
|
||
|
||
You can now evolve in one of two directions:
|
||
|
||
1. **Tutorial 7 — Persistence**
|
||
→ Save and restore store state via `localStorage` or IndexedDB (so greetings persist).
|
||
|
||
2. **Tutorial 8 — Agent Automation**
|
||
→ Introduce agent-driven TDD loops: use LLMs to generate new tests, detect regressions, and propose refactorings automatically.
|
||
|
||
---
|
||
|
||
Continue with **Tutorial 7: State Persistence** next — or move directly into **agent-driven TDD automation**.
|
||
|
||
xxx
|