diff --git a/README.md b/README.md index d2e4df2..7f53eba 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,168 @@ -# zulip +# Lit + Zustand Architecture -A minimal, no-build web app kit using only Lit-based web components via importmaps. +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 architecture maps to familiar concepts: + +| React/TSX Pattern | This Architecture | +|-------------------|-------------------| +| `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 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. **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 +* **Zustand state management** - Lightweight, vanilla JS store +* **IndexedDB persistence** - Automatic state persistence with idb-keyval + + +## Layout + +``` +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 + + + + + + ``` -### 3. Define Data Flows +## Benefits Over React/TSX -#### Parent-to-Child (Props) +✅ **No build step** - Edit and refresh, instant feedback +✅ **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 -```javascript -// Parent passes data down -html`` +## See Also -// Child receives via properties -static properties = { - items: { type: Array } -} -``` - -#### Child-to-Parent (Events) - -```javascript -// Child emits event -this.dispatchEvent(new CustomEvent('add-item', { - detail: { item: newItem }, - bubbles: true, - composed: true -})) - -// Parent listens -html` this.handleAdd(e.detail.item)}>` -``` - -## Benefits Over TSX - -1. **No build step** - Edit and refresh, instant feedback - -1. **Minimal dependencies** - Just Lit, nothing else - -1. **Smaller bundle** - No framework runtime, just standards - -1. **Better encapsulation** - Shadow DOM, scoped styles - -1. **Framework agnostic** - Works anywhere, even in React apps - -1. **Future-proof** - Built on web standards - -1. **Easy to learn** - Simple mental model for state, with natural evolution into Zustand/IndexedDB or similar extensions +- [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) 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