@nativele/ts-dom 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,10 @@
2
2
  name: nativele-integration
3
3
  description: "Use Nativele web components inside an app built with a UI framework that has no Nativele Skill of its own (Preact, Solid, Lit, ...; React, Vue, Svelte and Angular have their own): defining before render, props as properties, listening to events, children and slots, lifecycle, and template types. Use when a project depends on @nativele/ts-dom and on such a framework."
4
4
  metadata:
5
- tier: experimental
5
+ tier: official
6
6
  nativele: ">=0.5.1 <0.6.0"
7
7
  verified: "2026-11-05"
8
- tools: "vue@3.5.44 vite@8.3.6 @vitejs/plugin-vue@6.0.10 vue-tsc@3.3.14 typescript@6.0.3"
8
+ tools: "preact@11.1.1 vite@8.3.6 @preact/preset-vite@2.10.6 typescript@7.0.2"
9
9
  author: "Nativele maintainers"
10
10
  detects: "preact solid-js"
11
11
  ---
@@ -16,7 +16,7 @@ metadata:
16
16
 
17
17
  - The app is built with a UI framework and wants to use components written with Nativele (`@nativele/ts-dom`), and no Skill exists for that framework yet. React, Vue, Svelte and Angular have their own (`nativele-react`, `nativele-vue`, `nativele-svelte`, `nativele-angular`); `npx ts-dom skills` lists every Skill.
18
18
  - It is a procedure for finding out how the framework treats custom elements and connecting it correctly. It does not cover setting the framework itself up: start from the app as it is, with its own type checker and build.
19
- - A framework with its own file format type-checks it with its own tool (Vue: `vue-tsc`). `ts-dom check` runs `vue-tsc` instead of `tsc` when the project's `package.json` lists it; for other formats see Troubleshooting.
19
+ - A framework with its own file format type-checks it with its own tool (Vue: `vue-tsc`). `ts-dom check` runs `vue-tsc` instead of `tsc` when the project's `package.json` lists it; for other formats see Troubleshooting. A framework that uses TSX (Preact, Solid) is checked by plain `tsc`, and `ts-dom check` reports its errors too.
20
20
  - Not for writing the Nativele components themselves: use `nativele-core` (`npx ts-dom skills add nativele-core`).
21
21
 
22
22
  Nativele components are native custom elements. The framework renders their tags like any other tag; nothing wraps them. Every rule below follows from that.
@@ -26,18 +26,17 @@ Nativele components are native custom elements. The framework renders their tags
26
26
  | Tool | Verified version |
27
27
  |---|---|
28
28
  | @nativele/ts-dom | 0.5.1 |
29
- | vue | 3.5.44 |
29
+ | preact | 11.1.1 |
30
30
  | vite | 8.3.6 |
31
- | @vitejs/plugin-vue | 6.0.10 |
32
- | vue-tsc | 3.3.14 |
33
- | typescript | 6.0.3 |
31
+ | @preact/preset-vite | 2.10.6 |
32
+ | typescript | 7.0.2 |
34
33
 
35
- Verified on 2026-11-05 by an agent that followed this Skill in a fresh Vue app made with `create-vite` (`vue-ts`), and checked in Google Chrome. The rules come from the custom element standard and hold for any framework, but each framework's own behavior (step 3) is verified only for the frameworks named here and in `nativele-react`.
34
+ Verified on 2026-11-05 in Google Chrome 148, starting from `create-vite` 9.3.4 with the `preact-ts` template, with the example project in the Nativele repository (`skills/nativele-integration/example`), which CI runs again when the example or the library changes and once a month. The procedure was also followed earlier in a Vue app made with the `vue-ts` template. The rules come from the custom element standard and hold for any framework, but each framework's own behavior (the answers to step 3) is verified only for Preact 11 here and for the frameworks that have their own Skill. With Preact 10, treat the Preact answers below as unverified.
36
35
 
37
36
  ## Set-up
38
37
 
39
38
  1. Keep the Nativele components in their own folder, for example `src/nv/`, each as `x.html` plus `x.ts`, written with the core Skill.
