totally-tubular 0.3.3 → 0.4.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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ npm i totally-tubular --save
18
18
 
19
19
  ## Core concepts
20
20
 
21
- A `Tubular` instance wraps an object and gives you four things:
21
+ A `Tubular` instance wraps an object and gives you five things:
22
22
 
23
23
  | Method | What it does |
24
24
  |---|---|
@@ -26,6 +26,7 @@ A `Tubular` instance wraps an object and gives you four things:
26
26
  | `update(path, fn)` | Calls `fn` with the current value, stores the return value, then notifies all observers |
27
27
  | `observe(path, fn)` | Registers a callback that fires whenever `path` is updated |
28
28
  | `unobserve(path, fn)` | Removes a previously registered callback |
29
+ | `reset()` | Restores every key to its initial value and notifies all observers of each change |
29
30
 
30
31
  Paths are fully type-checked. TypeScript knows every valid dot-path into your state shape, so you get autocomplete and a compile error if you mistype a key.
31
32
 
@@ -63,6 +64,30 @@ store.update("meta.title", (prev) => `Supreme ${prev}`);
63
64
  console.info(store.read("meta.title")); // "Supreme Director"
64
65
  ```
65
66
 
67
+ ## Resetting state
68
+
69
+ Call `reset()` to restore your entire state back to the value you passed to the constructor. Every updated key is restored and every registered observer fires with the new (initial) value.
70
+
71
+ ```typescript
72
+ const store = new Tubular({
73
+ name: "Alice",
74
+ count: 0,
75
+ settings: { theme: "light" },
76
+ });
77
+
78
+ store.update("name", () => "Bob");
79
+ store.update("count", (prev) => prev + 1);
80
+ store.update("settings.theme", () => "dark");
81
+
82
+ store.observe("name", (newVal, oldVal) => {
83
+ console.log(`name: ${oldVal} -> ${newVal}`);
84
+ });
85
+
86
+ store.reset();
87
+ // Logs: name: Bob -> Alice
88
+ // Reads back: "Alice", 0, { theme: "light" }
89
+ ```
90
+
66
91
  ## React
67
92
 
68
93
  Import `useTubular` from `totally-tubular/react`. It works exactly like React's
@@ -115,6 +140,24 @@ setCount((prev) => (prev ?? 0) + 1);
115
140
  `useTubular` automatically unsubscribes when the component unmounts, so you
116
141
  don't need to manage cleanup yourself.
117
142
 
143
+ ### Resetting from React
144
+
145
+ Import `useTubularReset` from `totally-tubular/react`. It returns a stable
146
+ callback that restores the store to its initial state. Every `useTubular` hook
147
+ watching the store re-renders with the restored value.
148
+
149
+ ```tsx
150
+ import { useTubularReset } from "totally-tubular/react";
151
+
152
+ function ResetButton() {
153
+ const handleReset = useTubularReset(store);
154
+ return <button onClick={handleReset}>Reset</button>;
155
+ }
156
+ ```
157
+
158
+ The callback identity never changes, so it's safe to pass directly as an event
159
+ handler or as a dependency to `useEffect`.
160
+
118
161
  ## Things you should know
119
162
 
120
163
  - **Arrays and nested objects are observed at their path, not their contents.**
@@ -125,53 +168,55 @@ don't need to manage cleanup yourself.
125
168
 
126
169
  ## Performance benchmarks
127
170
 
171
+ All benchmarks were run with Node.js 26.5.0 using the `tsx` TypeScript interpreter.
172
+
128
173
  ### Apple M3 Max · 36 GB RAM · macOS Sequoia 15.5
129
174
 
130
175
  ```
131
176
  -----shallow state object-----
132
177
 
