juststore 0.1.4 → 0.3.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/README.md CHANGED
@@ -454,38 +454,41 @@ Creates a form store with validation support.
454
454
 
455
455
  The store root provides path-based methods for dynamic access:
456
456
 
457
- | Method | Description |
458
- | ------------------------------- | ------------------------------------------------------- |
459
- | `.use(path)` | Subscribe and read value (triggers re-render on change) |
460
- | `.useDebounce(path, ms)` | Subscribe with debounced updates |
461
- | `.useState(path)` | Returns `[value, setValue]` tuple |
462
- | `.value(path)` | Read without subscribing |
463
- | `.set(path, value)` | Update value |
464
- | `.set(path, fn)` | Functional update |
465
- | `.reset(path)` | Delete value at path |
466
- | `.subscribe(path, fn)` | Subscribe to changes (for effects) |
467
- | `.notify(path)` | Manually trigger subscribers |
468
- | `.useCompute(path, fn)` | Derive a computed value |
469
- | `.Render({ path, children })` | Render prop component |
470
- | `.Show({ path, children, on })` | Conditional render component |
457
+ | Method | Description |
458
+ | ---------------------------------------------- | ------------------------------------------------------- |
459
+ | `.state(path)` | Get the state object for a path |
460
+ | `.use(path)` | Subscribe and read value (triggers re-render on change) |
461
+ | `.useDebounce(path, ms)` | Subscribe with debounced updates |
462
+ | `.useState(path)` | Returns `[value, setValue]` tuple |
463
+ | `.value(path)` | Read without subscribing |
464
+ | `.set(path, value)` | Update value |
465
+ | `.set(path, fn)` | Functional update |
466
+ | `.reset(path)` | Delete value at path |
467
+ | `.rename(path, oldKey, newKey, notifyObject?)` | Rename a key in an object |
468
+ | `.subscribe(path, fn)` | Subscribe to changes (for effects) |
469
+ | `.notify(path)` | Manually trigger subscribers |
470
+ | `.useCompute(path, fn)` | Derive a computed value |
471
+ | `.Render({ path, children })` | Render prop component |
472
+ | `.Show({ path, children, on })` | Conditional render component |
471
473
 
472
474
  ### State Methods
473
475
 
474
- | Method | Description |
475
- | ------------------------- | ------------------------------------------------------- |
476
- | `.use()` | Subscribe and read value (triggers re-render on change) |
477
- | `.useDebounce(ms)` | Subscribe with debounced updates |
478
- | `.useState()` | Returns `[value, setValue]` tuple |
479
- | `.value` | Read without subscribing |
480
- | `.set(value)` | Update value |
481
- | `.set(fn)` | Functional update |
482
- | `.reset()` | Delete value at path |
483
- | `.subscribe(fn)` | Subscribe to changes (for effects) |
484
- | `.notify()` | Manually trigger subscribers |
485
- | `.useCompute(fn)` | Derive a computed value |
486
- | `.derived({ from, to })` | Create bidirectional transform |
487
- | `.Render({ children })` | Render prop component |
488
- | `.Show({ children, on })` | Conditional render component |
476
+ | Method | Description |
477
+ | ---------------------------------------- | ------------------------------------------------------- |
478
+ | `.use()` | Subscribe and read value (triggers re-render on change) |
479
+ | `.useDebounce(ms)` | Subscribe with debounced updates |
480
+ | `.useState()` | Returns `[value, setValue]` tuple |
481
+ | `.value` | Read without subscribing |
482
+ | `.set(value)` | Update value |
483
+ | `.set(fn)` | Functional update |
484
+ | `.reset()` | Delete value at path |
485
+ | `.subscribe(fn)` | Subscribe to changes (for effects) |
486
+ | `.rename(oldKey, newKey, notifyObject?)` | Rename a key in an object |
487
+ | `.notify()` | Manually trigger subscribers |
488
+ | `.useCompute(fn)` | Derive a computed value |
489
+ | `.derived({ from, to })` | Create bidirectional transform |
490
+ | `.Render({ children })` | Render prop component |
491
+ | `.Show({ children, on })` | Conditional render component |
489
492
 
490
493
  ## License
491
494
 
package/dist/form.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- import type { ArrayProxy, State } from './types';
3
- export { useForm, type CreateFormOptions, type DeepNonNullable, type FormArrayProxy, type FormDeepProxy, type FormState, type FormStore };
1
+ import type { FieldPath, FieldPathValue, FieldValues, Primitive } from './path';
2
+ import type { ArrayProxy, ExcludeNullUndefined, ObjectMutationMethods, ValueState } from './types';
3
+ export { useForm, type CreateFormOptions, type DeepNonNullable, type FormArrayState, type FormObjectState, type FormState, type FormStore, type FormValueState };
4
4
  /**
5
5
  * Common form field methods available on every form state node.
6
6
  */
@@ -12,20 +12,23 @@ type FormCommon = {
12
12
  /** Manually set a validation error. */
13
13
  setError: (error: string | undefined) => void;
14
14
  };
15
- type FormArrayProxy<T> = ArrayProxy<T> & FormCommon;
16
- type FormState<T> = State<T> & FormCommon;
15
+ type FormState<T> = [ExcludeNullUndefined<T>] extends [readonly (infer U)[]] ? FormArrayState<U> : [T] extends [FieldValues] ? FormObjectState<T> : [ExcludeNullUndefined<T>] extends [FieldValues] ? FormObjectState<ExcludeNullUndefined<T>> : FormValueState<T>;
16
+ interface FormValueState<T> extends ValueState<T>, FormCommon {
17
+ }
18
+ type FormArrayElementState<T> = T extends Primitive ? FormValueState<T> : FormState<T>;
19
+ interface FormArrayState<T> extends FormValueState<T[]>, ArrayProxy<T, FormArrayElementState<T>> {
20
+ }
21
+ type FormObjectState<T extends FieldValues> = {
22
+ [K in keyof T]-?: FormState<T[K]>;
23
+ } & FormValueState<T> & ObjectMutationMethods;
17
24
  /** Type for nested objects with proxy methods */
18
- type FormDeepProxy<T> = NonNullable<T> extends readonly (infer U)[] ? FormArrayProxy<U> & FormState<T> : NonNullable<T> extends FieldValues ? {
19
- [K in keyof NonNullable<T>]-?: NonNullable<NonNullable<T>[K]> extends object ? FormDeepProxy<NonNullable<T>[K]> : FormState<NonNullable<T>[K]>;
20
- } & FormState<T> : FormState<T>;
21
- /** Type for nested objects with proxy methods */
22
- type DeepNonNullable<T> = NonNullable<T> extends readonly (infer U)[] ? U[] : NonNullable<T> extends FieldValues ? {
23
- [K in keyof NonNullable<T>]-?: DeepNonNullable<NonNullable<T>[K]>;
24
- } : NonNullable<T>;
25
+ type DeepNonNullable<T> = [ExcludeNullUndefined<T>] extends [readonly (infer U)[]] ? U[] : [ExcludeNullUndefined<T>] extends [FieldValues] ? {
26
+ [K in keyof ExcludeNullUndefined<T>]-?: DeepNonNullable<ExcludeNullUndefined<T>[K]>;
27
+ } : ExcludeNullUndefined<T>;
25
28
  /**
26
29
  * The form store type, combining form state with validation and submission handling.
27
30
  */