40
- 2. Connect the build: with Vite, add `tsDom({ dirs: ["src/nv"] })` from `@nativele/ts-dom/vite` to `plugins`, and pass the same folder to every `ts-dom` command (`npx ts-dom check src/nv`). In the tsconfig whose `include` covers that folder, set `"strict": true`. If the project lints with oxlint, add `{ "name": "ts-dom", "specifier": "@nativele/ts-dom/oxlint" }` to `jsPlugins` and `"ts-dom/unused-markup": "error"` to `rules`. Add `*.html.ts` and `ts-dom-tags.ts` to `.gitignore`. Run `npx ts-dom doctor src/nv` and fix what it reports.
39
+ 2. Connect the build: with Vite, add `tsDom({ dirs: ["src/nv"] })` from `@nativele/ts-dom/vite` to `plugins`, and pass the same folder to every `ts-dom` command (`npx ts-dom check src/nv`). In the tsconfig whose `include` covers that folder, set `"strict": true` (in Vite's templates that is `tsconfig.app.json`; some starters, such as `preact-ts`, leave it off). If the project lints with oxlint, add `{ "name": "ts-dom", "specifier": "@nativele/ts-dom/oxlint" }` to `jsPlugins` and `"ts-dom/unused-markup": "error"` to `rules`. Add `*.html.ts` and `ts-dom-tags.ts` to `.gitignore`. Run `npx ts-dom doctor src/nv` and fix what it reports.
41
40
  3. Find out, from the framework's documentation on custom elements (often called "web components"), these four things before writing any template:
42
41
 
43
42
  | Question | Why it matters |
@@ -50,14 +49,15 @@ Verified on 2026-11-05 by an agent that followed this Skill in a fresh Vue app m
50
49
  4. Import every Nativele component module in the app's entry file, before the framework mounts anything:
51
50
 
52
51
  ```ts
53
- // src/main.ts
52
+ // src/main.tsx
54
53
  import "./nv/info-card.js";
55
54
  // then create and mount the app
56
55
  ```
57
56
 
58
57
  Many frameworks decide between property and attribute by checking whether the element already has the property, which is only true after `define()` has run.
59
58
 
60
- 5. Give the framework's template type checker the Nativele tags. `ts-dom gen` writes `ts-dom-tags.ts`, which adds every exported component to `HTMLElementTagNameMap`; a component's props are the keys of its `props` member, and its events are in the optional `"~emits"` member (event name to `detail` type). Build the framework's tag declaration from these, so new components need no edit; `nativele-react` has the TSX one and `nativele-vue` the Vue one. Check how the framework names an event listener in its types: Vue types `@user-select` as the prop `onUserSelect`. If the framework has no typed templates, skip this step.
59
+ 5. Give the framework's template type checker the Nativele tags. `ts-dom gen` writes `ts-dom-tags.ts`, which adds every exported component to `HTMLElementTagNameMap`; a component's props are the keys of its `props` member, and its events are in the optional `"~emits"` member (event name to `detail` type). Build the framework's tag declaration from these, so new components need no edit; "In Preact (verified)" below has the one for Preact's JSX, `nativele-react` the one for React and `nativele-vue` the Vue one. Check how the framework names an event listener in its types: Preact and React type `item-select` as the prop `onitem-select`, Vue types `@item-select` as `onItemSelect`. If the framework has no typed templates, skip this step.
60
+ 6. The generated files are not committed, so on a clean checkout a build script that runs the type checker before the bundler (the `preact-ts` template's `"build": "tsc -b && vite build"`) fails with `TS2307: Cannot find module './x.html.js'`. Put `ts-dom gen src/nv &&` in front: `"build": "ts-dom gen src/nv && tsc -b && vite build"`. The dev server needs no change: the Vite plugin generates the files when it starts.
61
61
 
62
62
  ## Example
63
63
 
@@ -77,18 +77,74 @@ card.item = { id: 2, name: "Grace" }; // a new object re-renders; ch
77
77
  card.remove(); // disconnected() runs, bind() subscriptions end
78
78
  ```
79
79
 
80
- In a framework, the same calls come from its template syntax. `nativele-vue` (`npx ts-dom skills nativele-vue`) is a complete worked example: its compiler option, property binding, event listener and template type declaration answer the four questions of step 3 for Vue.
80
+ In a framework, the same calls come from its template syntax. Preact below is the worked example for a TSX framework; `nativele-vue` (`npx ts-dom skills nativele-vue`) is a complete one for a framework with its own template compiler. Both answer the four questions of step 3.
81
+
82
+ ### In Preact (verified)
83
+
84
+ | Question | Preact 11 |
85
+ |---|---|
86
+ | Compiler option for custom elements | none: a lowercase JSX tag is always a DOM element |
87
+ | Property or attribute | a property when the element already has one of that name (`name in el`), otherwise an attribute; there is no syntax to force a property. Define before render (step 4); for a module loaded later, set object props from a `ref` (limitation 1) |
88
+ | Event listener | `on` plus the exact event name: `onitem-select={(e) => ...}`. Preact lowercases the whole name when the letter after `on` is uppercase, so `onItemSelect` listens to `itemselect` and never runs |
89
+ | Framework-managed children | yes: Preact inserts, removes and reorders them (limitation 3); keyed lists move the same element, with `moveBefore` where the browser has it (limitation 5) |
90
+
91
+ The JSX declaration, as `src/nativele-preact.d.ts` (the template's `tsconfig.app.json` includes `src`):
92
+
93
+ ```ts
94
+ // src/nativele-preact.d.ts
95
+ import type { HTMLAttributes } from "preact";
96
+
97
+ type NativeleTag = Extract<keyof HTMLElementTagNameMap, `${string}-${string}`>;
98
+ type NativeleProps<E> = E extends { readonly props: infer P } ? { [K in keyof P & keyof E]?: E[K] } : unknown;
99
+ type NativeleEvents<E> = E extends { readonly "~emits"?: infer M }
100
+ ? { [K in keyof NonNullable<M> & string as `on${K}`]?: (event: CustomEvent<NonNullable<M>[K]>) => void }
101
+ : unknown;
102
+ type NativeleElementProps<E extends HTMLElement> = Omit<HTMLAttributes<E>, keyof NativeleProps<E>> & NativeleProps<E> & NativeleEvents<E>;
103
+ type NativeleIntrinsics = { [K in NativeleTag]: NativeleElementProps<HTMLElementTagNameMap[K]> };
104
+
105
+ declare module "preact" {
106
+ namespace JSX {
107
+ interface IntrinsicElements extends NativeleIntrinsics {}
108
+ }
109
+ }
110
+ ```
111
+
112
+ Used from a Preact component, with the `nv-info-card` and `nv-text-field` components of `nativele-vue`'s example (props `label`, `count`, `item`, event `item-select`; prop `value`, event `value-change`):
113
+
114
+ ```tsx
115
+ import { useState } from "preact/hooks";
116
+ import type { Item } from "./nv/info-card.js";
117
+
118
+ export function App() {
119
+ const [item] = useState<Item>({ id: 1, name: "apple" });
120
+ const [picked, setPicked] = useState<number[]>([]);
121
+ const [text, setText] = useState("");
122
+ return (
123
+ <>
124
+ <nv-info-card label="Fruit" count={picked.length} item={item} onitem-select={(e) => setPicked((p) => [...p, e.detail.id])} />
125
+ <nv-text-field value={text} onvalue-change={(e) => setText(e.detail.value)} />
126
+ <nv-light-card>
127
+ <div slot="title">{item && <span>{item.name}</span>}</div>
128
+ <div>{picked.map((id, i) => <p key={i}>{id}</p>)}</div>
129
+ </nv-light-card>
130
+ </>
131
+ );
132
+ }
133
+ ```
134
+
135
+ A template ref is typed from the same map: `useRef<HTMLElementTagNameMap["nv-info-card"]>(null)`.
81
136
 
82
137
  ## Known limitations
83
138
 
84
- 1. **Define before render.** Import the component modules before the framework mounts (step 4). A framework that sees an undefined element may write an attribute instead of a property, and an object then arrives as the text `"[object Object]"`. A property set before `define()` is kept: Nativele takes it over when the element is upgraded.
139
+ 1. **Define before render.** Import the component modules before the framework mounts (step 4). A framework that sees an undefined element may write an attribute instead of a property: in Preact, `item={item}` becomes the attribute `item="[object Object]"`, the component gets `null` after `define()`, and the attribute stays even after a later new object is set as a property. A property set before `define()` is kept: Nativele takes it over when the element is upgraded. So when a module really is loaded later, set object props through the framework's property syntax, or, where it has none (Preact), from a ref: `ref={(el) => { if (el) el.item = item; }}`, and leave `item={item}` out of that tag, or Preact also writes the attribute.
85
140
  2. **Props.** A prop whose default is a string, number or boolean can also come from an attribute, with camelCase names in kebab-case (`maxLength` from `max-length`, a boolean `true` when the attribute is present). Arrays and objects can only be set as properties. Pass a new object when data changes: props are compared by reference.
86
- 3. **Light DOM slots and framework-managed children do not mix.** In the default Light DOM mode (`Component(tpl)` without `shadow`), Nativele moves the tag's children into its slot positions; a framework that later inserts, removes or reorders those children can fail or leave nodes outside the slot. Use one of:
141
+ 3. **Light DOM slots and framework-managed children do not mix.** In the default Light DOM mode (`Component(tpl)` without `shadow`), Nativele moves the tag's children into its slot positions; a framework that later inserts, removes or reorders those children can fail or leave nodes outside the slot. In Preact, toggling or reordering such children throws `NotFoundError: Failed to execute 'moveBefore' on 'Element'` (or `'insertBefore' on 'Node'`) and the slot loses its children. Use one of:
87
142
  - `shadow: true` on the component, so the browser assigns slots natively and the children can change freely;
88
143
  - one stable element per slot that the framework never removes, with the changing content inside it (`<div slot="title">...</div>`, `<div>...</div>` for the default slot). A stable but empty wrapper still fills its slot, so the slot's fallback content does not show; when the fallback matters, use `shadow: true`.
89
144
  4. **Events do not bubble.** `emit` dispatches a `CustomEvent` on the component that does not bubble, so the listener must be on that element, not on an ancestor. The event data is `event.detail`.
90
- 5. **Lifecycle.** `connected()` runs each time the element is inserted and `disconnected()` each time it is removed; a framework that moves an element (reordering a keyed list) runs both. `bind` subscriptions end on removal and start again on insertion, and the children already in a Light DOM component's slots move with it.
91
- 6. **The framework's templates are checked by the framework's type checker**, with the declaration from step 5, not by the Nativele rules. How much it checks depends on the framework: `vue-tsc` reports a wrong prop type or event detail, but not an unknown prop name.
145
+ 5. **Lifecycle.** `connected()` runs each time the element is inserted and `disconnected()` each time it is removed, and `bind` subscriptions end on removal and start again on insertion. A framework that moves an element (reordering a keyed list) with `insertBefore` runs both; one that moves it with `moveBefore` keeps it connected, so neither runs and its subscriptions continue. Preact 11 uses `moveBefore` where the browser has it (Chrome does) and `insertBefore` elsewhere. Either way the moved element keeps its props and the children in its Light DOM slots. So subscribe in `connected()`, and do not rely on a move running it.
146
+ 6. **The framework's templates are checked by the framework's type checker**, with the declaration from step 5, not by the Nativele rules. How much it checks depends on the framework: with the Preact declaration, `tsc` reports a wrong prop type, a wrong object shape, a wrong use of `e.detail`, an unknown prop name, an event prop in the wrong case (`onItemSelect`) and an unknown tag, but not a hyphenated attribute name (`selected-item`), which TypeScript never checks in JSX; `vue-tsc` reports a wrong prop type or event detail, but not an unknown prop name.
147
+ 7. Not verified with Preact: server-side rendering and hydration, `preact/compat`, signals passed as props, and Preact 10.
92
148
 
93
149
  ## Verify
94
150
 
@@ -97,7 +153,16 @@ npx ts-dom check src/nv # expect: ts-dom check: no issues
97
153
  npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
98
154
  ```
99
155
 
100
- Then run the app's own type check and build (for example `npm run build` when it runs the framework's type checker). Keep one wrong prop per component kind that has props in a file the app never imports, under the framework's expect-error comment (a template comment such as `<!-- @ts-expect-error -->` is often not honored; check that a wrong prop fails without it), so the declaration is proved to type props.
156
+ Then run the app's own type check and build (for example `npm run build` when it runs the framework's type checker). Keep one wrong prop per component kind that has props in a file the app never imports, under the framework's expect-error comment, so the declaration is proved to type props. In TSX, `{/* @ts-expect-error */}` on the line before the tag works:
157
+
158
+ ```tsx
159
+ {/* @ts-expect-error count is a number */}
160
+ <nv-info-card count="1" />
161
+ {/* @ts-expect-error detail is { id: number } */}
162
+ <nv-info-card onitem-select={(e) => e.detail.name} />
163
+ ```
164
+
165
+ The type check passes only while each of these is an error; if the declaration stops working, it reports `TS2578: Unused '@ts-expect-error' directive`. A template comment such as `<!-- @ts-expect-error -->` is often not honored; check that a wrong prop fails without it.
101
166
 
