juststore 1.1.1 → 1.2.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.
@@ -0,0 +1,141 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { getSnapshot, updateSnapshot, useCompute } 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
+ * <Render state={stateA}>
17
+ * {(value, setValue) => (
18
+ * <button onClick={() => setValue(!value)}>{value ? 'Hide' : 'Show'}</button>
19
+ * )}
20
+ * </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 === "useCompute") {
57
+ return (target._useCompute ??= (fn, deps) => useCompute(key, undefined, fn, deps, 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
+ if (typeof value !== "function") {
96
+ updateSnapshot(key, value, memoryOnly);
97
+ }
98
+ else {
99
+ updateSnapshot(key, value(getAtom(key, memoryOnly)), memoryOnly);
100
+ }
101
+ notifyAtom(key);
102
+ }
103
+ const listeners = new Map();
104
+ /**
105
+ * Subscribes to changes for an atom.
106
+ *
107
+ * @param key - The full key path to subscribe to
108
+ * @param listener - Callback invoked when the value changes
109
+ * @returns An unsubscribe function to remove the listener
110
+ */
111
+ function subscribeAtom(key, memoryOnly, listener) {
112
+ let listenerSet = listeners.get(key);
113
+ if (!listenerSet) {
114
+ listenerSet = new Set();
115
+ listeners.set(key, listenerSet);
116
+ }
117
+ const atomListener = () => listener(getAtom(key, memoryOnly));
118
+ listenerSet.add(atomListener);
119
+ return () => {
120
+ const keyListeners = listeners.get(key);
121
+ if (keyListeners) {
122
+ keyListeners.delete(atomListener);
123
+ if (keyListeners.size === 0) {
124
+ listeners.delete(key);
125
+ }
126
+ }
127
+ };
128
+ }
129
+ /**
130
+ * Notifies all listeners for an atom.
131
+ *
132
+ * @param namespace - The namespace
133
+ */
134
+ function notifyAtom(namespace) {
135
+ const listenerSet = listeners.get(namespace);
136
+ if (!listenerSet)
137
+ return;
138
+ for (const listener of listenerSet) {
139
+ listener();
140
+ }
141
+ }
@@ -0,0 +1,97 @@
1
+ import type { FieldPath, FieldPathValue, FieldValues, IsEqual } from "./path";
2
+ import type { ArrayProxy, DerivedStateProps, ObjectMutationMethods, Prettify, ValueState } from "./types";
3
+ export { type CreateFormOptions, createForm, type DeepNonNullable, type FormArrayState, type FormObjectState, type FormState, type FormStore, type FormValueState, useForm, };
4
+ /**
5
+ * Common form field methods available on every form state node.
6
+ */
7
+ type FormCommon = {
8
+ /** Subscribe and read the validation error. Re-renders when the error changes. */
9
+ useError: () => string | undefined;
10
+ /** Read the validation error without subscribing. */
11
+ readonly error: string | undefined;
12
+ /** Manually set a validation error. */
13
+ setError: (error: string | undefined) => void;
14
+ };
15
+ type FormState<T> = IsEqual<T, unknown> extends true ? never : [NonNullable<T>] extends [readonly (infer U)[]] ? FormArrayState<U, T> : [NonNullable<T>] extends [FieldValues] ? FormObjectState<NonNullable<T>, T> : FormValueState<T>;
16
+ type FormReadOnlyState<T> = Prettify<Pick<FormValueState<Readonly<Required<T>>>, "value" | "use" | "useCompute" | "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;
22
+ /** Return a new state with a default value, and make the type non-nullable */
23
+ withDefault(defaultValue: T): FormState<NonNullable<T>>;
24
+ /** Virtual state derived from the current value.
25
+ *
26
+ * @returns ArrayState if the derived value is an array, ObjectState if the derived value is an object, otherwise State.
27
+ * @example
28
+ * const state = store.a.b.c.derived({
29
+ * from: value => value + 1,
30
+ * to: value => value - 1
31
+ * })
32
+ * state.use() // returns the derived value
33
+ * state.set(10) // sets the derived value
34
+ * state.reset() // resets the derived value
35
+ */
36
+ derived: <R>({ from, to }: DerivedStateProps<T, R>) => FormState<R>;
37
+ }
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
+ } & {
45
+ [K in keyof T]-?: FormState<T[K]>;
46
+ };
47
+ type FormArrayState<TValue, TArray = TValue[]> = IsEqual<TValue, unknown> extends true ? never : FormValueState<TArray> & ArrayProxy<TValue, FormState<TValue>>;
48
+ type FormObjectState<TNonNullable extends FieldValues, TObject = TNonNullable> = FormObjectProxy<TNonNullable> & FormValueState<TObject> & ObjectMutationMethods;
49
+ /** Type for nested objects with proxy methods */
50
+ type DeepNonNullable<T> = [NonNullable<T>] extends [readonly (infer U)[]] ? U[] : [NonNullable<T>] extends [FieldValues] ? {
51
+ [K in keyof NonNullable<T>]-?: DeepNonNullable<NonNullable<T>[K]>;
52
+ } : NonNullable<T>;
53
+ /**
54
+ * The form store type, combining form state with validation and submission handling.
55
+ */
56
+ type FormStore<T extends FieldValues> = FormState<T> & {
57
+ /** Clears all validation errors from the form. */
58
+ clearErrors(): void;
59
+ /** Returns a form submit handler that validates and calls onSubmit with form values. */
60
+ handleSubmit(onSubmit: (values: T) => void): (e: React.SyntheticEvent) => void;
61
+ };
62
+ type NoEmptyValidator = "not-empty";
63
+ type RegexValidator = RegExp;
64
+ type FunctionValidator<T extends FieldValues> = (value: FieldPathValue<T, FieldPath<T>>, state: FormStore<T>) => string | undefined;
65
+ type Validator<T extends FieldValues> = NoEmptyValidator | RegexValidator | FunctionValidator<T>;
66
+ type FieldConfig<T extends FieldValues> = {
67
+ validate?: Validator<T>;
68
+ };
69
+ type CreateFormOptions<T extends FieldValues> = Partial<Record<FieldPath<T>, FieldConfig<T>>>;
70
+ type UnsubscribeFn = () => void;
71
+ type UnsubscribeFns = UnsubscribeFn[];
72
+ /**
73
+ * React hook that creates a form store with validation support.
74
+ *
75
+ * The form store extends the memory store with error handling and validation.
76
+ * Fields can be configured with validators that run on every change.
77
+ *
78
+ * @param defaultValue - Initial form values
79
+ * @param fieldConfigs - Optional validation configuration per field
80
+ * @returns A form store with validation and submission handling
81
+ *
82
+ * @example
83
+ * const form = useForm(
84
+ * { email: '', password: '' },
85
+ * {
86
+ * email: { validate: 'not-empty' },
87
+ * password: { validate: v => v && v.length < 8 ? 'Too short' : undefined }
88
+ * }
89
+ * )
90
+ *
91
+ * <form onSubmit={form.handleSubmit(values => console.log(values))}>
92
+ * <input value={form.email.use() ?? ''} onChange={e => form.email.set(e.target.value)} />
93
+ * {form.email.useError() && <span>{form.email.error}</span>}
94
+ * </form>
95
+ */
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];
@@ -0,0 +1,176 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: intended */
2
+ "use client";
3
+ import { pascalCase } from "change-case";
4
+ import { useEffect, useId, useMemo } from "react";
5
+ import { getSnapshot, produce } from "./impl";
6
+ import { createNode } from "./node";
7
+ import { createStoreRoot } from "./root";
8
+ export { createForm, useForm, };
9
+ /**
10
+ * React hook that creates a form store with validation support.
11
+ *
12
+ * The form store extends the memory store with error handling and validation.
13
+ * Fields can be configured with validators that run on every change.
14
+ *
15
+ * @param defaultValue - Initial form values
16
+ * @param fieldConfigs - Optional validation configuration per field
17
+ * @returns A form store with validation and submission handling
18
+ *
19
+ * @example
20
+ * const form = useForm(
21
+ * { email: '', password: '' },
22
+ * {
23
+ * email: { validate: 'not-empty' },
24
+ * password: { validate: v => v && v.length < 8 ? 'Too short' : undefined }
25
+ * }
26
+ * )
27
+ *
28
+ * <form onSubmit={form.handleSubmit(values => console.log(values))}>
29
+ * <input value={form.email.use() ?? ''} onChange={e => form.email.set(e.target.value)} />
30
+ * {form.email.useError() && <span>{form.email.error}</span>}
31
+ * </form>
32
+ */
33
+ function useForm(defaultValue, fieldConfigs = {}) {
34
+ const formId = useId();
35
+ const namespace = `form:${formId}`;
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}`;
48
+ const errorStore = createStoreRoot(errorNamespace, {}, { memoryOnly: true });
49
+ const storeApi = createStoreRoot(namespace, defaultValue, {
50
+ memoryOnly: true,
51
+ });
52
+ const formApi = {
53
+ clearErrors: () => produce(errorNamespace, undefined, false, true),
54
+ handleSubmit: (onSubmit) => (e) => {
55
+ e.preventDefault();
56
+ // disable submit if there are errors
57
+ if (Object.keys(getSnapshot(errorNamespace, true) ?? {}).length === 0) {
58
+ onSubmit(getSnapshot(namespace, true));
59
+ }
60
+ },
61
+ };
62
+ const store = createFormProxy(storeApi, errorStore);
63
+ const proxy = new Proxy(formApi, {
64
+ get(target, prop) {
65
+ if (prop in target) {
66
+ return target[prop];
67
+ }
68
+ return store[prop];
69
+ },
70
+ });
71
+ const unsubscribeFns = [];
72
+ for (const entry of Object.entries(fieldConfigs)) {
73
+ const [path, config] = entry;
74
+ const validator = getValidator(path, config?.validate);
75
+ if (validator) {
76
+ const unsubscribe = storeApi.subscribe(path, (value) => {
77
+ const error = validator(value, store);
78
+ if (!error) {
79
+ errorStore.reset(path);
80
+ }
81
+ else {
82
+ errorStore.set(path, error);
83
+ }
84
+ });
85
+ unsubscribeFns.push(unsubscribe);
86
+ }
87
+ }
88
+ return [proxy, unsubscribeFns];
89
+ }
90
+ /**
91
+ * Creates a form proxy node that extends the base node with error handling.
92
+ *
93
+ * @param storeApi - The form's value store
94
+ * @param errorStore - The form's error store
95
+ * @param path - The field path
96
+ * @returns A proxy with both state methods and error methods
97
+ */
98
+ function createFormProxy(storeApi, errorStore) {
99
+ const proxyCache = new Map();
100
+ return createNode(storeApi, "", proxyCache, {
101
+ useError: {
102
+ get: (path) => () => errorStore.use(path),
103
+ },
104
+ error: {
105
+ get: (path) => errorStore.value(path),
106
+ },
107
+ setError: {
108
+ get: (path) => (error) => {
109
+ errorStore.set(path, error, false);
110
+ return true;
111
+ },
112
+ },
113
+ });
114
+ }
115
+ /**
116
+ * Converts a validator configuration into a validation function.
117
+ *
118
+ * @param field - The field path (used for error messages)
119
+ * @param validator - The validator config ('not-empty', RegExp, or function)
120
+ * @returns A validation function, or undefined if no validator provided
121
+ */
122
+ function getValidator(field, validator) {
123
+ if (!validator) {
124
+ return undefined;
125
+ }
126
+ if (validator === "not-empty") {
127
+ return (value) => validateNoEmpty(field, value);
128
+ }
129
+ if (validator instanceof RegExp) {
130
+ return (value) => validateRegex(field, value, validator);
131
+ }
132
+ return validator;
133
+ }
134
+ /**
135
+ * Validates that a field has a non-empty value.
136
+ *
137
+ * @param field - The field path (used for error message)
138
+ * @param value - The value to validate
139
+ * @returns Error message if empty, undefined if valid
140
+ */
141
+ function validateNoEmpty(field, value) {
142
+ if (!stringValue(value)) {
143
+ return `${pascalCase(field)} is required`;
144
+ }
145
+ return undefined;
146
+ }
147
+ /**
148
+ * Validates that a field matches a regular expression.
149
+ *
150
+ * @param field - The field path (used for error message)
151
+ * @param value - The value to validate
152
+ * @param regex - The pattern to match against
153
+ * @returns Error message if invalid, undefined if valid
154
+ */
155
+ function validateRegex(field, value, regex) {
156
+ if (!regex.test(stringValue(value))) {
157
+ return `${pascalCase(field)} is invalid`;
158
+ }
159
+ return undefined;
160
+ }
161
+ /**
162
+ * Converts a value to a string for validation purposes.
163
+ * Returns empty string for non-primitive values.
164
+ */
165
+ function stringValue(v) {
166
+ if (typeof v === "string") {
167
+ return v;
168
+ }
169
+ if (typeof v === "number") {
170
+ return String(v);
171
+ }
172
+ if (typeof v === "boolean") {
173
+ return String(v);
174
+ }
175
+ return "";
176
+ }
@@ -0,0 +1,128 @@
1
+ import type { FieldPath, FieldPathValue, FieldValues } from "./path";
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, useCompute, useDebounce, useObject, };
4
+ declare function testReset(): void;
5
+ declare function isClass(value: unknown): boolean;
6
+ declare function isRecord(value: unknown): boolean;
7
+ /** Compare two values for equality
8
+ * @description
9
+ * - react-fast-compare for non-class instances
10
+ * - reference equality for class instances
11
+ * @param a - The first value to compare
12
+ * @param b - The second value to compare
13
+ * @returns True if the values are equal, false otherwise
14
+ */
15
+ declare function isEqual(a: unknown, b: unknown): boolean;
16
+ /**
17
+ * Joins a namespace and path into a full key string.
18
+ *
19
+ * @param namespace - The store namespace (root key)
20
+ * @param path - Optional dot-separated path within the namespace
21
+ * @returns Combined key string (e.g., "app.user.name")
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;
28
+ /** Get a nested value from an object/array using a dot-separated path. */
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;
43
+ /**
44
+ * Notifies all relevant listeners when a value changes.
45
+ *
46
+ * Handles three types of listeners:
47
+ * 1. Exact match - listeners subscribed to the exact changed path
48
+ * 2. Root listeners - listeners on the namespace root (for full-store subscriptions)
49
+ * 3. Child listeners - listeners on nested paths that may be affected by the change
50
+ *
51
+ * Child listeners are only notified if their specific value actually changed,
52
+ * determined by deep equality comparison.
53
+ */
54
+ declare function notifyListeners(key: string, oldValue: unknown, newValue: unknown, { skipRoot, skipChildren, forceNotify }?: {
55
+ skipRoot?: boolean | undefined;
56
+ skipChildren?: boolean | undefined;
57
+ forceNotify?: boolean | undefined;
58
+ }): void;
59
+ /**
60
+ * Subscribes to changes for a specific key.
61
+ *
62
+ * @param key - The full key path to subscribe to
63
+ * @param listener - Callback invoked when the value changes
64
+ * @returns An unsubscribe function to remove the listener
65
+ */
66
+ declare function subscribe(key: string, listener: () => void): () => void;
67
+ declare function useCompute<T = unknown, R = unknown>(namespace: string, path: string | undefined, fn: (value: T) => R, deps?: readonly unknown[], memoryOnly?: boolean): R;
68
+ /**
69
+ * Core mutation function that updates the store and notifies listeners.
70
+ *
71
+ * Handles both setting and deleting values, with optimizations to skip
72
+ * unnecessary updates when the value hasn't changed.
73
+ *
74
+ * @param key - The full key path to update
75
+ * @param value - The new value, or undefined to delete
76
+ * @param skipUpdate - When true, skips notifying listeners
77
+ * @param memoryOnly - When true, skips localStorage persistence
78
+ */
79
+ declare function produce(key: string, value: unknown, skipUpdate: boolean, memoryOnly: boolean): void;
80
+ /**
81
+ * Renames a key in an object.
82
+ *
83
+ * It trigger updates to
84
+ *
85
+ * - listeners to `path` (key is updated)
86
+ * - listeners to `path.oldKey` (deleted)
87
+ * - listeners to `path.newKey` (created)
88
+ *
89
+ * @param path - The full key path to rename
90
+ * @param oldKey - The old key to rename
91
+ * @param newKey - The new key to rename to
92
+ */
93
+ declare function rename(path: string, oldKey: string, newKey: string, memoryOnly: boolean): void;
94
+ /**
95
+ * React hook that subscribes to and reads a value at a path.
96
+ *
97
+ * Uses useSyncExternalStore for tear-free reads and automatic re-rendering
98
+ * when the subscribed value changes.
99
+ *
100
+ * @param key - The namespace or full key
101
+ * @param path - Optional path within the namespace
102
+ * @param memoryOnly - When true, skips localStorage persistence
103
+ * @returns The current value at the path, or undefined if not set
104
+ */
105
+ declare function useObject<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P | undefined, memoryOnly: boolean): FieldPathValue<T, P> | undefined;
106
+ /**
107
+ * React hook that subscribes to a value with debounced updates.
108
+ *
109
+ * The returned value only updates after the specified delay has passed
110
+ * since the last change, useful for expensive operations like search.
111
+ *
112
+ * @param key - The namespace or full key
113
+ * @param path - Path within the namespace
114
+ * @param delay - Debounce delay in milliseconds
115
+ * @param memoryOnly - When true, skips localStorage persistence
116
+ * @returns The debounced value at the path
117
+ */
118
+ declare function useDebounce<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, delay: number, memoryOnly: boolean): FieldPathValue<T, P> | undefined;
119
+ /**
120
+ * Sets a value at a specific path within a namespace.
121
+ *
122
+ * @param key - The namespace
123
+ * @param path - Path within the namespace
124
+ * @param value - The value to set, or undefined to delete
125
+ * @param skipUpdate - When true, skips notifying listeners
126
+ * @param memoryOnly - When true, skips localStorage persistence
127
+ */
128
+ declare function setLeaf<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, value: FieldPathValue<T, P> | undefined, skipUpdate?: boolean, memoryOnly?: boolean): void;