133
- 1,000 updates to "name" string val with 10 observers: 0.0036749580000000038s
134
- 1,000 updates to "name" string val with 100 observers: 0.0004760830000000027s
135
- 1,000 updates to "name" string val with 1000 observers: 0.002774291000000005s
136
- 10,000 updates to "name" string val with 10 observers: 0.0014766249999999986s
137
- 10,000 updates to "name" string val with 100 observers: 0.0023700000000000045s
138
- 10,000 updates to "name" string val with 1000 observers: 0.010886792s
139
- 100,000 updates to "name" string val with 10 observers: 0.012480834000000001s
140
- 100,000 updates to "name" string val with 100 observers: 0.022641292000000007s
141
- 100,000 updates to "name" string val with 1000 observers: 0.102325125s
142
- 1,000,000 updates to "name" string val with 10 observers: 0.16086666700000002s
143
- 1,000,000 updates to "name" string val with 100 observers: 0.26716437499999995s
144
- 1,000,000 updates to "name" string val with 1000 observers: 1.0726739170000001s
178
+ 1,000 updates to "name" string val with 10 observers: 0.0005343750000000114s
179
+ 1,000 updates to "name" string val with 100 observers: 0.0006880840000000035s
180
+ 1,000 updates to "name" string val with 1000 observers: 0.002814292000000023s
181
+ 10,000 updates to "name" string val with 10 observers: 0.0007928750000000377s
182
+ 10,000 updates to "name" string val with 100 observers: 0.0017347500000000195s
183
+ 10,000 updates to "name" string val with 1000 observers: 0.007635916000000009s
184
+ 100,000 updates to "name" string val with 10 observers: 0.01587337500000001s
185
+ 100,000 updates to "name" string val with 100 observers: 0.020373334s
186
+ 100,000 updates to "name" string val with 1000 observers: 0.12892133400000005s
187
+ 1,000,000 updates to "name" string val with 10 observers: 0.14660462499999993s
188
+ 1,000,000 updates to "name" string val with 100 observers: 0.20982341600000007s
189
+ 1,000,000 updates to "name" string val with 1000 observers: 0.8595329590000002s
145
190
 
146
191
  -----medium state object-----
147
192
 
148
- 1,000 updates to "user.settings.theme" string val with 10 observers: 0.000344333000000006s
149
- 1,000 updates to "user.settings.theme" string val with 100 observers: 0.00034458300000005695s
150
- 1,000 updates to "user.settings.theme" string val with 1000 observers: 0.0011382089999999608s
151
- 10,000 updates to "user.settings.theme" string val with 10 observers: 0.0012657919999999195s
152
- 10,000 updates to "user.settings.theme" string val with 100 observers: 0.002091625000000022s
153
- 10,000 updates to "user.settings.theme" string val with 1000 observers: 0.010777082999999948s
154
- 100,000 updates to "user.settings.theme" string val with 10 observers: 0.009487583000000086s
155
- 100,000 updates to "user.settings.theme" string val with 100 observers: 0.01902945799999998s
156
- 100,000 updates to "user.settings.theme" string val with 1000 observers: 0.11656079100000011s
157
- 1,000,000 updates to "user.settings.theme" string val with 10 observers: 0.14811841699999992s
158
- 1,000,000 updates to "user.settings.theme" string val with 100 observers: 0.27773633299999984s
159
- 1,000,000 updates to "user.settings.theme" string val with 1000 observers: 1.0240415829999998s
193
+ 1,000 updates to "user.settings.theme" string val with 10 observers: 0.0011529580000001261s
194
+ 1,000 updates to "user.settings.theme" string val with 100 observers: 0.002206040999999914s
195
+ 1,000 updates to "user.settings.theme" string val with 1000 observers: 0.006846457999999984s
196
+ 10,000 updates to "user.settings.theme" string val with 10 observers: 0.01919616700000006s
197
+ 10,000 updates to "user.settings.theme" string val with 100 observers: 0.007707957999999963s
198
+ 10,000 updates to "user.settings.theme" string val with 1000 observers: 0.014114458000000014s
199
+ 100,000 updates to "user.settings.theme" string val with 10 observers: 0.012900958000000174s
200
+ 100,000 updates to "user.settings.theme" string val with 100 observers: 0.018197167000000035s
201
+ 100,000 updates to "user.settings.theme" string val with 1000 observers: 0.09116387499999973s
202
+ 1,000,000 updates to "user.settings.theme" string val with 10 observers: 0.18448416599999973s
203
+ 1,000,000 updates to "user.settings.theme" string val with 100 observers: 0.24980879200000028s
204
+ 1,000,000 updates to "user.settings.theme" string val with 1000 observers: 0.8701055419999998s
160
205
 
