@manyducksco/stator 1.0.0-rc.1

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.
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
8
+
9
+ jobs:
10
+ test-and-build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Setup Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: "22"
20
+ cache: "npm"
21
+
22
+ - name: Install dependencies
23
+ run: npm ci
24
+
25
+ - name: Typecheck
26
+ run: npx tsc --noEmit
27
+
28
+ - name: Run tests
29
+ run: npm run test
30
+
31
+ - name: Build library
32
+ run: npm run build
@@ -0,0 +1,29 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout repository
12
+ uses: actions/checkout@v4
13
+
14
+ - name: Setup Node.js
15
+ uses: actions/setup-node@v4
16
+ with:
17
+ node-version: "22"
18
+ registry-url: "https://registry.npmjs.org"
19
+
20
+ - name: Install dependencies
21
+ run: npm ci
22
+
23
+ - name: Build library
24
+ run: npm run build
25
+
26
+ - name: Publish package
27
+ run: npm publish --access public
28
+ env:
29
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2026 That's a lot of ducks, LLC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # @manyducksco/stator
2
+
3
+ Stator answers the question, "How do I share one hook's state between multiple components?" Stator makes this simple.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @manyducksco/stator
9
+ ```
10
+
11
+ ## Example: Counter Store
12
+
13
+ ```tsx
14
+ import { createStore } from "@manyducksco/stator";
15
+ import { useState, useCallback } from "react";
16
+
17
+ type CounterOptions = {
18
+ initialValue?: number;
19
+ };
20
+
21
+ // Write your hook code, pass it to `createStore`, get a Provider and a hook.
22
+
23
+ const [CounterProvider, useCounter] = createStore((options: CounterOptions) => {
24
+ const [value, setValue] = useState(options.initialValue ?? 0);
25
+
26
+ const increment = useCallback((amount = 1) => {
27
+ setValue((current) => current + amount);
28
+ }, []);
29
+
30
+ const decrement = useCallback((amount = 1) => {
31
+ setValue((current) => current - amount);
32
+ }, []);
33
+
34
+ const reset = useCallback(() => {
35
+ setValue(0);
36
+ }, []);
37
+
38
+ return {
39
+ value,
40
+ increment,
41
+ decrement,
42
+ reset,
43
+ };
44
+ });
45
+
46
+ function MyApp() {
47
+ return (
48
+ // One instance of your store is created wherever you render the provider.
49
+ // You can render multiple `<CounterProvider>`s in different parts of your app,
50
+ // and each one maintains its own isolated state.
51
+ <CounterProvider options={{ initialValue: 51 }}>
52
+ <CounterDisplay />
53
+ <CounterControls />
54
+ </CounterProvider>
55
+ );
56
+ }
57
+
58
+ function CounterDisplay() {
59
+ // All children can access the same instance via the hook.
60
+ // TypeScript will automatically infer the correct return types here.
61
+ const { value } = useCounter();
62
+
63
+ return <p>Count is: {value}</p>;
64
+ }
65
+
66
+ function CounterControls() {
67
+ const { increment, decrement, reset } = useCounter();
68
+
69
+ return (
70
+ <div>
71
+ <button onClick={increment}>+1</button>
72
+ <button onClick={decrement}>-1</button>
73
+ <button onClick={reset}>Reset</button>
74
+ </div>
75
+ );
76
+ }
77
+ ```
78
+
79
+ ## Optimizing with a selector
80
+
81
+ It's typical to only need part of a store's state. A component that calls `useCounter` will render every time the store state changes, even if it's not using the part of the state that changed. You can pass a selector function to pluck out the parts you care about so your component will only render when you need it to.
82
+
83
+ Let's optimize the Counter components.
84
+
85
+ ```tsx
86
+ function CounterDisplay() {
87
+ const value = useCounter((state) => state.value);
88
+ // We only care about the value.
89
+ // If we add more state to the counter store later, this component won't even notice.
90
+
91
+ return <p>Count is: {value}</p>;
92
+ }
93
+
94
+ function CounterControls() {
95
+ const [increment, decrement, reset] = useCounter((state) => [
96
+ state.increment,
97
+ state.decrement,
98
+ state.reset,
99
+ ]);
100
+ // We don't care about the value, only the functions to modify it.
101
+ // Because we've wrapped them in `useCallback` they will always reference the same functions.
102
+ // Changes to the counter value will never cause this component to render.
103
+
104
+ return (
105
+ <div>
106
+ <button onClick={increment}>+1</button>
107
+ <button onClick={decrement}>-1</button>
108
+ <button onClick={reset}>Reset</button>
109
+ </div>
110
+ );
111
+ }
112
+ ```
113
+
114
+ > [!NOTE]
115
+ > The hook performs a shallow check on the selected value that treats arrays or objects with equivalent keys and values as equal.
116
+ > A selector may return an array or object with multiple values (like in our CounterControls example above).
117
+ > In this case the return value is just a container. It's the items _inside_ the array that are compared for equality.
118
+
119
+ ### Memoize everything
120
+
121
+ > [!IMPORTANT]
122
+ > Because Stator relies on referential equality when comparing selected state, you must memoize any selected functions and derived objects returned by your base hook.
123
+
124
+ If you return a new function or object reference on every render, components selecting those values will also re-render every time, defeating the selector optimization.
125
+
126
+ ```ts
127
+ // ❌ Bad: This creates a new function reference every render!
128
+ const increment = (amount = 1) => setValue((current) => current + amount);
129
+
130
+ // ✅ Good: The reference remains stable.
131
+ const increment = useCallback((amount = 1) => {
132
+ setValue((current) => current + amount);
133
+ }, []);
134
+ ```
135
+
136
+ ## Prior art
137
+
138
+ We have been long time users of the great [unstated-next](https://github.com/jamiebuilds/unstated-next). Stator was created to add memoization and an improved API on top of that same idea.
139
+
140
+ ## License
141
+
142
+ Stator is provided under the MIT license.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@manyducksco/stator",
3
+ "description": "Memoized context stores for React",
4
+ "version": "1.0.0-rc.1",
5
+ "sideEffects": false,
6
+ "author": "Tony McCoy <tony@manyducks.co>",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./dist/stator.js"
10
+ },
11
+ "main": "./dist/stator.js",
12
+ "types": "./dist/stator.d.ts",
13
+ "scripts": {
14
+ "build": "vite build && tsc",
15
+ "test": "vitest",
16
+ "lint": "eslint . --max-warnings 0",
17
+ "check-types": "tsc --noEmit"
18
+ },
19
+ "devDependencies": {
20
+ "@testing-library/react": "^16.3.2",
21
+ "@types/node": "^22.19.11",
22
+ "@types/react": "19.2.2",
23
+ "@types/react-dom": "19.2.2",
24
+ "eslint": "^9.39.1",
25
+ "jsdom": "^28.1.0",
26
+ "typescript": "5.9.2",
27
+ "vite": "^7.3.1",
28
+ "vitest": "^4.0.18"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=18",
32
+ "react-dom": ">=18"
33
+ }
34
+ }
@@ -0,0 +1,153 @@
1
+ import { cleanup, fireEvent, render, renderHook } from "@testing-library/react";
2
+ import { useCallback, useState } from "react";
3
+ import { afterEach, expect, test, vi } from "vitest";
4
+ import { createStore } from "./stator.js";
5
+
6
+ type TestStoreOptions = { initialCount?: number; initialText?: string };
7
+
8
+ afterEach(() => {
9
+ cleanup();
10
+ });
11
+
12
+ const [TestProvider, useTestStore] = createStore(
13
+ (options?: TestStoreOptions) => {
14
+ const [count, setCount] = useState(options?.initialCount ?? 0);
15
+ const [text, setText] = useState(options?.initialText ?? "hello");
16
+
17
+ const increment = useCallback(() => setCount((c) => c + 1), []);
18
+ const updateText = useCallback((t: string) => setText(t), []);
19
+
20
+ return { count, text, increment, updateText };
21
+ },
22
+ );
23
+
24
+ test("throws when hook is used outside of provider", () => {
25
+ // Suppress React's internal error logging for this specific test
26
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
27
+
28
+ expect(() => renderHook(() => useTestStore())).toThrow(
29
+ "Component must be wrapped with a store <Provider>",
30
+ );
31
+
32
+ consoleError.mockRestore();
33
+ });
34
+
35
+ test("store receives options object from provider props", () => {
36
+ const { result } = renderHook(() => useTestStore(), {
37
+ wrapper: ({ children }) => (
38
+ <TestProvider options={{ initialCount: 10, initialText: "initialized" }}>
39
+ {children}
40
+ </TestProvider>
41
+ ),
42
+ });
43
+
44
+ expect(result.current.count).toBe(10);
45
+ expect(result.current.text).toBe("initialized");
46
+ expect(typeof result.current.increment).toBe("function");
47
+ });
48
+
49
+ test("hook returns only the selected state", () => {
50
+ const { result } = renderHook(() => useTestStore((state) => state.count), {
51
+ wrapper: ({ children }) => <TestProvider>{children}</TestProvider>,
52
+ });
53
+
54
+ expect(result.current).toBe(0);
55
+ });
56
+
57
+ test("renders only when selected state changes", () => {
58
+ const displaySpy = vi.fn();
59
+ const controlSpy = vi.fn();
60
+
61
+ const DisplayComponent = () => {
62
+ displaySpy();
63
+ const count = useTestStore((state) => state.count);
64
+ return <div data-testid="display">{count}</div>;
65
+ };
66
+
67
+ const ControlComponent = () => {
68
+ controlSpy();
69
+ const increment = useTestStore((state) => state.increment);
70
+ return (
71
+ <button data-testid="increment" onClick={increment}>
72
+ Increment
73
+ </button>
74
+ );
75
+ };
76
+
77
+ const { getByTestId } = render(
78
+ <TestProvider>
79
+ <DisplayComponent />
80
+ <ControlComponent />
81
+ </TestProvider>,
82
+ );
83
+
84
+ expect(displaySpy).toHaveBeenCalledTimes(1);
85
+ expect(controlSpy).toHaveBeenCalledTimes(1);
86
+
87
+ fireEvent.click(getByTestId("increment"));
88
+
89
+ expect(displaySpy).toHaveBeenCalledTimes(2);
90
+ expect(controlSpy).toHaveBeenCalledTimes(1);
91
+ expect(getByTestId("display").textContent).toBe("1");
92
+ });
93
+
94
+ test("handles new array/object references deeply", () => {
95
+ const selectSpy = vi.fn();
96
+ const renderSpy = vi.fn();
97
+
98
+ const CountComponent = () => {
99
+ renderSpy();
100
+ const [count, increment] = useTestStore((state) => {
101
+ selectSpy();
102
+ return [state.count, state.increment];
103
+ });
104
+ return (
105
+ <div>
106
+ <div>{count}</div>
107
+ <button data-testid="increment" onClick={() => increment()}>
108
+ Increment
109
+ </button>
110
+ </div>
111
+ );
112
+ };
113
+
114
+ const TextComponent = () => {
115
+ const [text, setText] = useTestStore((state) => [
116
+ state.text,
117
+ state.updateText,
118
+ ]);
119
+ return (
120
+ <button
121
+ data-testid="update-text"
122
+ onClick={() => {
123
+ setText("text has been updated!");
124
+ }}
125
+ >
126
+ {text}
127
+ </button>
128
+ );
129
+ };
130
+
131
+ const { getByTestId } = render(
132
+ <TestProvider>
133
+ <CountComponent />
134
+ <TextComponent />
135
+ </TestProvider>,
136
+ );
137
+
138
+ expect(selectSpy).toHaveBeenCalledTimes(1);
139
+ expect(renderSpy).toHaveBeenCalledTimes(1);
140
+ expect(getByTestId("update-text").textContent).toBe("hello");
141
+
142
+ // The hook should see the contents are deeply equal and abort the render.
143
+ fireEvent.click(getByTestId("update-text"));
144
+
145
+ expect(selectSpy).toHaveBeenCalledTimes(2); // selected again
146
+ expect(renderSpy).toHaveBeenCalledTimes(1); // still rendered once
147
+ expect(getByTestId("update-text").textContent).toBe("text has been updated!");
148
+
149
+ fireEvent.click(getByTestId("increment"));
150
+
151
+ expect(selectSpy).toHaveBeenCalledTimes(3);
152
+ expect(renderSpy).toHaveBeenCalledTimes(2);
153
+ });
package/src/stator.ts ADDED
@@ -0,0 +1,199 @@
1
+ import {
2
+ createContext,
3
+ createElement,
4
+ useContext,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useSyncExternalStore,
8
+ } from "react";
9
+
10
+ const EMPTY: unique symbol = Symbol();
11
+
12
+ export interface StoreProviderProps<Options> {
13
+ options?: Options;
14
+ children: React.ReactNode;
15
+ }
16
+
17
+ export interface StoreProviderPropsWithOptions<
18
+ Options,
19
+ > extends StoreProviderProps<Options> {
20
+ options: Options;
21
+ }
22
+
23
+ /**
24
+ * Provides a single instance of a store to all its children.
25
+ */
26
+ export type StoreProvider<Options> = React.ComponentType<
27
+ StoreProviderProps<Options>
28
+ >;
29
+
30
+ /**
31
+ * Provides a single instance of a store to all its children.
32
+ * Store options are passed as props.
33
+ */
34
+ export type StoreProviderWithOptions<Options> = React.ComponentType<
35
+ StoreProviderPropsWithOptions<Options>
36
+ >;
37
+
38
+ /**
39
+ * Plucks only the parts of the state this component cares about.
40
+ */
41
+ export type Selector<Value, Selected> = (value: Value) => Selected;
42
+
43
+ /**
44
+ * Accesses the nearest parent instance of a store.
45
+ */
46
+ export interface StoreHook<Value> {
47
+ (): Value;
48
+ <Selected>(select: Selector<Value, Selected>): Selected;
49
+ }
50
+
51
+ class Store<T> {
52
+ public value: T;
53
+ private listeners = new Set<() => void>();
54
+
55
+ constructor(value: T) {
56
+ this.value = value;
57
+ }
58
+
59
+ update(value: T) {
60
+ this.value = value;
61
+ }
62
+
63
+ notify() {
64
+ this.listeners.forEach((listener) => listener());
65
+ }
66
+
67
+ subscribe = (listener: () => void) => {
68
+ this.listeners.add(listener);
69
+ return () => this.listeners.delete(listener);
70
+ };
71
+
72
+ get = () => this.value;
73
+ }
74
+
75
+ /**
76
+ * Defines a new store, returning its provider and hook.
77
+ */
78
+ export function createStore<Value, Options>(
79
+ fn: () => Value,
80
+ ): [StoreProvider<Options>, StoreHook<Value>];
81
+
82
+ /**
83
+ * Defines a new store, returning its provider and hook.
84
+ */
85
+ export function createStore<Value, Options>(
86
+ fn: (options: Options) => Value,
87
+ ): [StoreProviderWithOptions<Options>, StoreHook<Value>];
88
+
89
+ export function createStore<Value, Options>(
90
+ fn: (options?: Options) => Value,
91
+ ):
92
+ | [StoreProvider<Options>, StoreHook<Value>]
93
+ | [StoreProviderWithOptions<Options>, StoreHook<Value>] {
94
+ const Context = createContext<Store<Value> | typeof EMPTY>(EMPTY);
95
+
96
+ function Provider(props: StoreProviderProps<Options>) {
97
+ const value = fn(props.options);
98
+ const storeRef = useRef<Store<Value>>(null);
99
+
100
+ if (!storeRef.current) {
101
+ storeRef.current = new Store(value);
102
+ } else {
103
+ storeRef.current.update(value);
104
+ }
105
+
106
+ useLayoutEffect(() => {
107
+ storeRef.current!.notify();
108
+ }, [value]);
109
+
110
+ return createElement(Context.Provider, {
111
+ value: storeRef.current,
112
+ children: props.children,
113
+ });
114
+ }
115
+
116
+ function useStore<Selected = Value>(select?: Selector<Value, Selected>) {
117
+ const store = useContext(Context);
118
+
119
+ if (store === EMPTY) {
120
+ throw new Error("Component must be wrapped with a store <Provider>");
121
+ }
122
+
123
+ const snapshotRef = useRef<{ root: Value; selected: Selected } | null>(
124
+ null,
125
+ );
126
+
127
+ const getSnapshot = () => {
128
+ const root = store.get();
129
+ if (!select) return root as unknown as Selected;
130
+
131
+ const prev = snapshotRef.current;
132
+
133
+ if (prev && Object.is(prev.root, root)) {
134
+ return prev.selected;
135
+ }
136
+
137
+ const selected = select(root);
138
+
139
+ // If the selected values are equal, return the old reference to bail out of the render.
140
+ if (prev && _equals(selected, prev.selected)) {
141
+ snapshotRef.current = { root, selected: prev.selected };
142
+ return prev.selected;
143
+ }
144
+
145
+ // Otherwise, cache and return the new reference.
146
+ snapshotRef.current = { root, selected };
147
+ return selected;
148
+ };
149
+
150
+ return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
151
+ }
152
+
153
+ return [Provider, useStore];
154
+ }
155
+
156
+ /**
157
+ * Shallow `Object.is` comparison for selected objects and arrays.
158
+ */
159
+ function _equals(a: any, b: any): boolean {
160
+ // Exact match (handles identical object references and primitive matches like 1 === 1)
161
+ if (Object.is(a, b)) return true;
162
+
163
+ // If either is not an object (primitive, function) or is null,
164
+ // and they failed the Object.is check above, they are definitely not equal.
165
+ if (
166
+ typeof a !== "object" ||
167
+ a === null ||
168
+ typeof b !== "object" ||
169
+ b === null
170
+ ) {
171
+ return false;
172
+ }
173
+
174
+ // Shallow array compare
175
+ if (Array.isArray(a)) {
176
+ if (!Array.isArray(b) || a.length !== b.length) return false;
177
+ for (let i = 0; i < a.length; i++) {
178
+ if (!Object.is(a[i], b[i])) return false;
179
+ }
180
+ return true;
181
+ }
182
+
183
+ // Bail out on different types (e.g., Object vs Date)
184
+ if (a.constructor !== b.constructor) return false;
185
+
186
+ // Shallow object compare
187
+ const keys = Object.keys(a);
188
+ if (keys.length !== Object.keys(b).length) return false;
189
+
190
+ const hasOwn = Object.prototype.hasOwnProperty;
191
+ for (let i = 0; i < keys.length; i++) {
192
+ const key = keys[i];
193
+ if (!hasOwn.call(b, key) || !Object.is(a[key], b[key])) {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ return true;
199
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "declarationMap": true,
6
+ "emitDeclarationOnly": true,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "incremental": false,
10
+ "isolatedModules": true,
11
+ "lib": ["es2022", "DOM", "DOM.Iterable", "WebWorker"],
12
+ "module": "preserve",
13
+ "moduleDetection": "force",
14
+ "moduleResolution": "bundler",
15
+ "noUncheckedIndexedAccess": false,
16
+ "resolveJsonModule": true,
17
+ "skipLibCheck": true,
18
+ "strict": true,
19
+ "target": "ES2022",
20
+ "outDir": "dist",
21
+ "jsx": "react-jsx"
22
+ },
23
+ "include": ["src"],
24
+ "exclude": ["node_modules", "dist", "src/*.test.*"]
25
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+
7
+ export default defineConfig({
8
+ build: {
9
+ lib: {
10
+ entry: resolve(__dirname, "src/stator.ts"),
11
+ name: "Stator",
12
+ fileName: "stator",
13
+ formats: ["es"],
14
+ },
15
+ rollupOptions: {
16
+ external: ["react"],
17
+ output: {
18
+ globals: {
19
+ react: "React",
20
+ },
21
+ },
22
+ },
23
+ },
24
+ test: {
25
+ environment: "jsdom",
26
+ },
27
+ });