102
167
  In the browser, render a component with an object prop and an event listener, and check:
103
168
 
@@ -105,21 +170,26 @@ In the browser, render a component with an object prop and an event listener, an
105
170
  - the listener runs once per event;
106
171
  - children that the framework toggles, reorders, adds and removes stay in the right slot, with no errors or framework warnings in the console (run this check once against the dev server too: production builds drop framework warnings). Read a slot with `el.assignedNodes(name)` (`""` is the default slot), which works in both modes; a Light DOM component has no `<slot>` elements once rendered. Frameworks leave comment and empty text nodes as anchors among the children, so compare elements, not all nodes.
107
172
 
173
+ The example project's [`verify.mjs`](https://github.com/Pixmi/nativele/blob/main/skills/nativele-integration/example/verify.mjs) does these checks for Preact with Playwright, plus a component defined after render, keyed reordering of hosts with and without `moveBefore`, and the end of `bind` on removal.
174
+
108
175
  ## Troubleshooting
109
176
 
110
177
  | Symptom | Cause | Fix |
111
178
  |---|---|---|
112
- | An object prop shows `[object Object]`, or the element has an attribute holding it | the framework set an attribute: the element was not defined yet, or the binding syntax sets attributes | import the module before mounting; use the framework's property binding |
179
+ | `TS2307: Cannot find module './x.html.js'` from the build on a clean checkout | the type check runs before the generated files exist | start the build script with `ts-dom gen src/nv &&` (step 6) |
180
+ | An object prop shows `[object Object]`, or the element has an attribute holding it | the framework set an attribute: the element was not defined yet, or the binding syntax sets attributes | import the module before mounting; use the framework's property binding, or a ref (limitation 1) |
113
181
  | The framework warns that it cannot resolve the component `info-card` | its compiler treats the tag as a framework component | mark hyphenated tags as custom elements in the framework's compiler options |