28
- type FormStore<T extends FieldValues> = FormDeepProxy<T> & {
31
+ type FormStore<T extends FieldValues> = FormState<T> & {
29
32
  /** Clears all validation errors from the form. */
30
33
  clearErrors(): void;
31
34
  /** Returns a form submit handler that validates and calls onSubmit with form values. */
package/dist/impl.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe };
2
+ export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
3
3
  declare function isClass(value: unknown): boolean;
4
4
  /** Compare two values for equality
5
5
  * @description
@@ -31,9 +31,21 @@ declare function getNestedValue(obj: unknown, path: string): unknown;
31
31
  * Child listeners are only notified if their specific value actually changed,
32
32
  * determined by deep equality comparison.
33
33
  */
34
- declare function notifyListeners(key: string, oldValue: unknown, newValue: unknown, skipRoot?: boolean, skipChildren?: boolean): void;
34
+ declare function notifyListeners(key: string, oldValue: unknown, newValue: unknown, { skipRoot, skipChildren, forceNotify }?: {
35
+ skipRoot?: boolean | undefined;
36
+ skipChildren?: boolean | undefined;
37
+ forceNotify?: boolean | undefined;
38
+ }): void;
35
39
  /** Snapshot getter used by React's useSyncExternalStore. */
36
40
  declare function getSnapshot(key: string): unknown;
41
+ /**
42
+ * Subscribes to changes for a specific key.
43
+ *
44
+ * @param key - The full key path to subscribe to
45
+ * @param listener - Callback invoked when the value changes
46
+ * @returns An unsubscribe function to remove the listener
47
+ */
48
+ declare function subscribe(key: string, listener: () => void): () => void;
37
49
  /**
38
50
  * Core mutation function that updates the store and notifies listeners.
39
51
  *
@@ -46,6 +58,21 @@ declare function getSnapshot(key: string): unknown;
46
58
  * @param memoryOnly - When true, skips localStorage persistence
47
59
  */
48
60
  declare function produce(key: string, value: unknown, skipUpdate?: boolean, memoryOnly?: boolean): void;
61
+ /**
62
+ * Renames a key in an object.
63
+ *
64
+ * It trigger updates to
65
+ *
66
+ * - listeners to `path` (key is updated)
67
+ * - listeners to `path.oldKey` (deleted)
68
+ * - listeners to `path.newKey` (created)
69
+ *
70
+ * @param path - The full key path to rename
71
+ * @param oldKey - The old key to rename
72
+ * @param newKey - The new key to rename to
73
+ * @param notifyObject - Whether to notify listeners to the object path
74
+ */
75
+ declare function rename(path: string, oldKey: string, newKey: string, notifyObject?: boolean): void;
49
76
  /**
50
77
  * React hook that subscribes to and reads a value at a path.
51
78
  *
package/dist/impl.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import rfcIsEqual from 'react-fast-compare';
3
3
  import { localStorageDelete, localStorageGet, localStorageSet } from './local_storage';
4
- export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe };
4
+ export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
5
5
  const memoryStore = new Map();
6
+ const listeners = new Map();
7
+ const descendantListenerKeysByPrefix = new Map();
6
8
  // check if the value is a class instance
7
9
  function isClass(value) {
8
10
  if (value === null || value === undefined)
@@ -14,7 +16,7 @@ function isClass(value) {
14
16
  return false;
15
17
  const descriptors = Object.getOwnPropertyDescriptors(proto);
16
18
  for (const key in descriptors) {
17
- if (descriptors[key].get)
19
+ if (descriptors[key]?.get)
18
20
  return true;
19
21
  }
20
22
  return false;
@@ -28,6 +30,8 @@ function isClass(value) {
28
30
  * @returns True if the values are equal, false otherwise
29
31
  */
30
32
  function isEqual(a, b) {
33
+ if (a === b)
34
+ return true;
31
35
  if (isClass(a) || isClass(b))
32
36
  return a === b;
33
37
  return rfcIsEqual(a, b);
@@ -68,34 +72,6 @@ function getNestedValue(obj, path) {
68
72
  }
69
73
  return current;
70
74
  }
71
- /**
72
- * Creates a deep clone of an object, optimized for common cases.
73
- *
74
- * Uses fast paths for primitives and arrays of primitives, falling back
75
- * to structuredClone for complex objects. Returns null if cloning fails.
76
- *
77
- * @param obj - The value to clone
78
- * @returns A deep copy of the value, or null if cloning fails
79
- */
80
- function tryStructuredClone(obj) {
81
- if (obj === null || obj === undefined)
82
- return null;
83
- if (typeof obj !== 'object')
84
- return obj;
85
- if (Array.isArray(obj)) {
86
- const needsDeepClone = obj.some(item => item !== null && typeof item === 'object');
87
- if (!needsDeepClone) {
88
- return [...obj];
89
- }
90
- return obj.map(item => tryStructuredClone(item));
91
- }
92
- try {
93
- return structuredClone(obj);
94
- }
95
- catch {
96
- return null;
97
- }
98
- }
99
75
  /**
100
76
  * Immutably sets or deletes a nested value using a dot-separated path.
101
77
  *
@@ -112,8 +88,15 @@ function setNestedValue(obj, path, value) {
112
88
  if (!path)
113
89
  return value;
114
90
  const segments = path.split('.');
115
- const result = segments.length === 1 ? obj : tryStructuredClone(obj);
116
- let current = result ?? {};
91
+ if (obj !== null && obj !== undefined && typeof obj !== 'object') {
92
+ return obj;
93
+ }
94
+ const result = obj === null || obj === undefined
95
+ ? {}
96
+ : Array.isArray(obj)
97
+ ? [...obj]
98
+ : { ...obj };
99
+ let current = result;
117
100
  for (let i = 0; i < segments.length - 1; i++) {
118
101
  const segment = segments[i];
119
102
  const nextSegment = segments[i + 1];
@@ -122,17 +105,41 @@ function setNestedValue(obj, path, value) {
122
105
  const index = Number(segment);
123
106
  if (Number.isNaN(index))
124
107
  break;
125
- if (!current[index]) {
126
- current[index] = isNextIndex ? [] : {};
108
+ const existing = current[index];
109
+ let next;
110
+ if (existing === null || existing === undefined) {
111
+ next = isNextIndex ? [] : {};
127
112
  }
128
- current = current[index];
113
+ else if (typeof existing !== 'object') {
114
+ next = isNextIndex ? [] : {};
115
+ }
116
+ else if (Array.isArray(existing)) {
117
+ next = [...existing];
118
+ }
119
+ else {
120
+ next = { ...existing };
121
+ }
122
+ current[index] = next;
123
+ current = next;
129
124
  }
130
125
  else if (typeof current === 'object' && current !== null) {
131
126
  const currentObj = current;
132
- if (!currentObj[segment]) {
133
- currentObj[segment] = isNextIndex ? [] : {};
127
+ const existing = currentObj[segment];
128
+ let next;
129
+ if (existing === null || existing === undefined) {
130
+ next = isNextIndex ? [] : {};
131
+ }
132
+ else if (typeof existing !== 'object') {
133
+ next = isNextIndex ? [] : {};
134
+ }
135
+ else if (Array.isArray(existing)) {
136
+ next = [...existing];
137
+ }
138
+ else {
139
+ next = { ...existing };
134
140
  }
135
- current = currentObj[segment];
141
+ currentObj[segment] = next;
142
+ current = next;
136
143
  }
137
144
  }
138
145
  const lastSegment = segments[segments.length - 1];
@@ -161,21 +168,46 @@ function setNestedValue(obj, path, value) {
161
168
  /**
162
169
  * Extracts the root namespace from a full key.
163
170
  *
164
- * @param key - Full key string (e.g., "app.user.name")
165
- * @returns The first segment (e.g., "app")
171
+ * @param key - Full key string
172
+ * @returns Namespace
173
+ * @example
174
+ * getNamespace('app.user.name') // 'app'
166
175
  */
167
- function getRootKey(key) {
168
- return key.split('.')[0];
176
+ function getNamespace(key) {
177
+ const index = key.indexOf('.');
178
+ if (index === -1)
179
+ return key;
180
+ return key.slice(0, index);
169
181
  }
170
182
  /**
171
- * Extracts the nested path from a full key, excluding the namespace.
183
+ * Extracts the namespace and path from a full key.
172
184
  *
173
- * @param key - Full key string (e.g., "app.user.name")
174
- * @returns The path after the namespace (e.g., "user.name")
185
+ * @param key - Full key string
186
+ * @returns [namespace, path]
187
+ * @example
188
+ * splitNSPath('app.user.name') // ['app', 'user.name']
175
189
  */
176
- function getPath(key) {
177
- const segments = key.split('.');
178
- return segments.slice(1).join('.');
190
+ function splitNSPath(key) {
191
+ const index = key.indexOf('.');
192
+ if (index === -1)
193
+ return [key, ''];
194
+ return [key.slice(0, index), key.slice(index + 1)];
195
+ }
196
+ function getKeyPrefixes(key) {
197
+ const dot = key.indexOf('.');
198
+ if (dot === -1)
199
+ return [];
200
+ const parts = key.split('.');
201
+ if (parts.length <= 1)
202
+ return [];
203
+ const prefixes = [];
204
+ let current = parts[0];
205
+ for (let i = 1; i < parts.length - 1; i++) {
206
+ current += '.' + parts[i];
207
+ prefixes.push(current);
208
+ }
209
+ prefixes.unshift(parts[0]);
210
+ return prefixes;
179
211
  }
180
212
  /**
181
213
  * Notifies all relevant listeners when a value changes.
@@ -188,35 +220,64 @@ function getPath(key) {
188
220
  * Child listeners are only notified if their specific value actually changed,
189
221
  * determined by deep equality comparison.
190
222
  */
191
- function notifyListeners(key, oldValue, newValue, skipRoot = false, skipChildren = false) {
192
- const rootKey = skipRoot ? null : key.split('.').slice(0, 2).join('.');
193
- const keyPrefix = skipChildren ? null : key + '.';
194
- // Single pass: collect listeners to notify
195
- const listenersToNotify = new Set();
196
- for (const [listenerKey, listenerSet] of listeners.entries()) {
197
- if (listenerKey === key) {
198
- // Exact key match
199
- listenerSet.forEach(listener => listenersToNotify.add(listener));
223
+ function notifyListeners(key, oldValue, newValue, { skipRoot = false, skipChildren = false, forceNotify = false } = {}) {
224
+ if (skipRoot && skipChildren) {
225
+ if (!forceNotify && isEqual(oldValue, newValue)) {
226
+ return;
200
227
  }
201
- else if (rootKey && listenerKey === rootKey) {
202
- // Root key match
203
- listenerSet.forEach(listener => listenersToNotify.add(listener));
228
+ // exact match only
229
+ const listenerSet = listeners.get(key);
230
+ if (listenerSet) {
231
+ listenerSet.forEach(listener => listener());
204
232
  }
205
- else if (keyPrefix && listenerKey.startsWith(keyPrefix)) {
206
- // Child key match - check if value actually changed
207
- const childPath = listenerKey.substring(key.length + 1);
208
- const oldChildValue = getNestedValue(oldValue, childPath);
209
- const newChildValue = getNestedValue(newValue, childPath);
210
- if (!isEqual(oldChildValue, newChildValue)) {
211
- listenerSet.forEach(listener => listenersToNotify.add(listener));
233
+ return;
234
+ }
235
+ // Exact key match
236
+ const exactSet = listeners.get(key);
237
+ if (exactSet) {
238
+ exactSet.forEach(listener => listener());
239
+ }
240
+ // Ancestor keys match (including namespace root)
241
+ if (!skipRoot) {
242
+ const namespace = getNamespace(key);
243
+ const rootSet = listeners.get(namespace);
244
+ if (rootSet) {
245
+ rootSet.forEach(listener => listener());
246
+ }
247
+ // Also notify intermediate ancestors
248
+ const prefixes = getKeyPrefixes(key);
249
+ for (const prefix of prefixes) {
250
+ if (prefix === namespace)
251
+ continue; // Already handled
252
+ const prefixSet = listeners.get(prefix);
253
+ if (prefixSet) {
254
+ prefixSet.forEach(listener => listener());
255
+ }
256
+ }
257
+ }
258
+ // Child key match - check if value actually changed
259
+ if (!skipChildren) {
260
+ const childKeys = descendantListenerKeysByPrefix.get(key);
261
+ if (childKeys) {
262
+ for (const childKey of childKeys) {
263
+ const childPath = childKey.slice(key.length + 1);
264
+ const oldChildValue = getNestedValue(oldValue, childPath);
265
+ const newChildValue = getNestedValue(newValue, childPath);
266
+ if (forceNotify || !isEqual(oldChildValue, newChildValue)) {
267
+ const childSet = listeners.get(childKey);
268
+ if (childSet) {
269
+ childSet.forEach(listener => listener());
270
+ }
271
+ }
212
272
  }
213
273
  }
214
274
  }
215
- // Notify all collected listeners
216
- listenersToNotify.forEach(listener => listener());
275
+ }
276
+ function forceNotifyListeners(key, options = {}) {
277
+ notifyListeners(key, undefined, undefined, { ...options, forceNotify: true });
217
278
  }
218
279
  // BroadcastChannel for cross-tab synchronization
219
- const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('godoxy-producer-consumer') : null;
280
+ const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('juststore') : null;
220
281
  /**
221
282
  * Backing store providing in-memory data with localStorage persistence
222
283
  * and cross-tab synchronization. All operations are namespaced at the root key
@@ -224,13 +285,12 @@ const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('g
224
285
  */
225
286
  const store = {
226
287
  has(key) {
227
- const rootKey = getRootKey(key);
288
+ const rootKey = getNamespace(key);
228
289
  return (memoryStore.has(rootKey) ||
229
290
  (typeof window !== 'undefined' && localStorageGet(rootKey) !== undefined));
230
291
  },
231
292
  get(key) {
232
- const rootKey = getRootKey(key);
233
- const path = getPath(key);
293
+ const [rootKey, path] = splitNSPath(key);
234
294
  // Get root object from memory or localStorage
235
295
  let rootValue;
236
296
  if (memoryStore.has(rootKey)) {
@@ -249,8 +309,10 @@ const store = {
249
309
  return getNestedValue(rootValue, path);
250
310
  },
251
311
  set(key, value, memoryOnly = false) {
252
- const rootKey = getRootKey(key);
253
- const path = getPath(key);
312
+ if (value === undefined) {
313
+ return this.delete(key, memoryOnly);
314
+ }
315
+ const [rootKey, path] = splitNSPath(key);
254
316
  let rootValue;
255
317
  if (!path) {
256
318
  // Setting root value directly
@@ -262,12 +324,7 @@ const store = {
262
324
  rootValue = setNestedValue(currentRoot, path, value);
263
325
  }
264
326
  // Update memory
265
- if (rootValue === undefined) {
266
- memoryStore.delete(rootKey);
267
- }
268
- else {
269
- memoryStore.set(rootKey, rootValue);
270
- }
327
+ memoryStore.set(rootKey, rootValue);
271
328
  // Persist to localStorage (unless memoryOnly)
272
329
  if (!memoryOnly && typeof window !== 'undefined') {
273
330
  localStorageSet(rootKey, rootValue);
@@ -278,8 +335,7 @@ const store = {
278
335
  }
279
336
  },
280
337
  delete(key, memoryOnly = false) {
281
- const rootKey = getRootKey(key);
282
- const path = getPath(key);
338
+ const [rootKey, path] = splitNSPath(key);
283
339
  if (!path) {
284
340
  // Deleting root key
285
341
  memoryStore.delete(rootKey);
@@ -329,25 +385,9 @@ if (broadcastChannel) {
329
385
  }
330
386
  // Notify all listeners that might be affected by this root key change
331
387
  const newRootValue = type === 'delete' ? undefined : value;
332
- for (const listenerKey of listeners.keys()) {
333
- if (listenerKey === key) {
334
- // Direct key match - notify with old and new values
335
- notifyListeners(listenerKey, oldRootValue, newRootValue);
336
- }
337
- else if (listenerKey.startsWith(key + '.')) {
338
- // Child key - check if its value actually changed
339
- const childPath = listenerKey.substring(key.length + 1);
340
- const oldChildValue = getNestedValue(oldRootValue, childPath);
341
- const newChildValue = getNestedValue(newRootValue, childPath);
342
- if (!isEqual(oldChildValue, newChildValue)) {
343
- const childListeners = listeners.get(listenerKey);
344
- childListeners?.forEach(listener => listener());
345
- }
346
- }
347
- }
388
+ notifyListeners(key, oldRootValue, newRootValue);
348
389
  });
349
390
  }
350
- const listeners = new Map();
351
391
  /**
352
392
  * Subscribes to changes for a specific key.
353
393
  *
@@ -360,6 +400,13 @@ function subscribe(key, listener) {
360
400
  listeners.set(key, new Set());
361
401
  }
362
402
  listeners.get(key).add(listener);
403
+ const prefixes = getKeyPrefixes(key);
404
+ for (const prefix of prefixes) {
405
+ if (!descendantListenerKeysByPrefix.has(prefix)) {
406
+ descendantListenerKeysByPrefix.set(prefix, new Set());
407
+ }
408
+ descendantListenerKeysByPrefix.get(prefix).add(key);
409
+ }
363
410
  return () => {
364
411
  const keyListeners = listeners.get(key);
365
412
  if (keyListeners) {
@@ -368,6 +415,15 @@ function subscribe(key, listener) {
368
415
  listeners.delete(key);
369
416
  }
370
417
  }
418
+ for (const prefix of prefixes) {
419
+ const prefixKeys = descendantListenerKeysByPrefix.get(prefix);
420
+ if (prefixKeys) {
421
+ prefixKeys.delete(key);
422
+ if (prefixKeys.size === 0) {
423
+ descendantListenerKeysByPrefix.delete(prefix);
424
+ }
425
+ }
426
+ }
371
427
  };
372
428
  }
373
429
  /**
@@ -383,20 +439,50 @@ function subscribe(key, listener) {
383
439
  */
384
440
  function produce(key, value, skipUpdate = false, memoryOnly = false) {
385
441
  const current = store.get(key);
386
- if (value === undefined) {
387
- skipUpdate = current === undefined;
388
- store.delete(key, memoryOnly);
389
- }
390
- else {
391
- if (isEqual(current, value))
392
- return;
393
- store.set(key, value, memoryOnly);
394
- }
442
+ if (isEqual(current, value))
443
+ return;
444
+ store.set(key, value, memoryOnly);
395
445
  if (skipUpdate)
396
446
  return;
397
447
  // Notify listeners hierarchically with old and new values
398
448
  notifyListeners(key, current, value);
399
449
  }
450
+ /**
451
+ * Renames a key in an object.
452
+ *
453
+ * It trigger updates to
454
+ *
455
+ * - listeners to `path` (key is updated)
456
+ * - listeners to `path.oldKey` (deleted)
457
+ * - listeners to `path.newKey` (created)
458
+ *
459
+ * @param path - The full key path to rename
460
+ * @param oldKey - The old key to rename
461
+ * @param newKey - The new key to rename to
462
+ * @param notifyObject - Whether to notify listeners to the object path
463
+ */
464
+ function rename(path, oldKey, newKey, notifyObject = true) {
465
+ const current = store.get(path);
466
+ if (current === undefined || current === null || typeof current !== 'object') {
467
+ // assign a new object with the new key
468
+ store.set(path, { [newKey]: undefined });
469
+ if (notifyObject) {
470
+ forceNotifyListeners(path, { skipChildren: true });
471
+ }
472
+ return;
473
+ }
474
+ const oldValue = current[oldKey];
475
+ const newObject = { ...current, [oldKey]: undefined, [newKey]: oldValue };
476
+ delete newObject[oldKey];
477
+ store.set(path, newObject);
478
+ if (oldValue !== undefined) {
479
+ forceNotifyListeners(joinPath(path, oldKey));
480
+ }
481
+ forceNotifyListeners(joinPath(path, newKey));
482
+ if (notifyObject) {
483
+ forceNotifyListeners(path, { skipChildren: true });
484
+ }
485
+ }
400
486
  /**
401
487
  * React hook that subscribes to and reads a value at a path.
402
488
  *
@@ -487,7 +573,8 @@ const __pc_debug = {
487
573
  getStoreSize: () => store.size,
488
574
  getListenerSize: () => listeners.size,
489
575
  getStore: () => memoryStore,
490
- getStoreValue: (key) => memoryStore.get(key)
576
+ getStoreValue: (key) => memoryStore.get(key),
577
+ getListeners: () => listeners
491
578
  };
492
579
  // Expose debug in browser for quick inspection during development
493
580
  if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
package/dist/memory.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FieldValues } from './path';
2
- import type { DeepProxy, State } from './types';
2
+ import type { State } from './types';
3
3
  export { useMemoryStore, type MemoryStore };
4
4
  /**
5
5
  * A component local store with React bindings.
@@ -11,7 +11,7 @@ export { useMemoryStore, type MemoryStore };
11
11
  * - Dynamic deep access via Proxy for ergonomic usage like `state.a.b.c.use()` and `state.a.b.c.set(v)`.
12
12
  */
13
13
  type MemoryStore<T extends FieldValues> = State<T> & {
14
- [K in keyof T]: NonNullable<T[K]> extends object ? DeepProxy<T[K]> : State<T[K]>;
14
+ [K in keyof T]-?: State<T[K]>;
15
15
  };
16
16
  /**
17
17
  * React hook that creates a component-scoped memory store.
@@ -1,10 +1,10 @@
1
- import type { Prettify, State } from './types';
1
+ import type { Prettify, State, ValueState } from './types';
2
2
  export { createMixedState, type MixedState };
3
3
  /**
4
4
  * A combined state that aggregates multiple independent states into a tuple.
5
5
  * Provides read-only access via `value`, `use`, `Render`, and `Show`.
6
6
  */
7
- type MixedState<T extends readonly unknown[]> = Prettify<Pick<State<Readonly<T>>, 'value' | 'use' | 'Render' | 'Show'>>;
7
+ type MixedState<T extends readonly unknown[]> = Prettify<Pick<ValueState<Readonly<Required<T>>>, 'value' | 'use' | 'Render' | 'Show'>>;
8
8
  /**
9
9
  * Creates a mixed state that combines multiple states into a tuple.
10
10
  *
@@ -21,5 +21,5 @@ type MixedState<T extends readonly unknown[]> = Prettify<Pick<State<Readonly<T>>
21
21
  * </mixedState.Render>
22
22
  */
23
23
  declare function createMixedState<T extends readonly unknown[]>(...states: {
24
- [K in keyof T]: State<T[K]>;
24
+ [K in keyof T]-?: State<T[K]>;
25
25
  }): MixedState<T>;
package/dist/node.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FieldValues } from './path';
1
+ import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
2
  import type { State, StoreRoot } from './types';
3
3
  export { createNode, createRootNode, type Extension };
4
4
  /**
@@ -11,7 +11,7 @@ export { createNode, createRootNode, type Extension };
11
11
  * @param initialPath - Starting path segment (default: empty string for root)
12
12
  * @returns A proxy that intercepts property access and returns nested proxies or state methods
13
13
  */
14
- declare function createRootNode<T extends FieldValues>(storeApi: StoreRoot<T>, initialPath?: string): State<T>;
14
+ declare function createRootNode<T extends FieldValues, P extends FieldPath<T>>(storeApi: StoreRoot<T>, initialPath?: P): State<FieldPathValue<T, P>>;
15
15
  /**
16
16
  * Extension interface for adding custom getters/setters to proxy nodes.
17
17
  * Used internally by form handling to add error-related methods.
package/dist/node.js CHANGED
@@ -30,13 +30,14 @@ function createRootNode(storeApi, initialPath = '') {
30
30
  */
31
31
  function createNode(storeApi, path, cache, extensions, from = unchanged, to = unchanged) {
32
32
  const isDerived = from !== unchanged || to !== unchanged;
33
+ const fieldName = path.split('.').pop();
33
34
  if (!isDerived && cache.has(path)) {
34
35
  return cache.get(path);
35
36
  }
36
37
  const proxy = new Proxy({}, {
37
38
  get(_target, prop) {
38
39
  if (prop === 'field') {
39
- return path.split('.').pop();
40
+ return fieldName;
40
41
  }
41
42
  if (prop === 'use') {
42
43
  return () => from(storeApi.use(path));
@@ -45,7 +46,10 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
45
46
  return (delay) => from(storeApi.useDebounce(path, delay));
46
47
  }
47
48
  if (prop === 'useState') {
48
- return () => [from(storeApi.use(path)), (value) => storeApi.set(path, to(value))];
49
+ return () => {
50
+ const value = storeApi.use(path);
51
+ return [from(value), (next) => storeApi.set(path, to(next))];
52
+ };
49
53
  }
50
54
  if (prop === 'value') {
51
55
  return from(storeApi.value(path));
@@ -75,11 +79,36 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
75
79
  }
76
80
  if (prop === 'derived') {
77
81
  if (isDerived) {
78
- throw new Error('Derived method cannot be called on a derived node');
82
+ throw new Error(`Derived method cannot be called on a derived node: ${path}`);
79
83
  }
80
84
  return ({ from, to }) => createNode(storeApi, path, cache, extensions, from, to);
81
85
  }
82
- if (Array.isArray(storeApi.value(path))) {
86
+ if (prop === 'notify') {
87
+ return () => storeApi.notify(path);
88
+ }
89
+ if (prop === 'ensureArray') {
90
+ return () => createNode(storeApi, path, cache, extensions, ensureArray, unchanged);
91
+ }
92
+ if (prop === 'ensureObject') {
93
+ return () => createNode(storeApi, path, cache, extensions, ensureObject, unchanged);
94
+ }
95
+ if (isObjectMethod(prop)) {
96
+ const derivedValue = from(storeApi.value(path));
97
+ if (derivedValue !== undefined && typeof derivedValue !== 'object') {
98
+ throw new Error(`Expected object at path ${path}, got ${typeof derivedValue}`);
99
+ }
100
+ if (prop === 'rename') {
101
+ return (oldKey, newKey, notifyObject) => {
102
+ storeApi.rename(path, oldKey, newKey, notifyObject);
103
+ };
104
+ }
105
+ }
106
+ if (isArrayMethod(prop)) {
107
+ const derivedValue = from(storeApi.value(path));
108
+ if (derivedValue !== undefined && !Array.isArray(derivedValue)) {
109
+ throw new Error(`Expected array at path ${path}, got ${typeof derivedValue}`);
110
+ }
111
+ const currentArray = derivedValue ? [...derivedValue] : [];
83
112
  if (prop === 'at') {
84
113
  return (index) => {
85
114
  const nextPath = path ? `${path}.${index}` : String(index);
@@ -87,104 +116,86 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
87
116
  };
88
117
  }
89
118
  if (prop === 'length') {
90
- const value = from(storeApi.value(path));
91
- return Array.isArray(value) ? value.length : undefined;
119
+ return currentArray.length;
92
120
  }
93
121
  // Array mutation methods
94
122
  if (prop === 'push') {
95
123
  return (...items) => {
96
- const currentArray = from(storeApi.value(path)) ?? [];
97
- const transformedItems = isDerived ? items.map(to) : items;
98
- const newArray = [...currentArray, ...transformedItems];
99
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
124
+ const newArray = [...currentArray, ...items];
125
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
100
126
  return newArray.length;
101
127
  };
102
128
  }
103
129
  if (prop === 'pop') {
104
130
  return () => {
105
- const currentArray = from(storeApi.value(path)) ?? [];
106
131
  if (currentArray.length === 0)
107
132
  return undefined;
108
133
  const newArray = currentArray.slice(0, -1);
109
134
  const poppedItem = currentArray[currentArray.length - 1];
110
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
135
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
111
136
  return poppedItem;
112
137
  };
113
138
  }
114
139
  if (prop === 'shift') {
115
140
  return () => {
116
- const currentArray = from(storeApi.value(path)) ?? [];
117
141
  if (currentArray.length === 0)
118
142
  return undefined;
119
143
  const newArray = currentArray.slice(1);
120
144
  const shiftedItem = currentArray[0];
121
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
145
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
122
146
  return shiftedItem;
123
147
  };
124
148
  }
125
149
  if (prop === 'unshift') {
126
150
  return (...items) => {
127
- const currentArray = from(storeApi.value(path)) ?? [];
128
- const transformedItems = isDerived ? items.map(to) : items;
129
- const newArray = [...transformedItems, ...currentArray];
130
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
151
+ const newArray = [...items, ...currentArray];
152
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
131
153
  return newArray.length;
132
154
  };
133
155
  }
134
156
  if (prop === 'splice') {
135
157
  return (start, deleteCount, ...items) => {
136
- const currentArray = from(storeApi.value(path)) ?? [];
137
- const newArray = [...currentArray];
138
- const transformedItems = isDerived ? items.map(to) : items;
139
- const deletedItems = newArray.splice(start, deleteCount ?? 0, ...transformedItems);
140
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
158
+ const deletedItems = currentArray.splice(start, deleteCount ?? 0, ...items);
159
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
141
160
  return deletedItems;
142
161
  };
143
162
  }
144
163
  if (prop === 'reverse') {
145
164
  return () => {
146
- const currentArray = from(storeApi.value(path)) ?? [];
147
165
  if (!Array.isArray(currentArray))
148
166
  return [];
149
- const newArray = [...currentArray].reverse();
150
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
151
- return newArray;
167
+ currentArray.reverse();
168
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
169
+ return currentArray;
152
170
  };
153
171
  }
154
172
  if (prop === 'sort') {
155
173
  return (compareFn) => {
156
- const currentArray = from(storeApi.value(path)) ?? [];
157
- const newArray = [...currentArray].sort(compareFn);
158
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
159
- return newArray;
174
+ currentArray.sort(compareFn);
175
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
176
+ return currentArray;
160
177
  };
161
178
  }
162
179
  if (prop === 'fill') {
163
180
  return (value, start, end) => {
164
- const currentArray = from(storeApi.value(path)) ?? [];
165
- const newArray = [...currentArray].fill(value, start, end);
166
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
167
- return newArray;
181
+ currentArray.fill(value, start, end);
182
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
183
+ return currentArray;
168
184
  };
169
185
  }
170
186
  if (prop === 'copyWithin') {
171
187
  return (target, start, end) => {
172
- const currentArray = from(storeApi.value(path)) ?? [];
173
- const newArray = [...currentArray].copyWithin(target, start, end);
174
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
175
- return newArray;
188
+ currentArray.copyWithin(target, start, end);
189
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
190
+ return currentArray;
176
191
  };
177
192
  }
178
193
  if (prop === 'sortedInsert') {
179
194
  return (cmp, ...items) => {
180
- const currentArray = from(storeApi.value(path)) ?? [];
181
195
  if (typeof cmp !== 'function')
182
- return isDerived ? currentArray.map(from).length : currentArray.length;
183
- // Create a copy of the current array
184
- const newArray = isDerived ? currentArray.map(from) : [...currentArray];
185
- const transformedItems = isDerived ? items.map(to) : items;
186
- // Insert each item in sorted order using binary search
187
- for (const item of transformedItems) {
196
+ return currentArray.length;
197
+ const newArray = [...currentArray];
198
+ for (const item of items) {
188
199
  let left = 0;
189
200
  let right = newArray.length;
190
201
  // Binary search to find insertion point
@@ -200,7 +211,7 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
200
211
  // Insert at the found position
201
212
  newArray.splice(left, 0, item);
202
213
  }
203
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
214
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
204
215
  return newArray.length;
205
216
  };
206
217
  }
@@ -220,7 +231,7 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
220
231
  return extensions[prop]?.set(value);
221
232
  }
222
233
  if (typeof prop === 'string' || typeof prop === 'number') {
223
- const nextPath = path ? `${path}.${prop}` : prop;
234
+ const nextPath = path ? `${path}.${prop}` : String(prop);
224
235
  storeApi.set(nextPath, to(value));
225
236
  return true;
226
237
  }
@@ -232,6 +243,37 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
232
243
  }
233
244
  return proxy;
234
245
  }
246
+ function isArrayMethod(prop) {
247
+ return (prop === 'at' ||
248
+ prop === 'length' ||
249
+ prop === 'push' ||
250
+ prop === 'pop' ||
251
+ prop === 'shift' ||
252
+ prop === 'unshift' ||
253
+ prop === 'splice' ||
254
+ prop === 'reverse' ||
255
+ prop === 'sort' ||
256
+ prop === 'fill' ||
257
+ prop === 'copyWithin' ||
258
+ prop === 'sortedInsert');
259
+ }
260
+ function isObjectMethod(prop) {
261
+ return prop === 'rename';
262
+ }
235
263
  function unchanged(value) {
236
264
  return value;
237
265
  }
266
+ function ensureArray(value) {
267
+ if (value === undefined || value === null)
268
+ return [];
269
+ if (Array.isArray(value))
270
+ return value;
271
+ return [];
272
+ }
273
+ function ensureObject(value) {
274
+ if (value === undefined || value === null)
275
+ return {};
276
+ if (typeof value === 'object')
277
+ return value;
278
+ return {};
279
+ }
package/dist/root.js CHANGED
@@ -1,5 +1,6 @@
1
- import { useCallback, useRef, useState } from 'react';
2
- import { getNestedValue, getSnapshot, isEqual, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe } from './impl';
1
+ import { useCallback, useRef, useSyncExternalStore } from 'react';
2
+ import { getNestedValue, getSnapshot, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe } from './impl';
3
+ import { createRootNode } from './node';
3
4
  export { createStoreRoot };
4
5
  /**
5
6
  * Creates the core store API with path-based methods.
@@ -20,6 +21,7 @@ function createStoreRoot(namespace, defaultValue, options = {}) {
20
21
  }
21
22
  produce(namespace, { ...defaultValue, ...(getSnapshot(namespace) ?? {}) }, true, true);
22
23
  const storeApi = {
24
+ state: (path) => createRootNode(storeApi, path),
23
25
  use: (path) => useObject(namespace, path),
24
26
  useDebounce: (path, delay) => useDebounce(namespace, path, delay),
25
27
  set: (path, value, skipUpdate = false) => {
@@ -31,24 +33,37 @@ function createStoreRoot(namespace, defaultValue, options = {}) {
31
33
  },
32
34
  value: (path) => getSnapshot(joinPath(namespace, path)),
33
35
  reset: (path) => produce(joinPath(namespace, path), undefined, false, memoryOnly),
36
+ rename: (path, oldKey, newKey, notifyObject = true) => rename(joinPath(namespace, path), oldKey, newKey, notifyObject),
34
37
  subscribe: (path, listener) =>
35
38
  // eslint-disable-next-line react-hooks/rules-of-hooks
36
39
  useSubscribe(joinPath(namespace, path), listener),
37
40
  useCompute: (path, fn) => {
38
41
  const fullPath = joinPath(namespace, path);
39
- const initialValue = getSnapshot(fullPath);
40
- const [computedValue, setComputedValue] = useState(() => fn(initialValue));
41
- useSubscribe(fullPath, value => {
42
- const newValue = fn(value);
43
- if (!isEqual(computedValue, newValue)) {
44
- setComputedValue(newValue);
42
+ const fnRef = useRef(fn);
43
+ fnRef.current = fn;
44
+ // Cache to avoid infinite loops - only recompute when store value changes
45
+ const cacheRef = useRef(null);
46
+ const subscribeToPath = useCallback((onStoreChange) => subscribe(fullPath, onStoreChange), [fullPath]);
47
+ const getComputedSnapshot = useCallback(() => {
48
+ const storeValue = getSnapshot(fullPath);
49
+ // Return cached result if store value hasn't changed
50
+ if (cacheRef.current && cacheRef.current.storeValue === storeValue) {
51
+ return cacheRef.current.computed;
45
52
  }
46
- });
47
- return computedValue;
53
+ // Recompute and cache
54
+ const computed = fnRef.current(storeValue);
55
+ cacheRef.current = { storeValue, computed };
56
+ return computed;
57
+ }, [fullPath]);
58
+ return useSyncExternalStore(subscribeToPath, getComputedSnapshot, getComputedSnapshot);
48
59
  },
49
60
  notify: (path) => {
50
61
  const value = getNestedValue(getSnapshot(namespace), path);
51
- return notifyListeners(joinPath(namespace, path), value, value, true, true);
62
+ return notifyListeners(joinPath(namespace, path), value, value, {
63
+ skipRoot: true,
64
+ skipChildren: true,
65
+ forceNotify: true
66
+ });
52
67
  },
53
68
  useState: (path) => {
54
69
  const fullPathRef = useRef(joinPath(namespace, path));
package/dist/store.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { FieldValues } from './path';
2
2
  import { type StoreOptions } from './root';
3
- import type { DeepProxy, State, StoreRoot } from './types';
3
+ import type { State, StoreRoot } from './types';
4
4
  export { createStore, type Store };
5
5
  /**
6
6
  * A persistent, hierarchical, cross-tab synchronized key-value store with React bindings.
@@ -14,7 +14,7 @@ export { createStore, type Store };
14
14
  * - Dynamic deep access via Proxy for ergonomic usage like `store.a.b.c.use()` and `store.a.b.c.set(v)`.
15
15
  */
16
16
  type Store<T extends FieldValues> = StoreRoot<T> & {
17
- [K in keyof T]: NonNullable<T[K]> extends object ? DeepProxy<T[K]> : State<T[K]>;
17
+ [K in keyof T]-?: State<T[K]>;
18
18
  };
19
19
  /**
20
20
  * Creates a persistent, hierarchical store with localStorage backing and cross-tab synchronization.
package/dist/types.d.ts CHANGED
@@ -1,17 +1,14 @@
1
- import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- export type { ArrayProxy, DeepProxy, DerivedStateProps, Prettify, State, StoreRenderProps, StoreRoot, StoreSetStateAction, StoreShowProps, StoreUse };
1
+ import type { FieldPath, FieldPathValue, FieldValues, Primitive } from './path';
2
+ export type { AllowedKeys, ArrayElementState, ArrayProxy, ArrayState, DerivedStateProps, ExcludeNullUndefined, ObjectMutationMethods, ObjectState, Prettify, State, StoreRenderProps, StoreRoot, StoreSetStateAction, StoreShowProps, StoreUse, ValueState };
3
3
  type Prettify<T> = {
4
4
  [K in keyof T]: T[K];
5
5
  } & {};
6
- /** Type for nested objects with proxy methods */
7
- type DeepProxy<T> = NonNullable<T> extends readonly (infer U)[] ? ArrayProxy<U> & State<T> : NonNullable<T> extends FieldValues ? {
8
- [K in keyof NonNullable<T>]-?: NonNullable<NonNullable<T>[K]> extends object ? DeepProxy<NonNullable<T>[K]> : State<NonNullable<T>[K]>;
9
- } & State<T> : State<T>;
6
+ type AllowedKeys<T> = Exclude<keyof T, keyof ValueState<unknown> | keyof ObjectMutationMethods>;
10
7
  type ArrayMutationMethods<T> = Pick<Array<T>, 'push' | 'pop' | 'shift' | 'unshift' | 'splice' | 'reverse' | 'sort' | 'fill' | 'copyWithin'>;
11
8
  /** Type for array proxy with index access */
12
- type ArrayProxy<T> = Prettify<ArrayMutationMethods<T>> & {
9
+ type ArrayProxy<T, ElementState = ArrayElementState<T>> = Prettify<ArrayMutationMethods<T>> & {
13
10
  /** Read without subscribing. Returns array or undefined for missing paths. */
14
- readonly value: T[] | undefined;
11
+ readonly value: T[];
15
12
  /**
16
13
  * Length of the underlying array. Runtime may return undefined when the
17
14
  * current value is not an array at the path. Prefer `Array.isArray(x) && x.length` when unsure.
@@ -20,42 +17,50 @@ type ArrayProxy<T> = Prettify<ArrayMutationMethods<T>> & {
20
17
  /** Numeric index access never returns undefined at the type level because
21
18
  * the proxy always returns another proxy object, even if the underlying value doesn't exist.
22
19
  */
23
- [K: number]: T extends object ? DeepProxy<T> : State<T>;
20
+ [K: number]: ElementState;
24
21
  /** Safe accessor that never returns undefined at the type level */
25
- at(index: number): T extends object ? DeepProxy<T> : State<T>;
22
+ at(index: number): ElementState;
26
23
  /** Insert items into the array in sorted order using the provided comparison function. */
27
24
  sortedInsert(cmp: (a: T, b: T) => number, ...items: T[]): number;
28
25
  };
26
+ type ObjectMutationMethods = {
27
+ /** Rename a key in an object. */
28
+ rename: (oldKey: string, newKey: string, notifyObject?: boolean) => void;
29
+ };
29
30
  /** Tuple returned by Store.use(path). */
30
31
  type StoreUse<T> = Readonly<[T | undefined, (value: T | undefined) => void]>;
31
32
  type StoreSetStateAction<T> = (value: T | undefined | ((prev: T) => T), skipUpdate?: boolean) => void;
32
33
  /** Public API returned by createStore(namespace, defaultValue). */
33
34
  type StoreRoot<T extends FieldValues> = {
35
+ /** Get the state object for a path. */
36
+ state: <P extends FieldPath<T>>(path: P) => State<FieldPathValue<T, P>>;
34
37
  /** Subscribe and read the value at path. Re-renders when the value changes. */
35
38
  use: <P extends FieldPath<T>>(path: P) => FieldPathValue<T, P> | undefined;
36
39
  /** Subscribe and read the debounced value at path. Re-renders when the value changes. */
37
40
  useDebounce: <P extends FieldPath<T>>(path: P, delay: number) => FieldPathValue<T, P> | undefined;
38
- /** Set value at path (creates intermediate nodes as needed). */
39
- set: <P extends FieldPath<T>>(path: P, value: FieldPathValue<T, P> | ((prev: FieldPathValue<T, P> | undefined) => FieldPathValue<T, P>), skipUpdate?: boolean) => void;
41
+ /** Convenience hook returning [value, setValue] for the path. */
42
+ useState: <P extends FieldPath<T>>(path: P) => StoreUse<FieldPathValue<T, P>>;
40
43
  /** Read without subscribing. */
41
44
  value: <P extends FieldPath<T>>(path: P) => FieldPathValue<T, P> | undefined;
45
+ /** Set value at path (creates intermediate nodes as needed). */
46
+ set: <P extends FieldPath<T>>(path: P, value: FieldPathValue<T, P> | ((prev: FieldPathValue<T, P> | undefined) => FieldPathValue<T, P>), skipUpdate?: boolean) => void;
42
47
  /** Delete value at path (for arrays, removes index; for objects, deletes key). */
43
48
  reset: <P extends FieldPath<T>>(path: P) => void;
49
+ /** Rename a key in an object. */
50
+ rename: <P extends FieldPath<T>>(path: P, oldKey: string, newKey: string, notifyObject?: boolean) => void;
44
51
  /** Subscribe to changes at path and invoke listener with the new value. */
45
52
  subscribe: <P extends FieldPath<T>>(path: P, listener: (value: FieldPathValue<T, P>) => void) => void;
46
53
  /** Compute a derived value from the current value, similar to useState + useMemo */
47
54
  useCompute: <P extends FieldPath<T>, R>(path: P, fn: (value: FieldPathValue<T, P>) => R) => R;
48
55
  /** Notify listeners at path. */
49
56
  notify: <P extends FieldPath<T>>(path: P) => void;
50
- /** Convenience hook returning [value, setValue] for the path. */
51
- useState: <P extends FieldPath<T>>(path: P) => StoreUse<FieldPathValue<T, P>>;
52
57
  /** Render-prop helper for inline usage. */
53
58
  Render: <P extends FieldPath<T>>(props: FieldPathValue<T, P> extends undefined ? never : StoreRenderProps<T, P>) => React.ReactNode;
54
59
  /** Show or hide children based on the value at the path. */
55
60
  Show: <P extends FieldPath<T>>(props: FieldPathValue<T, P> extends undefined ? never : StoreShowProps<T, P>) => React.ReactNode;
56
61
  };
57
62
  /** Common methods available on any deep proxy node */
58
- type State<T> = {
63
+ type ValueState<T> = {
59
64
  /** Read without subscribing. */
60
65
  readonly value: T;
61
66
  /** The field name for the proxy. */
@@ -74,10 +79,25 @@ type State<T> = {
74
79
  subscribe(listener: (value: T) => void): void;
75
80
  /** Compute a derived value from the current value, similar to useState + useMemo */
76
81
  useCompute: <R>(fn: (value: T) => R) => R;
77
- /** Virtual state derived from the current value. */
82
+ /** Ensure the value is an array. */
83
+ ensureArray<U>(): ArrayState<U>;
84
+ /** Ensure the value is an object. */
85
+ ensureObject<U extends FieldValues>(): ObjectState<U>;
86
+ /** Virtual state derived from the current value.
87
+ *
88
+ * @returns ArrayState if the derived value is an array, ObjectState if the derived value is an object, otherwise State.
89
+ * @example
90
+ * const state = store.a.b.c.derived({
91
+ * from: value => value + 1,
92
+ * to: value => value - 1
93
+ * })
94
+ * state.use() // returns the derived value
95
+ * state.set(10) // sets the derived value
96
+ * state.reset() // resets the derived value
97
+ */
78
98
  derived: <R>({ from, to }: {
79
- from: (value: T | undefined) => R;
80
- to: (value: R) => T | undefined;
99
+ from?: (value: T | undefined) => R;
100
+ to?: (value: R) => T | undefined;
81
101
  }) => State<R>;
82
102
  /** Notify listener of current value. */
83
103
  notify(): void;
@@ -102,7 +122,15 @@ type State<T> = {
102
122
  children: React.ReactNode;
103
123
  on: (value: T) => boolean;
104
124
  }) => React.ReactNode;
105
- } & (NonNullable<T> extends readonly (infer U)[] ? ArrayProxy<U> : unknown);
125
+ };
126
+ type ExcludeNullUndefined<T> = Exclude<T, undefined | null>;
127
+ type ArrayElementState<T> = T extends Primitive ? ValueState<T> : State<T>;
128
+ type State<T> = [ExcludeNullUndefined<T>] extends [readonly (infer U)[]] ? ArrayState<U> : [T] extends [FieldValues] ? ObjectState<T> : [ExcludeNullUndefined<T>] extends [FieldValues] ? ObjectState<ExcludeNullUndefined<T>> : ValueState<T>;
129
+ interface ArrayState<T> extends ValueState<T[]>, ArrayProxy<T> {
130
+ }
131
+ type ObjectState<T extends FieldValues> = {
132
+ [K in keyof T]-?: State<T[K]>;
133
+ } & ValueState<T> & ObjectMutationMethods;
106
134
  /** Props for Store.Render helper. */
107
135
  type StoreRenderProps<T extends FieldValues, P extends FieldPath<T>> = {
108
136
  path: P;
@@ -115,6 +143,6 @@ type StoreShowProps<T extends FieldValues, P extends FieldPath<T>> = {
115
143
  on: (value: FieldPathValue<T, P> | undefined) => boolean;
116
144
  };
117
145
  type DerivedStateProps<T, R> = {
118
- from: (value: T | undefined) => R;
119
- to: (value: R) => T | undefined;
146
+ from?: (value: T | undefined) => R;
147
+ to?: (value: R) => T | undefined;
120
148
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juststore",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "A small, expressive, and type-safe state management library for React.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",