diff --git a/README.md b/README.md index fb3507d..7f53eba 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,168 @@ -# Lit-Only Architecture (No Build) +# Lit + Zustand Architecture -A minimal, no-build web application using only Lit web components with local state management. - -> **Note:** This is the `lit-only` branch. For the full-featured version with Zustand state management and IndexedDB persistence, see the `main` branch. +A modern, no-build web application architecture using Lit web components with Zustand state management and IndexedDB persistence. ## For TSX/React Developers -If you're coming from React/TSX, here's how this minimal architecture maps to familiar concepts: +If you're coming from React/TSX, here's how this architecture maps to familiar concepts: | React/TSX Pattern | This Architecture | |-------------------|-------------------| -| `useState` | Lit's `static properties` with `state: true` | -| Props drilling | Property passing via `.prop=${value}` | -| Custom events | `CustomEvent` with `bubbles: true` | +| `useState` / `useReducer` | Zustand vanilla store | +| `useEffect` + state subscription | `StoreController` (ReactiveController) | +| Context API | Global Zustand store | +| `localStorage` / `sessionStorage` | IndexedDB via persist middleware | | JSX | Lit's `html` tagged template literals | | CSS-in-JS / CSS Modules | Lit's `css` tagged template literals | -| React Router | Simple route state in root component | +| React Router | Simple route state in store | | Build step (Vite/Webpack) | Import maps (no build!) | ### Key Differences 1. **No Virtual DOM**: Lit uses native Web Components with efficient DOM updates 2. **No Build Step**: Import maps let you use npm packages directly from CDN -3. **Local State Only**: Each component manages its own state, parent manages shared state -4. **Event-Based Communication**: Child components emit events, parent handles them -5. **Tagged Templates**: Instead of JSX, use `html\`
...
\`` +3. **Reactive Controllers**: Replace hooks - attach to component lifecycle +4. **Tagged Templates**: Instead of JSX, use `html\`
...
\`` ## Architecture Overview * **Lit-based web components** - Standards-based, framework-agnostic UI * **No-build tooling** - Import maps only, no bundler required -* **Local state management** - Component properties and state -* **Event-driven** - CustomEvents for child-to-parent communication +* **Zustand state management** - Lightweight, vanilla JS store +* **IndexedDB persistence** - Automatic state persistence with idb-keyval -## Project Structure + +## Layout ``` -components/ - app-root.js ← Root component with shared state - nav-bar.js ← Navigation component - page-home.js ← Home page component - page-items.js ← Items page component -index.html ← Entry point with inline importmap -README.md -tasks.md +src/ + store/ + index.js ← zustand store definition + middleware/ + persistence.js ← idb-keyval persist adapter + sync.js ← optional cross-tab broadcast + components/ + app-root.js + feature-a/ + feature-a.js ← Lit component + feature-a.css ← adopted stylesheet or constructable + controllers/ + fetch.js ← ReactiveController for API calls + 3d.js ← optional Three/WebGPU controller + lib/ + idb.js ← thin idb-keyval wrapper/schema +index.html +importmap.json ← extracted importmap (referenced via @@ -149,88 +206,17 @@ customElements.define('my-counter', Counter) ``` -## Component Communication Patterns - -### Pattern 1: Shared State in Root - -```javascript -class AppRoot extends LitElement { - static properties = { - items: { type: Array, state: true } - } - - constructor() { - super() - this.items = [] - } - - addItem(item) { - this.items = [...this.items, item] - } - - render() { - return html` - this.addItem(e.detail.item)} - > - ` - } -} -``` - -### Pattern 2: Local State in Component - -```javascript -class MyComponent extends LitElement { - static properties = { - _draft: { type: String, state: true } - } - - constructor() { - super() - this._draft = '' - } - - render() { - return html` - this._draft = e.target.value} - /> - ` - } -} -``` - ## Benefits Over React/TSX ✅ **No build step** - Edit and refresh, instant feedback -✅ **Minimal dependencies** - Just Lit, nothing else ✅ **Smaller bundle** - No framework runtime, just standards ✅ **Better encapsulation** - Shadow DOM, scoped styles ✅ **Framework agnostic** - Works anywhere, even in React apps ✅ **Future-proof** - Built on web standards -✅ **Easy to learn** - Simple mental model, no complex state management - -## When to Use This vs. Full Stack - -### Use Lit-Only When: -- Building small to medium apps -- Don't need persistence -- State is simple and hierarchical -- Want maximum simplicity - -### Use Full Stack (main branch) When: -- Need IndexedDB persistence -- Complex state shared across many components -- Need cross-tab synchronization -- Want time-travel debugging -- Building larger applications ## See Also - [tasks.md](./tasks.md) - Current issues and improvements - [Lit Documentation](https://lit.dev) +- [Zustand Documentation](https://zustand.docs.pmnd.rs) - [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) -- [Import Maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) diff --git a/components/app-root.js b/components/app-root.js index 88f59a6..6fe53e9 100644 --- a/components/app-root.js +++ b/components/app-root.js @@ -1,64 +1,44 @@ import { LitElement, html, css } from "lit"; +import { store} from "../store/index.js"; +import { StoreController } from "../controllers/store.js"; import "./nav-bar.js"; import "./page-home.js"; import "./page-items.js"; class AppRoot extends LitElement { - static properties = { - user: { type: Object, state: true }, - route: { type: String, state: true }, - items: { type: Array, state: true } - }; - - constructor() { - super(); - this.user = null; - this.route = 'home'; - this.items = []; - } + #hydrated = new StoreController(this, store, s => s._hydrated); + #user = new StoreController(this, store, s => s.user); + #route = new StoreController(this, store, s => s.route); static styles = css` :host { display: block; min-height: 100vh; } + + .loading { + display: grid; + place-items: center; + height: 100vh; + opacity: 0.4; + } `; - #navigate(route) { - this.route = route; - } - - #addItem(item) { - this.items = [...this.items, item]; - } - - #removeItem(id) { - this.items = this.items.filter(i => i.id !== id); - } - render() { + if (!this.#hydrated.value) { + return html`
loading...
`; + } return html` - this.#navigate(e.detail.route)} - > +
${this.#renderRoute()}
`; } #renderRoute() { - switch (this.route) { - case "home": - return html``; - case "items": - return html` this.#addItem(e.detail.item)} - @remove-item=${(e) => this.#removeItem(e.detail.id)} - >`; - default: - return html``; + switch (this.#route.value) { + case "home": return html``; + case "items": return html``; + default: return html``; } } } diff --git a/components/nav-bar.js b/components/nav-bar.js index 6f192a1..1b6e241 100644 --- a/components/nav-bar.js +++ b/components/nav-bar.js @@ -1,10 +1,13 @@ // components/nav-bar.js import { LitElement, html, css } from 'lit' +import { store } from '../store/index.js' +import { StoreController } from '../controllers/store.js' class NavBar extends LitElement { + #route = new StoreController(this, store, s => s.route) + static properties = { - user: { type: Object }, - route: { type: String } + user: { type: Object } } static styles = css` @@ -32,18 +35,14 @@ class NavBar extends LitElement { #navigate(route, e) { e.preventDefault() - this.dispatchEvent(new CustomEvent('navigate', { - detail: { route }, - bubbles: true, - composed: true - })) + store.getState().navigate(route) } #link(route, label) { return html` this.#navigate(route, e)} >${label} ` @@ -62,4 +61,4 @@ class NavBar extends LitElement { } } -customElements.define('nav-bar', NavBar) +customElements.define('nav-bar', NavBar) \ No newline at end of file diff --git a/components/page-home.js b/components/page-home.js index 3d5c616..5d71b8a 100644 --- a/components/page-home.js +++ b/components/page-home.js @@ -1,11 +1,11 @@ // components/page-home.js import { LitElement, html, css } from 'lit' +import { store } from '../store/index.js' +import { StoreController } from '../controllers/store.js' class PageHome extends LitElement { - static properties = { - user: { type: Object }, - items: { type: Array } - } + #user = new StoreController(this, store, s => s.user) + #items = new StoreController(this, store, s => s.items) static styles = css` :host { display: block; padding: 2rem 1.5rem; } @@ -25,8 +25,8 @@ class PageHome extends LitElement { ` render() { - const name = this.user?.name ?? 'there' - const count = this.items?.length ?? 0 + const name = this.#user.value?.name ?? 'there' + const count = this.#items.value.length return html`

Hey, ${name}

@@ -41,4 +41,4 @@ class PageHome extends LitElement { } } -customElements.define('page-home', PageHome) +customElements.define('page-home', PageHome) \ No newline at end of file diff --git a/components/page-items.js b/components/page-items.js index f788396..efdcf96 100644 --- a/components/page-items.js +++ b/components/page-items.js @@ -1,17 +1,16 @@ // components/page-items.js import { LitElement, html, css } from 'lit' +import { store } from '../store/index.js' +import { StoreController } from '../controllers/store.js' class PageItems extends LitElement { + #items = new StoreController(this, store, s => s.items) + static properties = { - items: { type: Array }, _draft: { type: String, state: true } } - constructor() { - super(); - this.items = []; - this._draft = ''; - } + _draft = '' static styles = css` :host { display: block; padding: 2rem 1.5rem; } @@ -64,22 +63,12 @@ class PageItems extends LitElement { #add() { const name = this._draft.trim() if (!name) return - - this.dispatchEvent(new CustomEvent('add-item', { - detail: { item: { id: crypto.randomUUID(), name } }, - bubbles: true, - composed: true - })) - + store.getState().addItem({ id: crypto.randomUUID(), name }) this._draft = '' } #remove(id) { - this.dispatchEvent(new CustomEvent('remove-item', { - detail: { id }, - bubbles: true, - composed: true - })) + store.getState().removeItem(id) } #onKeydown(e) { @@ -87,7 +76,7 @@ class PageItems extends LitElement { } render() { - const items = this.items ?? [] + const items = this.#items.value return html`