161
206
  -----deep state object-----
162
207
 
163
- 1,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.0012417919999998049s
164
- 1,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.0003201250000001892s
165
- 1,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 0.0011683750000001964s
166
- 10,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.0014113330000000133s
167
- 10,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.002289249999999811s
168
- 10,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 0.01099350000000004s
169
- 100,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.014709209000000101s
170
- 100,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.024220374999999877s
171
- 100,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 0.10777845800000023s
172
- 1,000,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.22398191699999961s
173
- 1,000,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.2829787500000002s
174
- 1,000,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 1.1015419579999999s
208
+ 1,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.0002052080000003116s
209
+ 1,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.0002497920000000704s
210
+ 1,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 0.001332209000000148s
211
+ 10,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.0018820000000000618s
212
+ 10,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.0024639170000000377s
213
+ 10,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 0.014082249999999932s
214
+ 100,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.021280209000000015s
215
+ 100,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.02714695800000027s
216
+ 100,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 0.09046704200000022s
217
+ 1,000,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 10 observers: 0.2687363329999998s
218
+ 1,000,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 100 observers: 0.3244868329999999s
219
+ 1,000,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 0.9486893330000002s
175
220
  ```
176
221
 
177
222
  ### Intel i9-13900HX · 32 GB RAM · Windows 11
@@ -0,0 +1,3 @@
1
+ export declare function isArray(thing: unknown): thing is unknown[];
2
+ export declare function isObject(thing: unknown): thing is object;
3
+ export declare function isNullOrUndefined(thing: unknown): thing is null | undefined;
package/dist/object.js ADDED
@@ -0,0 +1,9 @@
1
+ export function isArray(thing) {
2
+ return Array.isArray(thing);
3
+ }
4
+ export function isObject(thing) {
5
+ return typeof thing === 'object';
6
+ }
7
+ export function isNullOrUndefined(thing) {
8
+ return thing === null || thing === undefined || typeof thing === 'undefined';
9
+ }
package/dist/react.d.ts CHANGED
@@ -28,3 +28,26 @@ import type { AllObjectKeys, PropType } from './types.js';
28
28
  * ```
29
29
  */
30
30
  export declare function useTubular<T extends object, K extends string>(tubular: Tubular<T>, key: K & AllObjectKeys<T>): readonly [PropType<T, K> | null, (newValOrCallback: PropType<T, K> | ((prevVal: PropType<T, K> | null) => PropType<T, K>)) => void];
31
+ /**
32
+ * A React hook that returns a stable callback to reset a {@link Tubular} store
33
+ * back to its initial state.
34
+ *
35
+ * Every key in the store is restored to the value originally passed to the
36
+ * constructor, and every registered observer (including {@link useTubular}
37
+ * hooks) is notified of each change.
38
+ *
39
+ * The returned callback is stable across re-renders.
40
+ * It never changes its identity, so it is safe to pass as a dependency to `useEffect` or as a prop
41
+ * to memoized components.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * const store = new Tubular({ name: 'Alice', count: 0 });
46
+ *
47
+ * function ResetButton() {
48
+ * const handleReset = useTubularReset(store);
49
+ * return <button onClick={handleReset}>Reset</button>;
50
+ * }
51
+ * ```
52
+ */
53
+ export declare function useTubularReset<T extends object>(tubular: Tubular<T>): () => void;
package/dist/react.js CHANGED
@@ -52,3 +52,28 @@ export function useTubular(tubular, key) {
52
52
  }, [tubular, key]);
53
53
  return [val, handleUpdateVal];
54
54
  }
