@octanejs/zustand 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @octanejs/zustand
2
+
3
+ [zustand](https://github.com/pmndrs/zustand) for the [octane](https://github.com/octanejs/octane) renderer.
4
+
5
+ zustand separates a framework-agnostic **vanilla store** (`createStore`) from a tiny
6
+ **React binding** (`create` + `useStore`) built on `useSyncExternalStore`. This package
7
+ reuses the vanilla store unchanged (re-exported verbatim from `zustand/vanilla`) and
8
+ reimplements only the binding on top of octane's `useSyncExternalStore`. The public
9
+ surface matches zustand 1:1 — most zustand code works by changing the import.
10
+
11
+ ```tsx
12
+ // before
13
+ import { create } from 'zustand';
14
+ // after
15
+ import { create } from '@octanejs/zustand';
16
+
17
+ const useBearStore = create((set) => ({
18
+ bears: 0,
19
+ increase: () => set((s) => ({ bears: s.bears + 1 })),
20
+ }));
21
+
22
+ function BearCounter() @{
23
+ const bears = useBearStore((s) => s.bears);
24
+ <h1>{bears as string} bears</h1>
25
+ }
26
+ ```
27
+
28
+ ## Entry points
29
+
30
+ | import | what you get | notes |
31
+ | --- | --- | --- |
32
+ | `@octanejs/zustand` | `create`, `useStore`, `createStore` | the React binding, octane-bound |
33
+ | `@octanejs/zustand/vanilla` | `createStore` + types | re-exported verbatim from zustand |
34
+ | `@octanejs/zustand/shallow` | `shallow`, `useShallow` | `shallow` verbatim; `useShallow` octane-bound |
35
+ | `@octanejs/zustand/middleware` | `persist`, `devtools`, `subscribeWithSelector`, `combine`, `redux`, `createJSONStorage`, … | re-exported verbatim (all framework-agnostic) |
36
+ | `@octanejs/zustand/traditional` | `createWithEqualityFn`, `useStoreWithEqualityFn` | octane-bound (selector + equality fn) |
37
+
38
+ ## How it works
39
+
40
+ octane keys hooks by a compiler-injected per-call-site `Symbol`, appended as the last
41
+ argument of every `use*` call. A custom hook is just a wrapper that **forwards** that
42
+ slot to the base hook it composes — which is all `useStore` does. Because the slot is
43
+ per-call-site, `useBearStore(a)` and `useBearStore(b)` in one component (or the same
44
+ hook used twice) stay independent, exactly like in React.
45
+
46
+ > **Naming matters.** The hook you call must follow the `use*` convention (`const
47
+ > useBearStore = create(...)`) so the compiler recognises it and injects the slot —
48
+ > this is the same `use*`-is-reserved-for-hooks rule React uses.
49
+
50
+ ## Selecting object slices — `useShallow`
51
+
52
+ A selector that returns a fresh object/array each call (`(s) => ({ a: s.a })`) never
53
+ compares Object.is-equal, so it re-renders on every store change. Wrap it with
54
+ `useShallow` to compare by shallow equality:
55
+
56
+ ```tsx
57
+ import { useShallow } from '@octanejs/zustand/shallow';
58
+
59
+ function Sliced() @{
60
+ const { a, b } = useBearStore(useShallow((s) => ({ a: s.a, b: s.b })));
61
+ // re-renders only when a or b actually changes
62
+ }
63
+ ```
64
+
65
+ ## Divergences from React
66
+
67
+ - **Unstable selectors don't loop.** React's `useSyncExternalStore` throws the dev
68
+ warning _"The result of getSnapshot should be cached"_ and re-renders in a loop when
69
+ a selector returns a new reference every render. octane settles after a bounded
70
+ number of renders instead — no loop, no warning. Prefer `useShallow` regardless, for
71
+ the same reason you would in React (avoid the extra renders).
72
+
73
+ ## Equality functions — `traditional`
74
+
75
+ For the equality-fn pattern, `@octanejs/zustand/traditional` provides
76
+ `createWithEqualityFn` / `useStoreWithEqualityFn`:
77
+
78
+ ```tsx
79
+ import { createWithEqualityFn } from '@octanejs/zustand/traditional';
80
+ import { shallow } from '@octanejs/zustand/shallow';
81
+
82
+ const useStore = createWithEqualityFn((set) => ({ a: 0, b: 0 }), shallow);
83
+ const { a } = useStore((s) => ({ a: s.a })); // bails out via shallow
84
+ ```
85
+
86
+ For most object-slice selections, prefer `useShallow` — the v5-recommended approach.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@octanejs/zustand",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "zustand bindings for the octane renderer — reuses zustand's framework-agnostic vanilla store and swaps the React binding for octane's useSyncExternalStore.",
7
+ "author": {
8
+ "name": "Dominic Gannaway",
9
+ "email": "dg@domgan.com"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/octanejs/octane.git",
17
+ "directory": "packages/zustand"
18
+ },
19
+ "main": "src/index.ts",
20
+ "module": "src/index.ts",
21
+ "types": "src/index.ts",
22
+ "files": [
23
+ "src",
24
+ "README.md"
25
+ ],
26
+ "exports": {
27
+ ".": "./src/index.ts",
28
+ "./vanilla": "./src/vanilla.ts",
29
+ "./shallow": "./src/shallow.ts",
30
+ "./middleware": "./src/middleware.ts",
31
+ "./traditional": "./src/traditional.ts"
32
+ },
33
+ "dependencies": {
34
+ "zustand": "^5.0.0",
35
+ "octane": "0.1.1"
36
+ },
37
+ "devDependencies": {
38
+ "@tsrx/react": "^0.2.32",
39
+ "esbuild": "^0.28.1",
40
+ "react": "^19.2.0",
41
+ "react-dom": "^19.2.0",
42
+ "vitest": "^4.1.9"
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run"
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,89 @@
1
+ // @octanejs/zustand — zustand for the octane renderer.
2
+ //
3
+ // zustand cleanly separates a framework-agnostic vanilla store (`createStore`)
4
+ // from a tiny React binding (`create` + `useStore`) layered on
5
+ // `useSyncExternalStore`. This package reuses the vanilla store UNCHANGED (it's
6
+ // pure JS — re-exported verbatim from `zustand/vanilla`) and reimplements only
7
+ // the binding on top of octane's `useSyncExternalStore`. The public surface
8
+ // (`create`, `useStore`, `createStore`) matches zustand 1:1, so existing zustand
9
+ // code works by changing the import from `zustand` to `@octanejs/zustand`.
10
+ //
11
+ // The one octane-specific detail is hook slots: octane keys hooks by a
12
+ // compiler-injected per-call-site Symbol, appended as the LAST argument of every
13
+ // `use*` call. A custom hook is just a wrapper that FORWARDS that slot to the
14
+ // base hook it composes — which is exactly what `useStore` does below. Because
15
+ // the slot is per-call-site, `useFoo(a)` and `useFoo(b)` in one component (or
16
+ // the same `useFoo` used twice) stay independent, just like in React.
17
+ import { useSyncExternalStore } from 'octane';
18
+ import { createStore } from 'zustand/vanilla';
19
+ import type { Mutate, StateCreator, StoreApi, StoreMutatorIdentifier } from 'zustand/vanilla';
20
+
21
+ // Re-export the vanilla core so `@octanejs/zustand` is a drop-in for both the
22
+ // store-only (`createStore`) and binding (`create`) entry points of zustand.
23
+ export { createStore } from 'zustand/vanilla';
24
+ export type * from 'zustand/vanilla';
25
+
26
+ type ExtractState<S> = S extends { getState: () => infer T } ? T : never;
27
+ type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'getInitialState' | 'subscribe'>;
28
+
29
+ const identity = <T>(arg: T): T => arg;
30
+
31
+ export function useStore<S extends ReadonlyStoreApi<unknown>>(api: S): ExtractState<S>;
32
+ export function useStore<S extends ReadonlyStoreApi<unknown>, U>(
33
+ api: S,
34
+ selector: (state: ExtractState<S>) => U,
35
+ ): U;
36
+ export function useStore<TState, StateSlice>(
37
+ api: ReadonlyStoreApi<TState>,
38
+ // The compiler appends the call-site slot Symbol after the user args, so the
39
+ // runtime tuple is `[selector?, slot?]`. Both are optional to the author; the
40
+ // slot is supplied by the octane transform.
41
+ ...rest: [selector?: (state: TState) => StateSlice, slot?: symbol]
42
+ ): StateSlice {
43
+ // Resolve the slot the way every base hook does: it's the LAST argument and a
44
+ // Symbol. Counting from the end means `useFoo()` (where the slot lands in the
45
+ // selector position) and `useFoo(sel)` both work.
46
+ const tail = rest[rest.length - 1];
47
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
48
+ const selector = (typeof rest[0] === 'function' ? rest[0] : identity) as (
49
+ state: TState,
50
+ ) => StateSlice;
51
+ return useSyncExternalStore(
52
+ api.subscribe,
53
+ () => selector(api.getState()),
54
+ () => selector(api.getInitialState()),
55
+ slot,
56
+ );
57
+ }
58
+
59
+ type UseBoundStore<S extends ReadonlyStoreApi<unknown>> = {
60
+ (): ExtractState<S>;
61
+ <U>(selector: (state: ExtractState<S>) => U): U;
62
+ } & S;
63
+
64
+ type Create = {
65
+ <T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
66
+ initializer: StateCreator<T, [], Mos>,
67
+ ): UseBoundStore<Mutate<StoreApi<T>, Mos>>;
68
+ <T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
69
+ initializer: StateCreator<T, [], Mos>,
70
+ ) => UseBoundStore<Mutate<StoreApi<T>, Mos>>;
71
+ };
72
+
73
+ const createImpl = (<T>(createState: StateCreator<T, [], []>) => {
74
+ const api = createStore(createState);
75
+ // The bound hook forwards ALL args (selector + the appended slot) straight
76
+ // through to `useStore`. It is NOT compiled by octane (it lives in this
77
+ // dependency), so no slot is injected here — the slot is the one the consumer's
78
+ // `useFoo(...)` call site carries, which is exactly what makes each call-site
79
+ // independent.
80
+ const useBoundStore = (...args: unknown[]) =>
81
+ (useStore as (...a: unknown[]) => unknown)(api, ...args);
82
+ Object.assign(useBoundStore, api);
83
+ return useBoundStore;
84
+ }) as Create;
85
+
86
+ export const create = (<T>(createState?: StateCreator<T, [], []>) =>
87
+ createState ? createImpl(createState) : createImpl) as Create;
88
+
89
+ export default create;
@@ -0,0 +1,8 @@
1
+ // `@octanejs/zustand/middleware` — re-exported verbatim from zustand.
2
+ //
3
+ // Every zustand middleware (persist, devtools, subscribeWithSelector, combine,
4
+ // redux, createJSONStorage, …) is a framework-agnostic store enhancer of the form
5
+ // `(set, get, api) => state` — it operates purely on the vanilla store and imports
6
+ // NO React. So it composes with octane's `create`/`useStore` unchanged, and we
7
+ // re-export it as-is (mirroring src/vanilla.ts).
8
+ export * from 'zustand/middleware';
package/src/shallow.ts ADDED
@@ -0,0 +1,29 @@
1
+ // `@octanejs/zustand/shallow` — the zustand selector-equality helpers.
2
+ //
3
+ // `shallow` is a pure comparator (no hooks) and is re-exported verbatim from
4
+ // zustand. `useShallow` IS a hook (zustand builds it on React.useRef), so it gets
5
+ // an octane reimplementation: same memoize-the-selection-by-shallow-equality
6
+ // logic, but built on octane's `useRef` with the compiler-injected slot forwarded
7
+ // through (exactly like `useStore`). Use it to select an object/array slice
8
+ // without the unstable-selector re-render churn:
9
+ //
10
+ // const { a, b } = useBearStore(useShallow((s) => ({ a: s.a, b: s.b })));
11
+ import { useRef } from 'octane';
12
+ import { shallow } from 'zustand/vanilla/shallow';
13
+
14
+ export { shallow } from 'zustand/vanilla/shallow';
15
+
16
+ export function useShallow<S, U>(selector: (state: S) => U): (state: S) => U;
17
+ export function useShallow<S, U>(
18
+ selector: (state: S) => U,
19
+ // Compiler-injected trailing slot (forwarded to the inner useRef).
20
+ ...rest: [slot?: symbol]
21
+ ): (state: S) => U {
22
+ const tail = rest[rest.length - 1];
23
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
24
+ const prev = useRef<U | undefined>(undefined, slot);
25
+ return (state: S) => {
26
+ const next = selector(state);
27
+ return shallow(prev.current, next) ? (prev.current as U) : (prev.current = next);
28
+ };
29
+ }
@@ -0,0 +1,144 @@
1
+ // `@octanejs/zustand/traditional` — zustand's equality-function binding.
2
+ //
3
+ // `createWithEqualityFn` / `useStoreWithEqualityFn` let a selector pair with a
4
+ // custom equality function (e.g. `shallow`). zustand builds these on React's
5
+ // `use-sync-external-store/shim/with-selector` — a polyfill whose extra machinery
6
+ // (a closure-memoizer in useMemo + a useEffect commit) exists for React's
7
+ // CONCURRENT rendering, where a render can be produced and then thrown away.
8
+ //
9
+ // octane renders synchronously (a render always commits), so none of that is
10
+ // needed: we build directly on octane's REAL `useSyncExternalStore`. A ref caches
11
+ // the last-returned selection and `getSnapshot` returns that SAME reference while
12
+ // the equality fn says the selection is unchanged — so useSyncExternalStore's own
13
+ // Object.is check bails out the re-render. One extra base hook (the ref), with its
14
+ // slot derived from the wrapper's forwarded slot.
15
+ //
16
+ // Note: v5 recommends `useShallow` over this equality-fn pattern for object
17
+ // slices; `traditional` exists for code that still uses it.
18
+ import { useSyncExternalStore, useRef } from 'octane';
19
+ import { createStore } from 'zustand/vanilla';
20
+ import type { StateCreator, StoreApi } from 'zustand/vanilla';
21
+
22
+ type ExtractState<S> = S extends { getState: () => infer T } ? T : never;
23
+ type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'getInitialState' | 'subscribe'>;
24
+
25
+ const identity = <T>(arg: T): T => arg;
26
+
27
+ // Derive a stable, distinct sub-slot from the wrapper's slot. `Symbol.for` interns
28
+ // by description, so the same call site always yields the same sub-slot; the
29
+ // `:wsel:` namespace keeps it clear of useSyncExternalStore's `:uses:` slots.
30
+ function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
31
+ return slot !== undefined ? Symbol.for((slot.description ?? '') + ':wsel:' + tag) : undefined;
32
+ }
33
+
34
+ interface SelectionCell<U> {
35
+ hasValue: boolean;
36
+ value: U | undefined;
37
+ }
38
+
39
+ export function useStoreWithEqualityFn<S extends ReadonlyStoreApi<unknown>>(
40
+ api: S,
41
+ ): ExtractState<S>;
42
+ export function useStoreWithEqualityFn<S extends ReadonlyStoreApi<unknown>, U>(
43
+ api: S,
44
+ selector: (state: ExtractState<S>) => U,
45
+ equalityFn?: (a: U, b: U) => boolean,
46
+ ): U;
47
+ export function useStoreWithEqualityFn<TState, StateSlice>(
48
+ api: ReadonlyStoreApi<TState>,
49
+ // Runtime args are `[selector?, equalityFn?, slot?]`; the slot is appended by
50
+ // the octane compiler and is always LAST.
51
+ ...rest: [
52
+ selector?: (state: TState) => StateSlice,
53
+ equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
54
+ slot?: symbol,
55
+ ]
56
+ ): StateSlice {
57
+ const tail = rest[rest.length - 1];
58
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
59
+ const userArgs = slot !== undefined ? rest.slice(0, -1) : rest;
60
+ const selector = (typeof userArgs[0] === 'function' ? userArgs[0] : identity) as (
61
+ state: TState,
62
+ ) => StateSlice;
63
+ const equalityFn =
64
+ typeof userArgs[1] === 'function'
65
+ ? (userArgs[1] as (a: StateSlice, b: StateSlice) => boolean)
66
+ : undefined;
67
+
68
+ const cache = useRef<SelectionCell<StateSlice>>(
69
+ { hasValue: false, value: undefined },
70
+ subSlot(slot, 'sel'),
71
+ );
72
+ // Returns the cached reference while the selection is "equal" → octane's
73
+ // useSyncExternalStore sees an Object.is-equal snapshot and bails out.
74
+ const select = (state: TState): StateSlice => {
75
+ const next = selector(state);
76
+ const c = cache.current;
77
+ if (c.hasValue) {
78
+ const prev = c.value as StateSlice;
79
+ if (equalityFn ? equalityFn(prev, next) : Object.is(prev, next)) return prev;
80
+ }
81
+ c.hasValue = true;
82
+ c.value = next;
83
+ return next;
84
+ };
85
+
86
+ return useSyncExternalStore(
87
+ api.subscribe,
88
+ () => select(api.getState()),
89
+ () => select(api.getInitialState()),
90
+ slot,
91
+ );
92
+ }
93
+
94
+ type UseBoundStoreWithEqualityFn<S extends ReadonlyStoreApi<unknown>> = {
95
+ (): ExtractState<S>;
96
+ <U>(selector: (state: ExtractState<S>) => U, equalityFn?: (a: U, b: U) => boolean): U;
97
+ } & S;
98
+
99
+ type CreateWithEqualityFn = {
100
+ <T, U = T>(
101
+ initializer: StateCreator<T, [], []>,
102
+ defaultEqualityFn?: (a: U, b: U) => boolean,
103
+ ): UseBoundStoreWithEqualityFn<StoreApi<T>>;
104
+ <T, U = T>(): (
105
+ initializer: StateCreator<T, [], []>,
106
+ defaultEqualityFn?: (a: U, b: U) => boolean,
107
+ ) => UseBoundStoreWithEqualityFn<StoreApi<T>>;
108
+ };
109
+
110
+ const createWithEqualityFnImpl = (<T>(
111
+ createState: StateCreator<T, [], []>,
112
+ defaultEqualityFn?: (a: unknown, b: unknown) => boolean,
113
+ ) => {
114
+ const api = createStore(createState);
115
+ // The bound hook receives `[selector?, equalityFn?, slot?]`. We resolve the
116
+ // default equality fn, then hand off to useStoreWithEqualityFn with the slot
117
+ // kept last (it re-parses the same trailing-slot shape).
118
+ const useBoundStoreWithEqualityFn = (...args: unknown[]) => {
119
+ const tail = args[args.length - 1];
120
+ const slot = typeof tail === 'symbol' ? tail : undefined;
121
+ const u = slot !== undefined ? args.slice(0, -1) : args;
122
+ const selector = u[0];
123
+ const equalityFn = u.length > 1 ? u[1] : defaultEqualityFn;
124
+ return (useStoreWithEqualityFn as (...a: unknown[]) => unknown)(
125
+ api,
126
+ selector,
127
+ equalityFn,
128
+ slot,
129
+ );
130
+ };
131
+ Object.assign(useBoundStoreWithEqualityFn, api);
132
+ return useBoundStoreWithEqualityFn;
133
+ }) as CreateWithEqualityFn;
134
+
135
+ export const createWithEqualityFn = (<T, U = T>(
136
+ createState?: StateCreator<T, [], []>,
137
+ defaultEqualityFn?: (a: U, b: U) => boolean,
138
+ ) =>
139
+ createState
140
+ ? createWithEqualityFnImpl(
141
+ createState,
142
+ defaultEqualityFn as (a: unknown, b: unknown) => boolean,
143
+ )
144
+ : createWithEqualityFnImpl) as CreateWithEqualityFn;
package/src/vanilla.ts ADDED
@@ -0,0 +1,7 @@
1
+ // The framework-agnostic core, re-exported verbatim from zustand. @octanejs/zustand
2
+ // reuses zustand's vanilla store unchanged — only the React binding (see index.ts)
3
+ // is swapped for one built on octane's `useSyncExternalStore`. Authors who only
4
+ // need the store (no component binding) can import from `@octanejs/zustand/vanilla`
5
+ // exactly as they would from `zustand/vanilla`.
6
+ export * from 'zustand/vanilla';
7
+ export { createStore as default } from 'zustand/vanilla';