Items

@@ -119,4 +108,4 @@ class PageItems extends LitElement { } } -customElements.define('page-items', PageItems) +customElements.define('page-items', PageItems) \ No newline at end of file diff --git a/controllers/store.js b/controllers/store.js new file mode 100644 index 0000000..998294d --- /dev/null +++ b/controllers/store.js @@ -0,0 +1,23 @@ +export class StoreController { + constructor(host, store, selector) { + this.host = host + this.store = store + this.selector = selector + host.addController(this) + } + + hostConnected() { + this._unsub = this.store.subscribe( + (state) => { + const next = this.selector(state) + if (next !== this.value) { + this.value = next + this.host.requestUpdate() + } + } + ) + this.value = this.selector(this.store.getState()) + } + + hostDisconnected() { this._unsub?.() } + } \ No newline at end of file diff --git a/index.html b/index.html index 879175b..51c53cd 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,10 @@ { "imports": { "lit": "https://esm.sh/lit@3", - "lit/decorators.js": "https://esm.sh/lit@3/decorators.js" + "lit/decorators.js": "https://esm.sh/lit@3/decorators.js", + "zustand/vanilla": "https://esm.sh/zustand@5/vanilla", + "zustand/middleware": "https://esm.sh/zustand@5/middleware", + "idb-keyval": "https://esm.sh/idb-keyval@6" } } diff --git a/store/idb.js b/store/idb.js new file mode 100644 index 0000000..e276f00 --- /dev/null +++ b/store/idb.js @@ -0,0 +1,33 @@ +// lib/idb.js +import { createStore, get, set, del, entries, clear } from 'idb-keyval' + +// Named stores — each maps to a distinct IDBObjectStore +export const itemsStore = createStore('app-db', 'items') +export const userStore = createStore('app-db', 'user') +export const cacheStore = createStore('app-db', 'cache') + +// Typed wrappers — keeps raw idb-keyval calls out of the rest of the app +// and gives you one place to add validation, logging, or migration logic + +export const db = { + items: { + getAll: () => entries(itemsStore), + get: (id) => get(id, itemsStore), + set: (id, value) => set(id, value, itemsStore), + remove: (id) => del(id, itemsStore), + clear: () => clear(itemsStore), + }, + + user: { + get: () => get('user', userStore), + set: (value) => set('user', value, userStore), + clear: () => del('user', userStore), + }, + + cache: { + get: (key) => get(key, cacheStore), + set: (key, value) => set(key, value, cacheStore), + remove: (key) => del(key, cacheStore), + clear: () => clear(cacheStore), + } +} \ No newline at end of file diff --git a/store/index.js b/store/index.js new file mode 100644 index 0000000..5fdf883 --- /dev/null +++ b/store/index.js @@ -0,0 +1,42 @@ +import { createStore } from 'zustand/vanilla' +import { persist, createJSONStorage } from 'zustand/middleware' +import { get, set } from 'idb-keyval' + +const storage = createJSONStorage(() => ({ + getItem: async (name) => { + const value = await get(name) + return value ?? null + }, + setItem: async (name, value) => { + await set(name, value) + }, + removeItem: async (name) => { + await del(name) + }, +})) + +export const store = createStore( + persist( + (set, get) => ({ + _hydrated: false, + user: null, + items: [], + route: 'home', + setUser: (user) => set({ user }), + addItem: (item) => set(s => ({ items: [...s.items, item] })), + removeItem: (id) => set(s => ({ items: s.items.filter(i => i.id !== id) })), + navigate: (route) => set({ route }), + }), + { + name: 'app-store', + storage, + onRehydrateStorage: () => { + return (state, error) => { + if (!error) { + store.setState({ _hydrated: true }) + } + } + }, + } + ) +) diff --git a/store/middleware/persistence.js b/store/middleware/persistence.js new file mode 100644 index 0000000..75552f1 --- /dev/null +++ b/store/middleware/persistence.js @@ -0,0 +1,9 @@ +// store/middleware/persistence.js +import { db } from '../idb.js' + +export const makeIdbStorage = (storeName) => + createJSONStorage(() => ({ + getItem: (name) => db[storeName].get(name), + setItem: (name, value) => db[storeName].set(name, value), + removeItem: (name) => db[storeName].remove(name), + })) \ No newline at end of file