juststore 0.0.1 → 0.0.2

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 ADDED
@@ -0,0 +1,358 @@
1
+ # juststore
2
+
3
+ A small, expressive, and type-safe state management library for React.
4
+
5
+ ## Features
6
+
7
+ - **Dot-path addressing** - Access nested values using paths like `store.user.profile.name`
8
+ - **Type-safe paths** - Full TypeScript inference for nested property access
9
+ - **Fine-grained subscriptions** - Components only re-render when their specific data changes
10
+ - **localStorage persistence** - Automatic persistence with cross-tab synchronization via BroadcastChannel
11
+ - **Memory-only stores** - Component-scoped state that doesn't persist
12
+ - **Form handling** - Built-in validation and error management
13
+ - **Array operations** - Native array methods (push, pop, splice, etc.) on array paths
14
+ - **Derived state** - Transform values bidirectionally without extra storage
15
+ - **SSR compatible** - Safe to use in server-side rendering environments
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install juststore
21
+ # or
22
+ bun add juststore
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```tsx
28
+ import { createStore } from 'juststore'
29
+
30
+ type AppState = {
31
+ user: {
32
+ name: string
33
+ preferences: {
34
+ theme: 'light' | 'dark'
35
+ }
36
+ }
37
+ todos: { id: number; text: string; done: boolean }[]
38
+ }
39
+
40
+ const store = createStore<AppState>('app', {
41
+ user: {
42
+ name: 'Guest',
43
+ preferences: { theme: 'light' }
44
+ },
45
+ todos: []
46
+ })
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ### Reading State
52
+
53
+ ```tsx
54
+ function UserName() {
55
+ // Subscribe to a specific path - re-renders only when this value changes
56
+ const name = store.user.name.use()
57
+ return <span>{name}</span>
58
+ }
59
+
60
+ function Theme() {
61
+ // Deep path access
62
+ const theme = store.user.preferences.theme.use()
63
+ return <span>Current theme: {theme}</span>
64
+ }
65
+ ```
66
+
67
+ ### Writing State
68
+
69
+ ```tsx
70
+ function Settings() {
71
+ return <button onClick={() => store.user.preferences.theme.set('dark')}>Dark Mode</button>
72
+ }
73
+
74
+ // Functional updates
75
+ store.user.name.set(prev => prev.toUpperCase())
76
+
77
+ // Read without subscribing
78
+ const currentName = store.user.name.value
79
+ ```
80
+
81
+ ### useState-style Hook
82
+
83
+ ```tsx
84
+ function EditableName() {
85
+ const [name, setName] = store.user.name.useState()
86
+ return <input value={name ?? ''} onChange={e => setName(e.target.value)} />
87
+ }
88
+ ```
89
+
90
+ ### Debounced Values
91
+
92
+ ```tsx
93
+ function SearchResults() {
94
+ // Value updates are debounced by 300ms
95
+ const query = store.search.query.useDebounce(300)
96
+ // fetch results based on debounced query...
97
+ }
98
+ ```
99
+
100
+ ### Array Operations
101
+
102
+ ```tsx
103
+ function TodoList() {
104
+ const todos = store.todos.use()
105
+
106
+ const addTodo = () => {
107
+ store.todos.push({ id: Date.now(), text: 'New todo', done: false })
108
+ }
109
+
110
+ const removeFirst = () => {
111
+ store.todos.shift()
112
+ }
113
+
114
+ const toggleTodo = (index: number) => {
115
+ store.todos.at(index).done.set(prev => !prev)
116
+ }
117
+
118
+ return (
119
+ <ul>
120
+ {todos?.map((todo, i) => (
121
+ <li key={todo.id} onClick={() => toggleTodo(i)}>
122
+ {todo.text}
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ )
127
+ }
128
+ ```
129
+
130
+ Available array methods: `push`, `pop`, `shift`, `unshift`, `splice`, `reverse`, `sort`, `fill`, `copyWithin`, `sortedInsert`.
131
+
132
+ ### Render Props
133
+
134
+ ```tsx
135
+ function Counter() {
136
+ return (
137
+ <store.counter.Render>
138
+ {(value, update) => (
139
+ <button onClick={() => update((value ?? 0) + 1)}>Count: {value ?? 0}</button>
140
+ )}
141
+ </store.counter.Render>
142
+ )
143
+ }
144
+ ```
145
+
146
+ ### Conditional Rendering
147
+
148
+ ```tsx
149
+ function AdminPanel() {
150
+ return (
151
+ <store.user.role.Show on={role => role === 'admin'}>
152
+ <AdminDashboard />
153
+ </store.user.role.Show>
154
+ )
155
+ }
156
+ ```
157
+
158
+ ### Derived State
159
+
160
+ Transform values without storing the transformed version:
161
+
162
+ ```tsx
163
+ function TemperatureInput() {
164
+ // Store holds Celsius, but we want to display/edit Fahrenheit
165
+ const fahrenheit = store.temperature.derived({
166
+ from: celsius => ((celsius ?? 0) * 9) / 5 + 32,
167
+ to: fahrenheit => ((fahrenheit - 32) * 5) / 9
168
+ })
169
+
170
+ const [temp, setTemp] = fahrenheit.useState()
171
+ return <input type="number" value={temp} onChange={e => setTemp(Number(e.target.value))} />
172
+ }
173
+ ```
174
+
175
+ ### Computed Values
176
+
177
+ ```tsx
178
+ function TotalPrice() {
179
+ const total = store.cart.items.compute(
180
+ items => items?.reduce((sum, item) => sum + item.price * item.qty, 0) ?? 0
181
+ )
182
+ return <span>Total: ${total}</span>
183
+ }
184
+ ```
185
+
186
+ ### Memory-Only Stores
187
+
188
+ For complex component-local state with nested structures. Useful when you need to pass state to child components without prop drilling:
189
+
190
+ ```tsx
191
+ import { useMemoryStore, type MemoryStore } from 'juststore'
192
+
193
+ type SearchState = {
194
+ query: string
195
+ filters: { category: string; minPrice: number }
196
+ results: { id: number; name: string }[]
197
+ }
198
+
199
+ function ProductSearch() {
200
+ const state = useMemoryStore<SearchState>({
201
+ query: '',
202
+ filters: { category: 'all', minPrice: 0 },
203
+ results: []
204
+ })
205
+
206
+ return (
207
+ <>
208
+ <SearchInput state={state} />
209
+ <FilterPanel state={state} />
210
+ <ResultsList state={state} />
211
+ </>
212
+ )
213
+ }
214
+
215
+ function SearchInput({ state }: { state: MemoryStore<SearchState> }) {
216
+ const query = state.query.use()
217
+ return <input value={query} onChange={e => state.query.set(e.target.value)} />
218
+ }
219
+
220
+ function FilterPanel({ state }: { state: MemoryStore<SearchState> }) {
221
+ const category = state.filters.category.use()
222
+ return (
223
+ <select value={category} onChange={e => state.filters.category.set(e.target.value)}>
224
+ <option value="all">All</option>
225
+ <option value="electronics">Electronics</option>
226
+ </select>
227
+ )
228
+ }
229
+
230
+ function ResultsList({ state }: { state: MemoryStore<SearchState> }) {
231
+ const results = state.results.use()
232
+ return (
233
+ <ul>
234
+ {results?.map(r => (
235
+ <li key={r.id}>{r.name}</li>
236
+ ))}
237
+ </ul>
238
+ )
239
+ }
240
+ ```
241
+
242
+ ### Form Handling
243
+
244
+ ```tsx
245
+ import { useForm } from 'juststore'
246
+
247
+ type LoginForm = {
248
+ email: string
249
+ password: string
250
+ }
251
+
252
+ function LoginPage() {
253
+ const form = useForm<LoginForm>(
254
+ { email: '', password: '' },
255
+ {
256
+ email: { validate: 'not-empty' },
257
+ password: {
258
+ validate: value => (value && value.length < 8 ? 'Password too short' : undefined)
259
+ }
260
+ }
261
+ )
262
+
263
+ return (
264
+ <form onSubmit={form.handleSubmit(values => console.log(values))}>
265
+ <input value={form.email.use() ?? ''} onChange={e => form.email.set(e.target.value)} />
266
+ {form.email.useError() && <span>{form.email.error}</span>}
267
+
268
+ <input
269
+ type="password"
270
+ value={form.password.use() ?? ''}
271
+ onChange={e => form.password.set(e.target.value)}
272
+ />
273
+ {form.password.useError() && <span>{form.password.error}</span>}
274
+
275
+ <button type="submit">Login</button>
276
+ </form>
277
+ )
278
+ }
279
+ ```
280
+
281
+ Validation options:
282
+
283
+ - `'not-empty'` - Field must have a value
284
+ - `RegExp` - Value must match the pattern
285
+ - `(value, form) => string | undefined` - Custom validation function
286
+
287
+ ### Mixed State
288
+
289
+ Combine multiple state values into a single subscription:
290
+
291
+ ```tsx
292
+ import { createMixedState } from 'juststore'
293
+
294
+ function LoadingOverlay() {
295
+ const loading = createMixedState(store.saving, store.fetching, store.uploading)
296
+
297
+ return (
298
+ <loading.Show on={([saving, fetching, uploading]) => saving || fetching || uploading}>
299
+ <Spinner />
300
+ </loading.Show>
301
+ )
302
+ }
303
+ ```
304
+
305
+ ### Path-based API
306
+
307
+ The store also exposes a path-based API for dynamic access:
308
+
309
+ ```tsx
310
+ // Equivalent to store.user.name.use()
311
+ const name = store.use('user.name')
312
+
313
+ // Equivalent to store.user.name.set('Alice')
314
+ store.set('user.name', 'Alice')
315
+
316
+ // Equivalent to store.user.name.value
317
+ const current = store.value('user.name')
318
+ ```
319
+
320
+ ## API Reference
321
+
322
+ ### createStore(namespace, defaultValue, options?)
323
+
324
+ Creates a persistent store with localStorage backing and cross-tab sync.
325
+
326
+ - `namespace` - Unique identifier for the store
327
+ - `defaultValue` - Initial state shape
328
+ - `options.memoryOnly` - Disable persistence (default: false)
329
+
330
+ ### useMemoryStore(defaultValue)
331
+
332
+ Creates a component-scoped store that doesn't persist.
333
+
334
+ ### useForm(defaultValue, fieldConfigs?)
335
+
336
+ Creates a form store with validation support.
337
+
338
+ ### State Methods
339
+
340
+ | Method | Description |
341
+ | ------------------------ | ------------------------------------------------------- |
342
+ | `.use()` | Subscribe and read value (triggers re-render on change) |
343
+ | `.useDebounce(ms)` | Subscribe with debounced updates |
344
+ | `.useState()` | Returns `[value, setValue]` tuple |
345
+ | `.value` | Read without subscribing |
346
+ | `.set(value)` | Update value |
347
+ | `.set(fn)` | Functional update |
348
+ | `.reset()` | Delete value at path |
349
+ | `.subscribe(fn)` | Subscribe to changes (for effects) |
350
+ | `.notify()` | Manually trigger subscribers |
351
+ | `.compute(fn)` | Derive a computed value |
352
+ | `.derived({ from, to })` | Create bidirectional transform |
353
+ | `.Render` | Render prop component |
354
+ | `.Show` | Conditional render component |
355
+
356
+ ## License
357
+
358
+ AGPL-3.0
package/dist/form.d.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
2
  import type { ArrayProxy, State } from './types';
3
3
  export { useForm, type CreateFormOptions, type DeepNonNullable, type FormArrayProxy, type FormDeepProxy, type FormState, type FormStore };
4
+ /**
5
+ * Common form field methods available on every form state node.
6
+ */
4
7
  type FormCommon = {
5
- /** Subscribe and read the error at path. Re-renders when the error changes. */
8
+ /** Subscribe and read the validation error. Re-renders when the error changes. */
6
9
  useError: () => string | undefined;
7
- /** Read the error at path without subscribing. */
10
+ /** Read the validation error without subscribing. */
8
11
  readonly error: string | undefined;
9
- /** Set the error at path. */
12
+ /** Manually set a validation error. */
10
13
  setError: (error: string | undefined) => void;
11
14
  };
12
15
  type FormArrayProxy<T> = ArrayProxy<T> & FormCommon;
@@ -19,8 +22,13 @@ type FormDeepProxy<T> = NonNullable<T> extends readonly (infer U)[] ? FormArrayP
19
22
  type DeepNonNullable<T> = NonNullable<T> extends readonly (infer U)[] ? U[] : NonNullable<T> extends FieldValues ? {
20
23
  [K in keyof NonNullable<T>]-?: DeepNonNullable<NonNullable<T>[K]>;
21
24
  } : NonNullable<T>;
25
+ /**
26
+ * The form store type, combining form state with validation and submission handling.
27
+ */
22
28
  type FormStore<T extends FieldValues> = FormDeepProxy<T> & {
29
+ /** Clears all validation errors from the form. */
23
30
  clearErrors(): void;
31
+ /** Returns a form submit handler that validates and calls onSubmit with form values. */
24
32
  handleSubmit(onSubmit: (values: T) => void): (e: React.FormEvent) => void;
25
33
  };
26
34
  type NoEmptyValidator = 'not-empty';
@@ -31,4 +39,28 @@ type FieldConfig<T extends FieldValues> = {
31
39
  validate?: Validator<T>;
32
40
  };
33
41
  type CreateFormOptions<T extends FieldValues> = Partial<Record<FieldPath<T>, FieldConfig<T>>>;
42
+ /**
43
+ * React hook that creates a form store with validation support.
44
+ *
45
+ * The form store extends the memory store with error handling and validation.
46
+ * Fields can be configured with validators that run on every change.
47
+ *
48
+ * @param defaultValue - Initial form values
49
+ * @param fieldConfigs - Optional validation configuration per field
50
+ * @returns A form store with validation and submission handling
51
+ *
52
+ * @example
53
+ * const form = useForm(
54
+ * { email: '', password: '' },
55
+ * {
56
+ * email: { validate: 'not-empty' },
57
+ * password: { validate: v => v && v.length < 8 ? 'Too short' : undefined }
58
+ * }
59
+ * )
60
+ *
61
+ * <form onSubmit={form.handleSubmit(values => console.log(values))}>
62
+ * <input value={form.email.use() ?? ''} onChange={e => form.email.set(e.target.value)} />
63
+ * {form.email.useError() && <span>{form.email.error}</span>}
64
+ * </form>
65
+ */
34
66
  declare function useForm<T extends FieldValues>(defaultValue: T, fieldConfigs?: CreateFormOptions<T>): FormStore<T>;
package/dist/form.js CHANGED
@@ -6,6 +6,30 @@ import { getSnapshot, produce } from './impl';
6
6
  import { createNode } from './node';
7
7
  import { createStoreRoot } from './root';
8
8
  export { 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
+ */
9
33
  function useForm(defaultValue, fieldConfigs = {}) {
10
34
  const formId = useId();
11
35
  const namespace = `form:${formId}`;
@@ -53,6 +77,14 @@ function useForm(defaultValue, fieldConfigs = {}) {
53
77
  }
54
78
  return store;
55
79
  }
80
+ /**
81
+ * Creates a form proxy node that extends the base node with error handling.
82
+ *
83
+ * @param storeApi - The form's value store
84
+ * @param errorStore - The form's error store
85
+ * @param path - The field path
86
+ * @returns A proxy with both state methods and error methods
87
+ */
56
88
  const createFormProxy = (storeApi, errorStore, path) => {
57
89
  const proxyCache = new Map();
58
90
  const useError = () => errorStore.use(path);
@@ -73,6 +105,13 @@ const createFormProxy = (storeApi, errorStore, path) => {
73
105
  }
74
106
  });
75
107
  };
108
+ /**
109
+ * Converts a validator configuration into a validation function.
110
+ *
111
+ * @param field - The field path (used for error messages)
112
+ * @param validator - The validator config ('not-empty', RegExp, or function)
113
+ * @returns A validation function, or undefined if no validator provided
114
+ */
76
115
  function getValidator(field, validator) {
77
116
  if (!validator) {
78
117
  return undefined;
@@ -85,18 +124,37 @@ function getValidator(field, validator) {
85
124
  }
86
125
  return validator;
87
126
  }
127
+ /**
128
+ * Validates that a field has a non-empty value.
129
+ *
130
+ * @param field - The field path (used for error message)
131
+ * @param value - The value to validate
132
+ * @returns Error message if empty, undefined if valid
133
+ */
88
134
  function validateNoEmpty(field, value) {
89
135
  if (!stringValue(value)) {
90
136
  return `${pascalCase(field)} is required`;
91
137
  }
92
138
  return undefined;
93
139
  }
140
+ /**
141
+ * Validates that a field matches a regular expression.
142
+ *
143
+ * @param field - The field path (used for error message)
144
+ * @param value - The value to validate
145
+ * @param regex - The pattern to match against
146
+ * @returns Error message if invalid, undefined if valid
147
+ */
94
148
  function validateRegex(field, value, regex) {
95
149
  if (!regex.test(stringValue(value))) {
96
150
  return `${pascalCase(field)} is invalid`;
97
151
  }
98
152
  return undefined;
99
153
  }
154
+ /**
155
+ * Converts a value to a string for validation purposes.
156
+ * Returns empty string for non-primitive values.
157
+ */
100
158
  function stringValue(v) {
101
159
  if (typeof v === 'string') {
102
160
  return v;
package/dist/impl.d.ts CHANGED
@@ -1,19 +1,82 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
2
  export { getNestedValue, getSnapshot, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe };
3
+ /**
4
+ * Joins a namespace and path into a full key string.
5
+ *
6
+ * @param namespace - The store namespace (root key)
7
+ * @param path - Optional dot-separated path within the namespace
8
+ * @returns Combined key string (e.g., "app.user.name")
9
+ */
3
10
  declare function joinPath(namespace: string, path?: string): string;
4
11
  /** Get a nested value from an object/array using a dot-separated path. */
5
12
  declare function getNestedValue(obj: unknown, path: string): unknown;
6
- /** Notify exact, root, and affected child listeners for a given key change. */
13
+ /**
14
+ * Notifies all relevant listeners when a value changes.
15
+ *
16
+ * Handles three types of listeners:
17
+ * 1. Exact match - listeners subscribed to the exact changed path
18
+ * 2. Root listeners - listeners on the namespace root (for full-store subscriptions)
19
+ * 3. Child listeners - listeners on nested paths that may be affected by the change
20
+ *
21
+ * Child listeners are only notified if their specific value actually changed,
22
+ * determined by deep equality comparison.
23
+ */
7
24
  declare function notifyListeners(key: string, oldValue: unknown, newValue: unknown, skipRoot?: boolean, skipChildren?: boolean): void;
8
25
  /** Snapshot getter used by React's useSyncExternalStore. */
9
26
  declare function getSnapshot(key: string): unknown;
10
- /** Core mutation function that updates store and notifies listeners. */
27
+ /**
28
+ * Core mutation function that updates the store and notifies listeners.
29
+ *
30
+ * Handles both setting and deleting values, with optimizations to skip
31
+ * unnecessary updates when the value hasn't changed.
32
+ *
33
+ * @param key - The full key path to update
34
+ * @param value - The new value, or undefined to delete
35
+ * @param skipUpdate - When true, skips notifying listeners
36
+ * @param memoryOnly - When true, skips localStorage persistence
37
+ */
11
38
  declare function produce(key: string, value: unknown, skipUpdate?: boolean, memoryOnly?: boolean): void;
12
- /** React hook: subscribe to and read a namespaced path value. */
39
+ /**
40
+ * React hook that subscribes to and reads a value at a path.
41
+ *
42
+ * Uses useSyncExternalStore for tear-free reads and automatic re-rendering
43
+ * when the subscribed value changes.
44
+ *
45
+ * @param key - The namespace or full key
46
+ * @param path - Optional path within the namespace
47
+ * @returns The current value at the path, or undefined if not set
48
+ */
13
49
  declare function useObject<T extends FieldValues, P extends FieldPath<T>>(key: string, path?: P): FieldPathValue<T, P> | undefined;
14
- /** React hook: subscribe to and read a namespaced path debounced value. */
50
+ /**
51
+ * React hook that subscribes to a value with debounced updates.
52
+ *
53
+ * The returned value only updates after the specified delay has passed
54
+ * since the last change, useful for expensive operations like search.
55
+ *
56
+ * @param key - The namespace or full key
57
+ * @param path - Path within the namespace
58
+ * @param delay - Debounce delay in milliseconds
59
+ * @returns The debounced value at the path
60
+ */
15
61
  declare function useDebounce<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, delay: number): FieldPathValue<T, P> | undefined;
16
- /** Effectful subscription helper that calls onChange with the latest value. */
62
+ /**
63
+ * React hook for side effects when a value changes.
64
+ *
65
+ * Unlike `use()`, this doesn't cause re-renders. Instead, it calls the
66
+ * provided callback whenever the value changes, useful for syncing with
67
+ * external systems or triggering effects.
68
+ *
69
+ * @param key - The full key path to subscribe to
70
+ * @param onChange - Callback invoked with the new value on each change
71
+ */
17
72
  declare function useSubscribe<T>(key: string, onChange: (value: T) => void): void;
18
- /** Set a leaf value under namespace.path, optionally skipping notifications. */
73
+ /**
74
+ * Sets a value at a specific path within a namespace.
75
+ *
76
+ * @param key - The namespace
77
+ * @param path - Path within the namespace
78
+ * @param value - The value to set, or undefined to delete
79
+ * @param skipUpdate - When true, skips notifying listeners
80
+ * @param memoryOnly - When true, skips localStorage persistence
81
+ */
19
82
  declare function setLeaf<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, value: FieldPathValue<T, P> | undefined, skipUpdate?: boolean, memoryOnly?: boolean): void;