55
+ /**
56
+ * A React hook that returns a stable callback to reset a {@link Tubular} store
57
+ * back to its initial state.
58
+ *
59
+ * Every key in the store is restored to the value originally passed to the
60
+ * constructor, and every registered observer (including {@link useTubular}
61
+ * hooks) is notified of each change.
62
+ *
63
+ * The returned callback is stable across re-renders.
64
+ * It never changes its identity, so it is safe to pass as a dependency to `useEffect` or as a prop
65
+ * to memoized components.
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * const store = new Tubular({ name: 'Alice', count: 0 });
70
+ *
71
+ * function ResetButton() {
72
+ * const handleReset = useTubularReset(store);
73
+ * return <button onClick={handleReset}>Reset</button>;
74
+ * }
75
+ * ```
76
+ */
77
+ export function useTubularReset(tubular) {
78
+ return useCallback(() => tubular.reset(), [tubular]);
79
+ }
@@ -8,6 +8,7 @@ import type { AllObjectKeys, ObservationCallback, PropType } from './types.js';
8
8
  export declare class Tubular<T extends object> {
9
9
  private observationCallbacks;
10
10
  private state;
11
+ private initialStateClone;
11
12
  constructor(initialState: T);
12
13
  private doSomethingToPath;
13
14
  private getPortionOfStateByPath;
@@ -19,6 +20,12 @@ export declare class Tubular<T extends object> {
19
20
  * reads a portion of your state
20
21
  */
21
22
  read<K extends string>(readPath: K & AllObjectKeys<T>): PropType<T, K> | null;
23
+ private resetImpl;
24
+ /**
25
+ * resets the state back to its initial value
26
+ * (the value you provided to the constructor)
27
+ */
28
+ reset(): void;
22
29
  /**
23
30
  * disconnects an observer callback from totally tubular
24
31
  */
@@ -1,3 +1,4 @@
1
+ import { isArray, isNullOrUndefined, isObject } from './object.js';
1
2
  /**
2
3
  * A totally tubular instance that tracks a top-level
3
4
  * set of keys and values.
@@ -7,14 +8,13 @@
7
8
  export class Tubular {
8
9
  observationCallbacks = new Map();
9
10
  state;
11
+ initialStateClone;
10
12
  constructor(initialState) {
11
13
  this.state = initialState;
14
+ this.initialStateClone = globalThis.structuredClone(this.state);
12
15
  }
13
16
  doSomethingToPath(readPath, doSomething) {
14
17
  let thing = this.state;
15
- if (typeof readPath !== 'string') {
16
- return this.getPortionOfStateByPath(thing[readPath]);
17
- }
18
18
  const splitPath = readPath.split('.');
19
19
  const len = splitPath.length;
20
20
  for (let i = 0; i < len; i++) {
@@ -26,15 +26,9 @@ export class Tubular {
26
26
  else
27
27
  thing = thing?.[p];
28
28
  }
29
- for (const p of splitPath) {
30
- thing = thing?.[p];
31
- }
32
29
  }
33
30
  getPortionOfStateByPath(readPath) {
34
31
  let thing = this.state;
35
- if (typeof readPath !== 'string') {
36
- return this.getPortionOfStateByPath(thing[readPath]);
37
- }
38
32
  const splitPath = readPath.split('.');
39
33
  for (const p of splitPath) {
40
34
  thing = thing?.[p];
@@ -54,6 +48,32 @@ export class Tubular {
54
48
  read(readPath) {
55
49
  return this.getPortionOfStateByPath(readPath);
56
50
  }
51
+ resetImpl(parentKey) {
52
+ let sourceObj = this.initialStateClone;
53
+ if (parentKey) {
54
+ for (const p of parentKey.split('.')) {
55
+ sourceObj = sourceObj?.[p];
56
+ }
57
+ if (isNullOrUndefined(sourceObj) || !isObject(sourceObj))
58
+ return;
59
+ }
60
+ for (const [key, val] of Object.entries(sourceObj)) {
61
+ const childKey = parentKey ? `${parentKey}.${key}` : key;
62
+ if (!isNullOrUndefined(val) && isObject(val) && !isArray(val)) {
63
+ this.resetImpl(childKey);
64
+ }
65
+ else {
66
+ this.update(childKey, () => val);
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * resets the state back to its initial value
72
+ * (the value you provided to the constructor)
73
+ */
74
+ reset() {
75
+ this.resetImpl();
76
+ }
57
77
  /**
58
78
  * disconnects an observer callback from totally tubular
59
79
  */
package/dist/types.d.ts CHANGED
@@ -8,7 +8,7 @@ type IsCollection<T> = T extends readonly any[] | Map<any, any> | Set<any> ? tru
8
8
  /**
9
9
  * Produces every dot-separated key path into T as a string union.
10
10
  *
11
- * - The `Depth` tuple acts as a recursion counter: when its length reaches 5 the
11
+ * - The `Depth` tuple acts as a recursion counter: when its length reaches 10 the
12
12
  * type resolves to `never`, which caps the union size and prevents the language
13
13
  * server from hanging on deeply-nested or self-referential shapes.
14
14
  * - Top-level keys are included as bare strings (`K`), so this type supersedes
@@ -18,7 +18,7 @@ type IsCollection<T> = T extends readonly any[] | Map<any, any> | Set<any> ? tru
18
18
  * - Returning `never` (not `''`) from every terminal branch avoids the need for
19
19
  * a secondary `Extract<D, string>` pass over the full union.
20
20
  */
21
- export type DotNestedKeys<T, Depth extends 1[] = []> = Depth['length'] extends 5 ? never : T extends object ? IsCollection<T> extends true ? never : {
21
+ export type DotNestedKeys<T, Depth extends 1[] = []> = Depth['length'] extends 10 ? never : T extends object ? IsCollection<T> extends true ? never : {
22
22
  [K in keyof T & string]: IsCollection<T[K]> extends true ? K : T[K] extends object ? K | `${K}.${DotNestedKeys<T[K], [...Depth, 1]>}` : K;
23
23
  }[keyof T & string] : never;
24
24
  /**
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "A completely framework-agnostic, JavaScript / TypeScript state management and update broadcasting library wit zero dependencies, solid performance and a micro file size.",
4
4
  "name": "totally-tubular",
5
5
  "type": "module",
6
- "version": "0.3.3",
6
+ "version": "0.4.1",
7
7
  "keywords": [
8
8
  "state",
9
9
  "management",
@@ -26,25 +26,24 @@
26
26
  "files": [
27
27
  "dist/"
28
28
  ],
29
- "scripts": {
30
- "build": "rm -rf dist/ && tsc --project ./tsconfig.build.json",
31
- "clean": "rm -rf dist/",
32
- "test": "bun test",
33
- "test:watch": "bun test --watch"
34
- },
35
29
  "devDependencies": {
36
30
  "@better-builds/lets-version": "^1.6.0",
37
- "@biomejs/biome": "^2.4.11",
38
- "@commitlint/cli": "^20.5.0",
39
- "@commitlint/config-conventional": "^20.5.0",
40
- "@happy-dom/global-registrator": "^20.9.0",
31
+ "@biomejs/biome": "^2.4.16",
32
+ "@commitlint/cli": "^20.5.3",
33
+ "@commitlint/config-conventional": "^20.5.3",
41
34
  "@testing-library/react": "^16.3.2",
42
35
  "@testing-library/user-event": "^14.6.1",
43
- "@types/bun": "latest",
44
- "@types/react": "^19.2.14",
45
- "happy-dom": "^20.9.0",
46
- "react": ">=18",
47
- "typescript": "^5.9.3"
36
+ "@types/react": "^19.2.17",
37
+ "@types/node": "^26.1.1",
38
+ "@types/react-dom": "^19.2.3",
39
+ "@vitejs/plugin-react": "^6.0.3",
40
+ "happy-dom": "^20.10.2",
41
+ "react": "^19.2.7",
42
+ "react-dom": "^19.2.7",
43
+ "tsx": "^4.23.0",
44
+ "typescript": "^5.9.3",
45
+ "vite": "^8.1.4",
46
+ "vitest": "^4.1.10"
48
47
  },
49
48
  "peerDependencies": {
50
49
  "react": ">=18"
@@ -72,5 +71,12 @@
72
71
  "default": "./dist/types.js"
73
72
  },
74
73
  "./package.json": "./package.json"
74
+ },
75
+ "scripts": {
76
+ "build": "rm -rf dist/ && tsc --project ./tsconfig.build.json",
77
+ "clean": "rm -rf dist/",
78
+ "test": "vitest test",
79
+ "test:performance": "tsx ./performanceTests.ts",
80
+ "test:watch": "vitest test --watch"
75
81
  }
76
- }
82
+ }