juststore 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/atom.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ export { createAtom, type Atom };
2
+ /**
3
+ * An atom is a value that can be subscribed to and updated.
4
+ *
5
+ * @param T - The type of the value
6
+ * @returns The atom
7
+ */
8
+ type Atom<T> = {
9
+ /** The current value. */
10
+ readonly value: T;
11
+ /** Subscribe to the value. */
12
+ use: () => T;
13
+ /** Set the value. */
14
+ set: (value: T) => void;
15
+ /** Reset the value to the default value. */
16
+ reset: () => void;
17
+ /** Subscribe to the value.with a callback function. */
18
+ subscribe: (listener: (value: T) => void) => () => void;
19
+ /** Render the value. */
20
+ Render: ({ children }: {
21
+ children: (value: T, setValue: (value: T) => void) => React.ReactNode;
22
+ }) => React.ReactNode;
23
+ };
24
+ /**
25
+ * Creates an atom with a given id and default value.
26
+ *
27
+ * @param id - The id of the atom
28
+ * @param defaultValue - The default value of the atom
29
+ * @returns The atom
30
+ * @example
31
+ * const stateA = createAtom(useId(), false)
32
+ * return (
33
+ * <>
34
+ * <ComponentA/>
35
+ * <ComponentB/>
36
+ * <stateA.Render>
37
+ * {(value, setValue) => (
38
+ * <button onClick={() => setValue(!value)}>{value ? 'Hide' : 'Show'}</button>
39
+ * )}
40
+ * </stateA.Render>
41
+ * <ComponentC/>
42
+ * <ComponentD/>
43
+ * </>
44
+ * )
45
+ */
46
+ declare function createAtom<T>(id: string, defaultValue: T, persistent?: boolean): Atom<T>;
package/dist/atom.js ADDED
@@ -0,0 +1,136 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { getSnapshot, updateSnapshot } from './impl';
3
+ export { createAtom };
4
+ /**
5
+ * Creates an atom with a given id and default value.
6
+ *
7
+ * @param id - The id of the atom
8
+ * @param defaultValue - The default value of the atom
9
+ * @returns The atom
10
+ * @example
11
+ * const stateA = createAtom(useId(), false)
12
+ * return (
13
+ * <>
14
+ * <ComponentA/>
15
+ * <ComponentB/>
16
+ * <stateA.Render>
17
+ * {(value, setValue) => (
18
+ * <button onClick={() => setValue(!value)}>{value ? 'Hide' : 'Show'}</button>
19
+ * )}
20
+ * </stateA.Render>
21
+ * <ComponentC/>
22
+ * <ComponentD/>
23
+ * </>
24
+ * )
25
+ */
26
+ function createAtom(id, defaultValue, persistent = false) {
27
+ const key = `atom:${id}`;
28
+ const memoryOnly = !persistent;
29
+ // set the default value
30
+ // so getAtom will never return undefined
31
+ if (getAtom(key, memoryOnly) === undefined) {
32
+ setAtom(key, defaultValue, memoryOnly);
33
+ }
34
+ const atomProxy = new Proxy({}, {
35
+ get(target, prop) {
36
+ const cacheKey = `_${String(prop)}`;
37
+ if (cacheKey in target) {
38
+ // return cached methods first
39
+ return target[cacheKey];
40
+ }
41
+ if (prop === 'value') {
42
+ return getAtom(key, memoryOnly);
43
+ }
44
+ if (prop === 'use') {
45
+ return (target._use ??= () => useAtom(key, memoryOnly));
46
+ }
47
+ if (prop === 'set') {
48
+ return (target._set ??= (value) => setAtom(key, value, memoryOnly));
49
+ }
50
+ if (prop === 'reset') {
51
+ return (target._reset ??= () => setAtom(key, defaultValue, memoryOnly));
52
+ }
53
+ if (prop === 'subscribe') {
54
+ return (target._subscribe ??= (listener) => subscribeAtom(key, memoryOnly, listener));
55
+ }
56
+ if (prop === 'Render') {
57
+ return (target._Render ??= ({ children }) => children(useAtom(key, memoryOnly), (value) => setAtom(key, value, memoryOnly)));
58
+ }
59
+ return undefined;
60
+ }
61
+ });
62
+ return atomProxy;
63
+ }
64
+ /**
65
+ * React hook that subscribes to and reads a value at a path.
66
+ *
67
+ * Uses useSyncExternalStore for tear-free reads and automatic re-rendering
68
+ * when the subscribed value changes.
69
+ *
70
+ * @param key - The namespace
71
+ * @param memoryOnly - When true, skips localStorage persistence
72
+ * @returns The current value at the namespace, or the default value if not set
73
+ */
74
+ function useAtom(key, memoryOnly = true) {
75
+ const value = useSyncExternalStore(listener => subscribeAtom(key, memoryOnly, listener), () => getSnapshot(key, memoryOnly), () => getSnapshot(key, memoryOnly));
76
+ return value;
77
+ }
78
+ /**
79
+ * Gets a value from an atom.
80
+ *
81
+ * @param key - The namespace
82
+ * @returns The value, or the default value if not set
83
+ */
84
+ function getAtom(key, memoryOnly = true) {
85
+ return getSnapshot(key, memoryOnly);
86
+ }
87
+ /**
88
+ * Sets a value at a specific path within a namespace.
89
+ *
90
+ * @param key - The namespace
91
+ * @param value - The value to set
92
+ * @param memoryOnly - When true, skips localStorage persistence
93
+ */
94
+ function setAtom(key, value, memoryOnly = true) {
95
+ updateSnapshot(key, value, memoryOnly);
96
+ notifyAtom(key);
97
+ }
98
+ const listeners = new Map();
99
+ /**
100
+ * Subscribes to changes for an atom.
101
+ *
102
+ * @param key - The full key path to subscribe to
103
+ * @param listener - Callback invoked when the value changes
104
+ * @returns An unsubscribe function to remove the listener
105
+ */
106
+ function subscribeAtom(key, memoryOnly, listener) {
107
+ let listenerSet = listeners.get(key);
108
+ if (!listenerSet) {
109
+ listenerSet = new Set();
110
+ listeners.set(key, listenerSet);
111
+ }
112
+ const atomListener = () => listener(getAtom(key, memoryOnly));
113
+ listenerSet.add(atomListener);
114
+ return () => {
115
+ const keyListeners = listeners.get(key);
116
+ if (keyListeners) {
117
+ keyListeners.delete(atomListener);
118
+ if (keyListeners.size === 0) {
119
+ listeners.delete(key);
120
+ }
121
+ }
122
+ };
123
+ }
124
+ /**
125
+ * Notifies all listeners for an atom.
126
+ *
127
+ * @param namespace - The namespace
128
+ */
129
+ function notifyAtom(namespace) {
130
+ const listenerSet = listeners.get(namespace);
131
+ if (!listenerSet)
132
+ return;
133
+ for (const listener of listenerSet) {
134
+ listener();
135
+ }
136
+ }
package/dist/form.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues, IsEqual } from './path';
2
- import type { ArrayProxy, IsNullable, MaybeNullable, ObjectMutationMethods, ValueState } from './types';
3
- export { useForm, type CreateFormOptions, type DeepNonNullable, type FormArrayState, type FormObjectState, type FormState, type FormStore, type FormValueState };
2
+ import type { ArrayProxy, DerivedStateProps, IsNullable, MaybeNullable, ObjectMutationMethods, Prettify, ValueState } from './types';
3
+ export { createForm, 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
  */
@@ -13,7 +13,12 @@ type FormCommon = {
13
13
  setError: (error: string | undefined) => void;
14
14
  };
15
15
  type FormState<T> = IsEqual<T, unknown> extends true ? never : [NonNullable<T>] extends [readonly (infer U)[]] ? FormArrayState<U, IsNullable<T>> : [NonNullable<T>] extends [FieldValues] ? FormObjectState<NonNullable<T>, IsNullable<T>> : FormValueState<T>;
16
- interface FormValueState<T> extends Omit<ValueState<T>, 'withDefault' | 'derived'>, FormCommon {
16
+ type FormReadOnlyState<T> = Prettify<Pick<FormValueState<Readonly<Required<T>>>, 'value' | 'use' | 'useCompute' | 'Render' | 'Show' | 'error' | 'setError'>>;
17
+ interface FormValueState<T> extends Omit<ValueState<T>, 'ensureArray' | 'ensureObject' | 'withDefault' | 'derived'>, FormCommon {
18
+ /** Ensure the value is an array. */
19
+ ensureArray(): NonNullable<T> extends (infer U)[] ? FormArrayState<U> : never;
20
+ /** Ensure the value is an object. */
21
+ ensureObject(): NonNullable<T> extends FieldValues ? FormObjectState<NonNullable<T>> : never;
17
22
  /** Return a new state with a default value, and make the type non-nullable */
18
23
  withDefault(defaultValue: T): FormState<NonNullable<T>>;
19
24
  /** Virtual state derived from the current value.
@@ -28,15 +33,19 @@ interface FormValueState<T> extends Omit<ValueState<T>, 'withDefault' | 'derived
28
33
  * state.set(10) // sets the derived value
29
34
  * state.reset() // resets the derived value
30
35
  */
31
- derived: <R>({ from, to }: {
32
- from?: (value: T | undefined) => R;
33
- to?: (value: R) => T | undefined;
34
- }) => FormState<R>;
36
+ derived: <R>({ from, to }: DerivedStateProps<T, R>) => FormState<R>;
35
37
  }
36
- type FormArrayState<T, Nullable extends boolean = false, TT = MaybeNullable<T[], Nullable>> = IsEqual<T, unknown> extends true ? never : FormValueState<TT[]> & ArrayProxy<TT, FormState<TT>>;
37
- type FormObjectState<T extends FieldValues, Nullable extends boolean = false> = {
38
+ type FormObjectProxy<T extends FieldValues> = {
39
+ /** Virtual state for the object's keys.
40
+ *
41
+ * This does NOT read from a real `keys` property on the stored object; it results in a stable array of keys.
42
+ */
43
+ readonly keys: FormReadOnlyState<FieldPath<T>[]>;
44
+ } & {
38
45
  [K in keyof T]-?: FormState<T[K]>;
39
- } & FormValueState<MaybeNullable<T, Nullable>> & ObjectMutationMethods;
46
+ };
47
+ type FormArrayState<T, Nullable extends boolean = false> = IsEqual<T, unknown> extends true ? never : FormValueState<MaybeNullable<T[], Nullable>> & ArrayProxy<T, FormState<T>>;
48
+ type FormObjectState<T extends FieldValues, Nullable extends boolean = false> = FormObjectProxy<T> & FormValueState<MaybeNullable<T, Nullable>> & ObjectMutationMethods;
40
49
  /** Type for nested objects with proxy methods */
41
50
  type DeepNonNullable<T> = [NonNullable<T>] extends [readonly (infer U)[]] ? U[] : [NonNullable<T>] extends [FieldValues] ? {
42
51
  [K in keyof NonNullable<T>]-?: DeepNonNullable<NonNullable<T>[K]>;
@@ -52,12 +61,14 @@ type FormStore<T extends FieldValues> = FormState<T> & {
52
61
  };
53
62
  type NoEmptyValidator = 'not-empty';
54
63
  type RegexValidator = RegExp;
55
- type FunctionValidator<T extends FieldValues> = (value: FieldPathValue<T, FieldPath<T>> | undefined, state: FormStore<T>) => string | undefined;
64
+ type FunctionValidator<T extends FieldValues> = (value: FieldPathValue<T, FieldPath<T>>, state: FormStore<T>) => string | undefined;
56
65
  type Validator<T extends FieldValues> = NoEmptyValidator | RegexValidator | FunctionValidator<T>;
57
66
  type FieldConfig<T extends FieldValues> = {
58
67
  validate?: Validator<T>;
59
68
  };
60
69
  type CreateFormOptions<T extends FieldValues> = Partial<Record<FieldPath<T>, FieldConfig<T>>>;
70
+ type UnsubscribeFn = () => void;
71
+ type UnsubscribeFns = UnsubscribeFn[];
61
72
  /**
62
73
  * React hook that creates a form store with validation support.
63
74
  *
@@ -83,3 +94,4 @@ type CreateFormOptions<T extends FieldValues> = Partial<Record<FieldPath<T>, Fie
83
94
  * </form>
84
95
  */
85
96
  declare function useForm<T extends FieldValues>(defaultValue: T, fieldConfigs?: CreateFormOptions<T>): FormStore<T>;
97
+ declare function createForm<T extends FieldValues>(namespace: string, defaultValue: T, fieldConfigs?: CreateFormOptions<T>): [FormStore<T>, UnsubscribeFns];
package/dist/form.js CHANGED
@@ -5,7 +5,7 @@ import { useEffect, useId, useMemo } from 'react';
5
5
  import { getSnapshot, produce } from './impl';
6
6
  import { createNode } from './node';
7
7
  import { createStoreRoot } from './root';
8
- export { useForm };
8
+ export { createForm, useForm };
9
9
  /**
10
10
  * React hook that creates a form store with validation support.
11
11
  *
@@ -33,61 +33,57 @@ export { useForm };
33
33
  function useForm(defaultValue, fieldConfigs = {}) {
34
34
  const formId = useId();
35
35
  const namespace = `form:${formId}`;
36
- const errorNamespace = `errors.${namespace}`;
37
- const storeApi = createStoreRoot(namespace, defaultValue, { memoryOnly: true });
36
+ const [form, unsubscribeFns] = useMemo(() => createForm(namespace, defaultValue, fieldConfigs), [namespace, defaultValue, fieldConfigs]);
37
+ useEffect(() => {
38
+ return () => {
39
+ for (const unsubscribe of unsubscribeFns) {
40
+ unsubscribe();
41
+ }
42
+ };
43
+ }, [unsubscribeFns]);
44
+ return form;
45
+ }
46
+ function createForm(namespace, defaultValue, fieldConfigs = {}) {
47
+ const errorNamespace = `_juststore_form_errors.${namespace}`;
38
48
  const errorStore = createStoreRoot(errorNamespace, {}, { memoryOnly: true });
39
- const formStore = useMemo(() => ({
49
+ const storeApi = createStoreRoot(namespace, defaultValue, { memoryOnly: true });
50
+ const formApi = {
40
51
  clearErrors: () => produce(errorNamespace, undefined, false, true),
41
52
  handleSubmit: (onSubmit) => (e) => {
42
53
  e.preventDefault();
43
54
  // disable submit if there are errors
44
- if (Object.keys(getSnapshot(errorNamespace) ?? {}).length === 0) {
45
- onSubmit(getSnapshot(namespace));
55
+ if (Object.keys(getSnapshot(errorNamespace, true) ?? {}).length === 0) {
56
+ onSubmit(getSnapshot(namespace, true));
46
57
  }
47
58
  }
48
- }), [namespace, errorNamespace]);
49
- const store = useMemo(() => new Proxy(storeApi, {
50
- get(_target, prop) {
51
- if (prop in formStore) {
52
- return formStore[prop];
53
- }
54
- if (prop in storeApi) {
55
- return storeApi[prop];
56
- }
57
- if (typeof prop === 'string') {
58
- return createFormProxy(storeApi, errorStore, prop);
59
+ };
60
+ const store = createFormProxy(storeApi, errorStore);
61
+ const proxy = new Proxy(formApi, {
62
+ get(target, prop) {
63
+ if (prop in target) {
64
+ return target[prop];
59
65
  }
60
- return undefined;
66
+ return store[prop];
61
67
  }
62
- }), [storeApi, formStore, errorStore]);
63
- const unsubscribeFns = useMemo(() => {
64
- const unsubscribeFns = [];
65
- for (const entry of Object.entries(fieldConfigs)) {
66
- const [path, config] = entry;
67
- const validator = getValidator(path, config?.validate);
68
- if (validator) {
69
- const unsubscribe = storeApi.subscribe(path, (value) => {
70
- const error = validator(value, store);
71
- if (!error) {
72
- errorStore.reset(path);
73
- }
74
- else {
75
- errorStore.set(path, error);
76
- }
77
- });
78
- unsubscribeFns.push(unsubscribe);
79
- }
68
+ });
69
+ const unsubscribeFns = [];
70
+ for (const entry of Object.entries(fieldConfigs)) {
71
+ const [path, config] = entry;
72
+ const validator = getValidator(path, config?.validate);
73
+ if (validator) {
74
+ const unsubscribe = storeApi.subscribe(path, value => {
75
+ const error = validator(value, store);
76
+ if (!error) {
77
+ errorStore.reset(path);
78
+ }
79
+ else {
80
+ errorStore.set(path, error);
81
+ }
82
+ });
83
+ unsubscribeFns.push(unsubscribe);
80
84
  }
81
- return unsubscribeFns;
82
- }, [fieldConfigs, storeApi, errorStore, store]);
83
- useEffect(() => {
84
- return () => {
85
- for (const unsubscribe of unsubscribeFns) {
86
- unsubscribe();
87
- }
88
- };
89
- }, [unsubscribeFns]);
90
- return store;
85
+ }
86
+ return [proxy, unsubscribeFns];
91
87
  }
92
88
  /**
93
89
  * Creates a form proxy node that extends the base node with error handling.
@@ -97,26 +93,23 @@ function useForm(defaultValue, fieldConfigs = {}) {
97
93
  * @param path - The field path
98
94
  * @returns A proxy with both state methods and error methods
99
95
  */
100
- const createFormProxy = (storeApi, errorStore, path) => {
96
+ function createFormProxy(storeApi, errorStore) {
101
97
  const proxyCache = new Map();
102
- const useError = () => errorStore.use(path);
103
- const getError = () => errorStore.value(path);
104
- const setError = (error) => {
105
- errorStore.set(path, error);
106
- return true;
107
- };
108
- return createNode(storeApi, path, proxyCache, {
98
+ return createNode(storeApi, '', proxyCache, {
109
99
  useError: {
110
- get: () => useError
100
+ get: (path) => () => errorStore.use(path)
111
101
  },
112
102
  error: {
113
- get: getError
103
+ get: (path) => errorStore.value(path)
114
104
  },
115
105
  setError: {
116
- get: () => setError
106
+ get: (path) => (error) => {
107
+ errorStore.set(path, error, false);
108
+ return true;
109
+ }
117
110
  }
118
111
  });
119
- };
112
+ }
120
113
  /**
121
114
  * Converts a validator configuration into a validation function.
122
115
  *
package/dist/impl.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- export { getNestedValue, getSnapshot, getStableKeys, isClass, isEqual, joinPath, notifyListeners, produce, rename, setExternalKeyOrder, setLeaf, subscribe, useDebounce, useObject };
3
- declare function setExternalKeyOrder(target: object, keys: string[]): void;
4
- declare function getStableKeys(value: unknown): string[];
2
+ import { getStableKeys, setExternalKeyOrder } from './stable_keys';
3
+ export { getNestedValue, getSnapshot, getStableKeys, isClass, isEqual, isRecord, joinPath, notifyListeners, produce, rename, setExternalKeyOrder, setLeaf, setNestedValue, subscribe, testReset, updateSnapshot, useDebounce, useObject };
4
+ declare function testReset(): void;
5
5
  declare function isClass(value: unknown): boolean;
6
+ declare function isRecord(value: unknown): boolean;
6
7
  /** Compare two values for equality
7
8
  * @description
8
9
  * - react-fast-compare for non-class instances
@@ -20,8 +21,25 @@ declare function isEqual(a: unknown, b: unknown): boolean;
20
21
  * @returns Combined key string (e.g., "app.user.name")
21
22
  */
22
23
  declare function joinPath(namespace: string, path?: string): string;
24
+ /** Snapshot getter used by React's useSyncExternalStore. */
25
+ declare function getSnapshot(key: string, memoryOnly: boolean): unknown;
26
+ /** Updates the snapshot of a key. */
27
+ declare function updateSnapshot(key: string, value: unknown, memoryOnly: boolean): void;
23
28
  /** Get a nested value from an object/array using a dot-separated path. */
24
29
  declare function getNestedValue(obj: unknown, path: string): unknown;
30
+ /**
31
+ * Immutably sets or deletes a nested value using a dot-separated path.
32
+ *
33
+ * Creates intermediate objects or arrays as needed based on whether the next
34
+ * path segment is numeric. When value is undefined, the key is deleted from
35
+ * objects or the index is spliced from arrays.
36
+ *
37
+ * @param obj - The root object to update
38
+ * @param path - Dot-separated path to the target location
39
+ * @param value - The value to set, or undefined to delete
40
+ * @returns A new root object with the change applied
41
+ */
42
+ declare function setNestedValue(obj: unknown, path: string, value: unknown): unknown;
25
43
  /**
26
44
  * Notifies all relevant listeners when a value changes.
27
45
  *
@@ -38,8 +56,6 @@ declare function notifyListeners(key: string, oldValue: unknown, newValue: unkno
38
56
  skipChildren?: boolean | undefined;
39
57
  forceNotify?: boolean | undefined;
40
58
  }): void;
41
- /** Snapshot getter used by React's useSyncExternalStore. */
42
- declare function getSnapshot(key: string): unknown;
43
59
  /**
44
60
  * Subscribes to changes for a specific key.
45
61
  *
@@ -59,7 +75,7 @@ declare function subscribe(key: string, listener: () => void): () => void;
59
75
  * @param skipUpdate - When true, skips notifying listeners
60
76
  * @param memoryOnly - When true, skips localStorage persistence
61
77
  */
62
- declare function produce(key: string, value: unknown, skipUpdate?: boolean, memoryOnly?: boolean): void;
78
+ declare function produce(key: string, value: unknown, skipUpdate: boolean, memoryOnly: boolean): void;
63
79
  /**
64
80
  * Renames a key in an object.
65
81
  *
@@ -73,7 +89,7 @@ declare function produce(key: string, value: unknown, skipUpdate?: boolean, memo
73
89
  * @param oldKey - The old key to rename
74
90
  * @param newKey - The new key to rename to
75
91
  */
76
- declare function rename(path: string, oldKey: string, newKey: string): void;
92
+ declare function rename(path: string, oldKey: string, newKey: string, memoryOnly: boolean): void;
77
93
  /**
78
94
  * React hook that subscribes to and reads a value at a path.
79
95
  *
@@ -82,9 +98,10 @@ declare function rename(path: string, oldKey: string, newKey: string): void;
82
98
  *
83
99
  * @param key - The namespace or full key
84
100
  * @param path - Optional path within the namespace
101
+ * @param memoryOnly - When true, skips localStorage persistence
85
102
  * @returns The current value at the path, or undefined if not set
86
103
  */
87
- declare function useObject<T extends FieldValues, P extends FieldPath<T>>(key: string, path?: P): FieldPathValue<T, P> | undefined;
104
+ declare function useObject<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P | undefined, memoryOnly: boolean): FieldPathValue<T, P> | undefined;
88
105
  /**
89
106
  * React hook that subscribes to a value with debounced updates.
90
107
  *
@@ -94,9 +111,10 @@ declare function useObject<T extends FieldValues, P extends FieldPath<T>>(key: s
94
111
  * @param key - The namespace or full key
95
112
  * @param path - Path within the namespace
96
113
  * @param delay - Debounce delay in milliseconds
114
+ * @param memoryOnly - When true, skips localStorage persistence
97
115
  * @returns The debounced value at the path
98
116
  */
99
- declare function useDebounce<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, delay: number): FieldPathValue<T, P> | undefined;
117
+ declare function useDebounce<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, delay: number, memoryOnly: boolean): FieldPathValue<T, P> | undefined;
100
118
  /**
101
119
  * Sets a value at a specific path within a namespace.
102
120
  *