diff --git a/README.md b/README.md index 062e549..4b5f7ff 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,25 @@ -# Lit + Zustand Architecture +# zulip -A simple (but modern & scalable) 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\`
...
\`` +A modern, no-build web application using Lit web components with Zustand state management. ## 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 +* **StoreController** - Reactive controller that bridges Zustand and Lit - -## Layout +## Project Structure ``` -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 - - - - - - +} ``` -## Benefits Over React/TSX +## Benefits Over TSX -✅ **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 +1. **No build step** - Edit and refresh, instant feedback -## See Also +1. **Smaller bundle** - No framework runtime, just standards -- [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) +1. **Better encapsulation** - Shadow DOM, scoped styles + +1. **Framework agnostic** - Works anywhere, even in React apps + +1. **Future-proof** - Built on web standards + +1. **Familiar patterns** - Zustand works like Redux/Context + +1. **Type-safe** - Can add JSDoc or TypeScript without build step diff --git a/components/app-root.js b/components/app-root.js index 6fe53e9..b9444a8 100644 --- a/components/app-root.js +++ b/components/app-root.js @@ -6,7 +6,6 @@ import "./page-home.js"; import "./page-items.js"; class AppRoot extends LitElement { - #hydrated = new StoreController(this, store, s => s._hydrated); #user = new StoreController(this, store, s => s.user); #route = new StoreController(this, store, s => s.route); @@ -15,19 +14,9 @@ class AppRoot extends LitElement { display: block; min-height: 100vh; } - - .loading { - display: grid; - place-items: center; - height: 100vh; - opacity: 0.4; - } `; render() { - if (!this.#hydrated.value) { - return html`
loading...
`; - } return html`
${this.#renderRoute()}
diff --git a/index.html b/index.html index 51c53cd..91255c2 100644 --- a/index.html +++ b/index.html @@ -9,9 +9,7 @@ "imports": { "lit": "https://esm.sh/lit@3", "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" + "zustand/vanilla": "https://esm.sh/zustand@5/vanilla" } } diff --git a/store/idb.js b/store/idb.js deleted file mode 100644 index e276f00..0000000 --- a/store/idb.js +++ /dev/null @@ -1,33 +0,0 @@ -// 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 index 86bedc5..3328f31 100644 --- a/store/index.js +++ b/store/index.js @@ -1,42 +1,13 @@ import { createStore } from 'zustand/vanilla' -import { persist, createJSONStorage } from 'zustand/middleware' -import { get, set, del } 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((set, get) => ({ + user: null, + items: [], + route: 'home', + + // Actions + 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 }), })) - -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 deleted file mode 100644 index 75552f1..0000000 --- a/store/middleware/persistence.js +++ /dev/null @@ -1,9 +0,0 @@ -// 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 diff --git a/tasks.md b/tasks.md deleted file mode 100644 index edd6904..0000000 --- a/tasks.md +++ /dev/null @@ -1,60 +0,0 @@ -# Tasks & Issues - -## 1. Critical Issues - -- [ ] **1.1 Fix missing `del` import in store/index.js** - - Line 3 imports `get, set` from idb-keyval but `del` is used on line 13 - - Add `del` to the import statement - - This will cause a ReferenceError when removeItem is called - -- [ ] **1.2 Fix hydration flag initialization** - - The `onRehydrateStorage` callback uses `store.setState()` before `store` is fully initialized - - Creates a circular reference issue preventing app from loading - - **Options:** - - A) Initialize `_hydrated: true` by default (simplest) - - B) Use Zustand's persist middleware built-in hydration detection - - C) Move hydration callback outside store creation - -- [ ] **1.3 Consolidate duplicate storage implementation** - - Storage is defined in both `store/index.js` AND `store/middleware/persistence.js` - - The middleware file exports `idbStorage` but it's not being used - - **Decision needed:** Use one or the other, not both - -## 2. Architectural Improvements - -- [ ] **2.1 Add error handling** - - Add try/catch blocks around IndexedDB operations - - Implement fallback if IndexedDB fails or is unavailable - - Add user feedback for errors (toast notifications?) - -- [ ] **2.2 Add partialize option to persist middleware** - - Currently saves ALL state including `_hydrated` flag and `route` - - Should use `partialize: (state) => ({ user: state.user, items: state.items })` - - Prevents unnecessary data in IndexedDB - -- [ ] **2.3 Add basic CSS reset to index.html** - - No base styles, margins, or font settings currently - - Consider adding minimal reset or normalize.css - -- [ ] **2.4 Add loading states for async operations** - - No loading indicators for add/remove item operations - - Consider adding optimistic updates - -## 3. Nice to Have - -- [ ] **3.1 Add TypeScript types** (optional) - - JSDoc comments for better IDE support - - Or migrate to .ts files with no-build setup - -- [ ] **3.2 Add cross-tab synchronization** - - README mentions optional `sync.js` middleware - - Implement BroadcastChannel for cross-tab state sync - -- [ ] **3.3 Add route history management** - - Integrate with browser history API - - Support back/forward navigation - -- [ ] **3.4 Add unit tests** - - Test store actions and selectors - - Test component rendering - - Test persistence layer