114
- | The event listener never runs | the name was changed (camelCase), or the listener is on an ancestor | listen with the exact name, on the element that emits |
115
- | Children vanish, appear outside the slot, or the framework throws during an update | framework-managed children inside a Light DOM component with slots | `shadow: true`, or a stable element per slot |
182
+ | The event listener never runs | the name was changed (camelCase; Preact's `onItemSelect` listens to `itemselect`), or the listener is on an ancestor | listen with the exact name (`onitem-select`), on the element that emits |
183
+ | Children vanish, appear outside the slot, or the framework throws during an update (Preact: `NotFoundError: Failed to execute 'moveBefore'`) | framework-managed children inside a Light DOM component with slots | `shadow: true`, or a stable element per slot |
184
+ | `connected()` does not run when a keyed list reorders | the framework moved the element with `moveBefore`, so it stayed connected | nothing to fix: its subscriptions continue (limitation 5) |
116
185
  | `ts-dom check` reports `TS2307: Cannot find module` for a framework file (`./App.vue`, ...) | plain `tsc` cannot read the framework's files | Vue: keep `vue-tsc` in `package.json` (Vue's own type checker; `ts-dom check` uses it when listed). Other formats: pass `--project=` a tsconfig whose `include` covers only the Nativele folder, and type-check the rest with the framework's tool |
117
- | The framework's type checker does not know the tag | no declaration built from `ts-dom-tags.ts`, or the component class is not exported | add the declaration (step 5), export the class, run `ts-dom gen` |
186
+ | `Property 'nv-info-card' does not exist on type 'JSX.IntrinsicElements'`, or the framework's type checker does not know the tag | no declaration built from `ts-dom-tags.ts`, or the component class is not exported | add the declaration (step 5), export the class, run `ts-dom gen` |
118
187
 
