juststore 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -424,7 +424,8 @@ Additional form methods:
424
424
  - `.error`
425
425
  - `.setError(message | undefined)`
426
426
  - `.clearErrors()`
427
- - `.handleSubmit(onSubmit)`
427
+ - `.handleSubmit(onSubmit)` — clears stale errors, validates current values, then calls `onSubmit`
428
+ only when current validation passes
428
429
 
429
430
  ### `createMixedState(...states)`
430
431
 
@@ -513,4 +514,4 @@ Available on all nodes (`store.a.b.c`):
513
514
 
514
515
  ## License
515
516
 
516
- AGPL-3.0
517
+ MIT
package/dist/form.js CHANGED
@@ -49,18 +49,38 @@ function createForm(namespace, defaultValue, fieldConfigs = {}) {
49
49
  const storeApi = createStoreRoot(namespace, defaultValue, {
50
50
  memoryOnly: true
51
51
  });
52
+ const store = createFormProxy(storeApi, errorStore);
53
+ const validators = new Map();
54
+ let proxy;
55
+ for (const entry of Object.entries(fieldConfigs)) {
56
+ const [path, config] = entry;
57
+ const validator = getValidator(path, config?.validate);
58
+ if (validator) {
59
+ validators.set(path, validator);
60
+ }
61
+ }
62
+ const validate = () => {
63
+ let hasError = false;
64
+ for (const [path, validator] of validators) {
65
+ const error = validator(storeApi.value(path), proxy);
66
+ if (error) {
67
+ errorStore.set(path, error);
68
+ hasError = true;
69
+ }
70
+ }
71
+ return !hasError;
72
+ };
52
73
  const formApi = {
53
74
  clearErrors: () => produce(errorNamespace, undefined, false, true),
54
75
  handleSubmit: (onSubmit) => (e) => {
55
76
  e.preventDefault();
56
- // disable submit if there are errors
57
- if (Object.keys(getSnapshot(errorNamespace, true) ?? {}).length === 0) {
77
+ formApi.clearErrors();
78
+ if (validate()) {
58
79
  onSubmit(getSnapshot(namespace, true));
59
80
  }
60
81
  }
61
82
  };
62
- const store = createFormProxy(storeApi, errorStore);
63
- const proxy = new Proxy(formApi, {
83
+ proxy = new Proxy(formApi, {
64
84
  get(target, prop) {
65
85
  if (prop in target) {
66
86
  return target[prop];
@@ -69,21 +89,17 @@ function createForm(namespace, defaultValue, fieldConfigs = {}) {
69
89
  }
70
90
  });
71
91
  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
- }
92
+ for (const [path, validator] of validators) {
93
+ const unsubscribe = storeApi.subscribe(path, value => {
94
+ const error = validator(value, proxy);
95
+ if (!error) {
96
+ errorStore.reset(path);
97
+ }
98
+ else {
99
+ errorStore.set(path, error);
100
+ }
101
+ });
102
+ unsubscribeFns.push(unsubscribe);
87
103
  }
88
104
  return [proxy, unsubscribeFns];
89
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juststore",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "A small, expressive, and type-safe state management library for React.",
5
5
  "keywords": [
6
6
  "hooks",
@@ -1,45 +0,0 @@
1
- export { type Atom, createAtom };
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: AtomSetState<T>;
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
- /** Compute a derived value from the current value, similar to useState + useMemo */
20
- useCompute: <R>(fn: (value: T) => R, deps?: readonly unknown[]) => R;
21
- };
22
- type AtomSetState<T> = (value: T | ((prev: T) => T)) => void;
23
- /**
24
- * Creates an atom with a given id and default value.
25
- *
26
- * @param id - The id of the atom
27
- * @param defaultValue - The default value of the atom
28
- * @returns The atom
29
- * @example
30
- * const stateA = createAtom(useId(), false)
31
- * return (
32
- * <>
33
- * <ComponentA/>
34
- * <ComponentB/>
35
- * <Render state={stateA}>
36
- * {(value, setValue) => (
37
- * <button onClick={() => setValue(!value)}>{value ? 'Hide' : 'Show'}</button>
38
- * )}
39
- * </Render>
40
- * <ComponentC/>
41
- * <ComponentD/>
42
- * </>
43
- * )
44
- */
45
- declare function createAtom<T>(id: string, defaultValue: T, persistent?: boolean): Atom<T>;
package/dist/src/atom.js DELETED
@@ -1,141 +0,0 @@
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
- }
@@ -1,97 +0,0 @@
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];
package/dist/src/form.js DELETED
@@ -1,176 +0,0 @@
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
- }