119
188
  ## See also
120
189
 
121
190
  - Props, events and data flow: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-flow-props-down-events-up
122
191
  - Slots, Light DOM and Shadow DOM: https://github.com/Pixmi/nativele/blob/main/docs/components.md#light-default-and-shadow
123
192
  - Lifecycle: https://github.com/Pixmi/nativele/blob/main/docs/components.md#components
193
+ - Moved elements, connecting and subscriptions: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#moving-items-connecting-and-subscriptions
124
194
  - Setup and checks: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md
125
- - A complete framework integration: `npx ts-dom skills nativele-react`
195
+ - Complete framework integrations: `npx ts-dom skills nativele-react`, `npx ts-dom skills nativele-vue`
@@ -0,0 +1,319 @@
1
+ ---
2
+ name: nativele-state-management
3
+ description: "Decide where state lives in an app built with Nativele (component state, a shared createStore, or an existing Zustand, Redux Toolkit or nanostores store) and connect external stores to this.bind with a small adapter: selectors that limit re-rendering, actions and dispatch from event handlers, unsubscribing when a component is removed. Use when a project depends on @nativele/ts-dom and on zustand, @reduxjs/toolkit, redux or nanostores, or when components need to share data."
4
+ metadata:
5
+ tier: official
6
+ nativele: ">=0.5.2 <0.6.0"
7
+ verified: "2026-11-05"
8
+ tools: "zustand@5.0.16 @reduxjs/toolkit@2.14.0 redux@5.0.1 nanostores@1.5.5 vite@8.3.6 typescript@7.0.2 create-vite@9.3.4"
9
+ author: "Nativele maintainers"
10
+ detects: "zustand @reduxjs/toolkit redux nanostores"
11
+ ---
12
+
13
+ # Nativele state management
14
+
15
+ ## When to use
16
+
17
+ - An app that already uses Nativele (`@nativele/ts-dom`) has to decide where a piece of data lives: in one component, in a store shared by several components, or in a state library.
18
+ - The app already keeps state in Zustand, Redux Toolkit, Redux or nanostores, and Nativele components have to show it and change it.
19
+ - Signals: `zustand`, `@reduxjs/toolkit`, `redux` or `nanostores` next to `@nativele/ts-dom` in `package.json`; a value passed down through several layers of components only so a deep one can show it.
20
+ - The starting point is a Vite app set up with Nativele by `nativele-vite` (`npx ts-dom skills add nativele-vite`), for example one started with `npm create vite@latest -- --template vanilla-ts`. This Skill does not teach the state libraries themselves, and it does not write the components (`nativele-core`).
21
+ - Not for React, Vue or another framework's own bindings to these libraries (`react-redux`, Zustand's hooks): here the Nativele components subscribe themselves.
22
+
23
+ ## Versions
24
+
25
+ | Tool | Verified version |
26
+ |---|---|
27
+ | @nativele/ts-dom | 0.5.2 |
28
+ | zustand (`zustand/vanilla`) | 5.0.16 |
29
+ | @reduxjs/toolkit | 2.14.0 |
30
+ | redux | 5.0.1 (installed by `@reduxjs/toolkit`; a `legacy_createStore` store with the adapter was checked by hand with `tsc` and Node, the example project does not run it) |
31
+ | nanostores | 1.5.5 (`atom` only) |
32
+ | vite | 8.3.6 |
33
+ | typescript | 7.0.2 |
34
+ | create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
35
+
36
+ Verified on 2026-11-05 in Google Chrome 148 with the example project in the Nativele repository (`skills/nativele-state-management/example`), which CI runs again when the example or the library changes and once a month. With a different major version of a state library, treat the adapter and every limitation about that library as unverified.
37
+
38
+ ## Set-up
39
+
40
+ 1. Decide where each piece of data lives:
41
+
42
+ | The data is | Put it in | Why |
43
+ |---|---|---|
44
+ | used by one component, or by it and the children it passes props to (a field's text, open or closed, the selected row) | the component's `state` | it is changed with `this.set` and rendered by the component that owns it |
45
+ | read or changed by components that are not parent and child, or would be passed through layers that do not use it (the signed-in user, a theme) | a `createStore` from `@nativele/ts-dom` in its own module | every component that needs it imports it and binds it; the layers between them do not know about it |
46
+ | already kept in Zustand, Redux or nanostores, or shared with code that is not Nativele, or in need of what the library offers (its middleware, devtools, persistence) | that library's store, connected with the adapter below | the store stays the one source; Nativele components only read it and send it actions |
47
+
48
+ Do not move an existing Zustand or Redux store into `createStore` to use Nativele, and do not add a state library for data that `state` or `createStore` already holds.
49
+
50
+ 2. Keep each store in its own module outside the components folder, for example `src/stores/`, and import it from the components that use it. `ts-dom check src/nv` runs `tsc` over the whole project, so it type-checks these modules too.
51
+ 3. For Zustand and Redux, add the adapter below as `src/stores/from-store.ts`. `this.bind` takes an object with `get()` and `subscribe(fn)`, calls `fn` at once with `get()`, calls it again on every change, and ends the subscription when the element is removed (see the components guide). A Zustand or Redux store has `getState()` instead of `get()`, and a Redux listener gets no value, so neither can be passed as it is (limitations 1 and 2). The adapter reads the state itself, applies a selector, and calls back only when the selected value changes under `Object.is`:
52
+
53
+ ```ts
54
+ // src/stores/from-store.ts
55
+ import type { ReadableStore } from "@nativele/ts-dom";
56
+
57
+ export interface ExternalStore<S> {
58
+ getState(): S;
59
+ subscribe(listener: () => void): () => void;
60
+ }
61
+
62
+ export function fromStore<S, T>(store: ExternalStore<S>, select: (state: S) => T): ReadableStore<T> {
63
+ return {
64
+ get: () => select(store.getState()),
65
+ subscribe(fn) {
66
+ let last = select(store.getState());
67
+ return store.subscribe(() => {
68
+ const next = select(store.getState());
69
+ if (Object.is(next, last)) return;
70
+ last = next;
71
+ fn(next);
72
+ });
73
+ },
74
+ };
75
+ }
76
+ ```
77
+
78
+ 4. In each component, make one source per value it shows (`fromStore(store, (s) => s.items.length)`, at module level), bind it in `connected()`, and copy the value into `state` with `this.set`. Bind in `connected()`, never in the constructor (limitation 6).
79
+ 5. Change the store from the component's `data-on-*` handlers: call a Zustand action (`cart.getState().add("apple")`), dispatch a Redux action (`store.dispatch(increment())`), or set a nanostores atom (`$theme.set("dark")`). The store notifies synchronously, so the view has changed when the handler returns.
80
+ 6. For a nanostores atom, bind `{ get: $theme.get, subscribe: $theme.listen }`, not the atom itself (limitation 3).
81
+
82
+ ## Example
83
+
84
+ The stores:
85
+
86
+ ```ts
87
+ // src/stores/cart.ts (Zustand)
88
+ import { createStore } from "zustand/vanilla";
89
+
90
+ export interface CartState {
91
+ items: string[];
92
+ note: string;
93
+ add(item: string): void;
94
+ setNote(note: string): void;
95
+ }
96
+
97
+ export const cart = createStore<CartState>()((set) => ({
98
+ items: [],
99
+ note: "",
100
+ add: (item) => set((s) => ({ items: [...s.items, item] })),
101
+ setNote: (note) => set({ note }),
102
+ }));
103
+ ```
104
+
105
+ ```ts
106
+ // src/stores/counter.ts (Redux Toolkit)
107
+ import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";
108
+
109
+ const counter = createSlice({
110
+ name: "counter",
111
+ initialState: { value: 0 },
112
+ reducers: {
113
+ increment: (state) => {
114
+ state.value += 1;
115
+ },
116
+ },
117
+ });
118
+
119
+ const filter = createSlice({
120
+ name: "filter",
121
+ initialState: "all" as "all" | "done",
122
+ reducers: { setFilter: (_state, action: PayloadAction<"all" | "done">) => action.payload },
123
+ });
124
+
125
+ export const store = configureStore({ reducer: { counter: counter.reducer, filter: filter.reducer } });
126
+ export const { increment } = counter.actions;
127
+ export const { setFilter } = filter.actions;
128
+ ```
129
+
130
+ ```ts
131
+ // src/stores/theme.ts (nanostores)
132
+ import { atom } from "nanostores";
133
+
134
+ export const $theme = atom<"light" | "dark">("light");
135
+ ```
136
+
137
+ ```ts
138
+ // src/stores/session.ts (Nativele)
139
+ import { createStore } from "@nativele/ts-dom";
140
+
141
+ export const session = createStore({ user: "guest" });
142
+ ```
143
+
144
+ A component that shows one slice of the Zustand store, and one that changes it:
145
+
146
+ ```html
147
+ <!-- src/nv/cart-badge.html -->
148
+ <span>{{ count }} in cart</span>
149
+ ```
150
+
151
+ ```ts
152
+ // src/nv/cart-badge.ts
153
+ import { Component, define } from "@nativele/ts-dom";
154
+ import { cart } from "../stores/cart.ts";
155
+ import { fromStore } from "../stores/from-store.ts";
156
+ import tpl from "./cart-badge.html.js";
157
+
158
+ const itemCount = fromStore(cart, (s) => s.items.length);
159
+
160
+ export class CartBadge extends Component(tpl, { state: { count: 0 } }) {
161
+ override connected(): void {
162
+ this.bind(itemCount, (count) => this.set({ count }));
163
+ }
164
+ }
165
+ define("cart-badge", CartBadge);
166
+ ```
167
+
168
+ ```html
169
+ <!-- src/nv/cart-form.html -->
170
+ <button type="button" data-on-click="addApple">add apple</button>
171
+ <input data-ref="note" data-on-input="onNote" />
172
+ ```
173
+
174
+ ```ts
175
+ // src/nv/cart-form.ts
176
+ import { Component, define } from "@nativele/ts-dom";
177
+ import { cart } from "../stores/cart.ts";
178
+ import tpl from "./cart-form.html.js";
179
+
180
+ export class CartForm extends Component(tpl) {
181
+ addApple(): void {
182
+ cart.getState().add("apple");
183
+ }
184
+ onNote(): void {
185
+ cart.getState().setNote(this.refs.note.value);
186
+ }
187
+ }
188
+ define("cart-form", CartForm);
189
+ ```
190
+
191
+ Typing a note changes `note` only, so `cart-badge` gets no callback and does not render.
192
+
193
+ A component that shows a Redux value and dispatches from a click:
194
+
195
+ ```html
196
+ <!-- src/nv/counter-view.html -->
197
+ <b>{{ value }}</b>
198
+ <button type="button" data-on-click="increment">+1</button>
199
+ ```
200
+
201
+ ```ts
202
+ // src/nv/counter-view.ts
203
+ import { Component, define } from "@nativele/ts-dom";
204
+ import { fromStore } from "../stores/from-store.ts";
205
+ import { increment, store } from "../stores/counter.ts";
206
+ import tpl from "./counter-view.html.js";
207
+
208
+ const counterValue = fromStore(store, (s) => s.counter.value);
209
+
210
+ export class CounterView extends Component(tpl, { state: { value: 0 } }) {
211
+ override connected(): void {
212
+ this.bind(counterValue, (value) => this.set({ value }));
213
+ }
214
+ increment(): void {
215
+ store.dispatch(increment());
216
+ }
217
+ }
218
+ define("counter-view", CounterView);
219
+ ```
220
+
221
+ A nanostores atom and a Nativele store, each bound directly:
222
+
223
+ ```ts
224
+ // src/nv/theme-switch.ts (template: <button type="button" data-on-click="toggle">theme: {{ theme }}</button>)
225
+ export class ThemeSwitch extends Component(tpl, { state: { theme: "light" as "light" | "dark" } }) {
226
+ override connected(): void {
227
+ this.bind({ get: $theme.get, subscribe: $theme.listen }, (theme) => this.set({ theme }));
228
+ }
229
+ toggle(): void {
230
+ $theme.set($theme.get() === "light" ? "dark" : "light");
231
+ }
232
+ }
233
+ define("theme-switch", ThemeSwitch);
234
+
235
+ // src/nv/user-badge.ts (template: <span>{{ user }}</span>); another component calls session.set((s) => ({ ...s, user: "Ada" }))
236
+ export class UserBadge extends Component(tpl, { state: { user: "" } }) {
237
+ override connected(): void {
238
+ this.bind(session, (s) => this.set({ user: s.user }));
239
+ }
240
+ }
241
+ define("user-badge", UserBadge);
242
+ ```
243
+
244
+ `src/main.ts` imports every component module; `index.html` places the tags anywhere on the page (`<cart-badge></cart-badge>` in a section, `<user-badge></user-badge>` in the header), with nothing passed between them.
245
+
246
+ ## Known limitations
247
+
248
+ 1. **A Zustand or Redux store cannot be passed to `bind`.** `this.bind(cart, ...)` fails with `TS2741: Property 'get' is missing in type 'StoreApi<CartState>'`, and `this.bind(store, ...)` with a Redux store with `TS2345: ... Property 'get' is missing`. Use `fromStore`. A Zustand store could be bound whole as `{ get: cart.getState, subscribe: cart.subscribe }` (Zustand 5 passes the state to its listeners), but then see limitation 4.
249
+ 2. **A Redux listener gets no value.** `{ get: store.getState, subscribe: store.subscribe }` fails with `TS2322: Type '(listener: ListenerCallback) => Unsubscribe' is not assignable ... Target signature provides too few arguments`: Redux 5 calls its listeners with no arguments. Use `fromStore`, which calls `getState()` itself.
250
+ 3. **A nanostores atom passes the type check but calls back twice.** `this.bind($theme, fn)` compiles, and `fn` runs twice when the component connects, because nanostores' `subscribe` also calls the listener at once. Bind `{ get: $theme.get, subscribe: $theme.listen }`: `listen` does not call at once, so `fn` runs once on connect and once per change. Not verified: `computed`, `map` and other nanostores stores.
251
+ 4. **Bind a selected value, not the whole store.** A whole store calls back on every change of any field. When the callback copies into `state`, `this.set` skips the render if nothing it copied changed (it compares each field with `Object.is`), but the callback still runs; a callback that writes `this.text`, `refs` or a list itself does that work every time. With `fromStore` and a selector, a change elsewhere in the store runs no callback and no render.
252
+ 5. **A selector must return one value that stays the same when unchanged.** A selector that builds a new object or array (`(s) => ({ count: s.items.length })`) differs under `Object.is` every time, so it calls back on every change of the store. Select each value with its own `fromStore`, or select an object or array the store itself keeps (Zustand and Redux keep an unchanged slice as the same object).
253
+ 6. **Bind in `connected()`.** When the element is removed, `bind` ends every subscription, and no store sends it updates. When the same element is inserted again, `connected()` runs, binds again, and the callback runs at once with the current value, so changes made while it was out show; it then gets each change once, not twice. A `bind` made anywhere else (the constructor, a field, code outside the component) ends on the first removal and is not made again.
254
+ 7. **`createStore` is exported by both `zustand/vanilla` and `@nativele/ts-dom`.** Importing both into one module fails with `TS2300: Duplicate identifier 'createStore'`. Keep each store in its own module (step 2), or rename one import (`import { createStore as createZustandStore } from "zustand/vanilla"`).
255
+ 8. **Store updates are synchronous.** A Zustand action, a Redux `dispatch` of a plain action and a nanostores `set` call `bind` callbacks before they return, so the view has changed when the handler ends.
256
+ 9. Not verified: Zustand middleware (`subscribeWithSelector`, `persist`, `devtools`), RTK Query and auto-batched actions, Redux thunks, an equality function other than `Object.is`, and the state libraries' framework bindings.
257
+
258
+ ## Verify
259
+
260
+ Run in the project:
261
+
262
+ ```sh
263
+ npm run build # expect: ts-dom check: no issues, then ✓ built
264
+ npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
265
+ ```
266
+
267
+ `ts-dom check` type-checks the store modules and the adapter as well. To keep the checks of limitations 1 and 2 in the project, add a file the app never imports, with one wrong use per store under `// @ts-expect-error`:
268
+
269
+ ```ts
270
+ // src/types-check.ts
271
+ import type { CartBadge } from "./nv/cart-badge.ts";
272
+ import { cart } from "./stores/cart.ts";
273
+ import { store } from "./stores/counter.ts";
274
+
275
+ export function typesCheck(el: CartBadge): void {
276
+ // @ts-expect-error a Zustand store has getState, not get
277
+ el.bind(cart, () => {});
278
+ // @ts-expect-error a Redux store has getState, not get
279
+ el.bind(store, () => {});
280
+ // @ts-expect-error a Redux listener gets no value
281
+ el.bind({ get: store.getState, subscribe: store.subscribe }, () => {});
282
+ }
283
+ ```
284
+
285
+ If one of them stops being an error, `ts-dom check` reports `TS2578: Unused '@ts-expect-error' directive`.
286
+
287
+ In the browser, against `npm run dev`: click and type in each component and check that the bound components show the new value; remove a bound element (`el.remove()`), change its store, insert it again, and check that it shows the current value. To prove that a selector limits re-rendering, count calls by wrapping the component's prototype methods before acting, without changing the component:
288
+
289
+ ```js
290
+ const proto = customElements.get("cart-badge").prototype;
291
+ for (const name of ["set", "render"]) {
292
+ const original = proto[name];
293
+ proto[name] = function (...args) { console.log(name); return original.apply(this, args); };
294
+ }
295
+ ```
296
+
297
+ Then typing a note must log nothing for `cart-badge`, and adding an item must log `set` and `render` once each. The example project's [`verify.mjs`](https://github.com/Pixmi/nativele/blob/main/skills/nativele-state-management/example/verify.mjs) does these checks with Playwright for Zustand, Redux, nanostores and `createStore`, plus the `bind` contract itself (an immediate call, no call for an `Object.is`-equal set, nothing after removal), the nanostores double call, selectors that return a new object or a kept slice, and no console errors.
298
+
299
+ ## Troubleshooting
300
+
301
+ | Symptom | Cause | Fix |
302
+ |---|---|---|
303
+ | `TS2741: Property 'get' is missing in type 'StoreApi<...>'` | a Zustand store passed to `bind` | `this.bind(fromStore(cart, (s) => s.x), ...)` |
304
+ | `TS2345: ... Property 'get' is missing in type '... Store<...>'` | a Redux store passed to `bind` | `fromStore(store, (s) => s.x)` |
305
+ | `TS2322: ... '(listener: ListenerCallback) => Unsubscribe' ... Target signature provides too few arguments` | Redux's `subscribe` passed to `bind` as it is | `fromStore`, which calls `getState()` |
306
+ | The callback runs twice when the component connects | a nanostores atom bound directly | bind `{ get: $a.get, subscribe: $a.listen }` |
307
+ | A component renders, or its callback runs, when an unrelated field changes | the whole store is bound, or the selector returns a new object | one `fromStore` per value, each returning a value the store keeps |
308
+ | A component stops updating after it was moved or removed and inserted again | `bind` was called outside `connected()` | call `this.bind` in `connected()` |
309
+ | `TS2300: Duplicate identifier 'createStore'` | `zustand/vanilla` and `@nativele/ts-dom` imported into one module | separate modules, or rename one import |
310
+ | `[missing-binding] "x" is used by {{ x }} ... but the component declares no state, prop or derived value of that name` | a template shows a store field the component has no `state` for | add the field to `state` and copy it in the `bind` callback |
311
+
312
+ ## See also
313
+
314
+ - `bind`, `createStore` and the store contract: https://github.com/Pixmi/nativele/blob/main/docs/components.md#state-store-when-not-using-state
315
+ - Component `state`, `set` and `batch`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-and-view-state-optional
316
+ - Shared stores instead of passing through layers: https://github.com/Pixmi/nativele/blob/main/docs/components.md#avoiding-layer-by-layer-passing-lint-ts-domdata-flow
317
+ - Copying a store value into state: https://github.com/Pixmi/nativele/blob/main/docs/recipes.md#showing-data-from-a-store
318
+ - Moved elements, connecting and subscriptions: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#moving-items-connecting-and-subscriptions
319
+ - Setting Nativele up in Vite: `npx ts-dom skills nativele-vite`