juststore 0.4.4 → 1.0.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
@@ -4,528 +4,534 @@ A small, expressive, and type-safe state management library for React.
4
4
 
5
5
  ## Features
6
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
7
+ - Type-safe deep state with property-style access (`store.user.profile.name`)
8
+ - Path-based API for dynamic access (`store.use("user.profile.name")`)
9
+ - Fine-grained subscriptions powered by `useSyncExternalStore`
10
+ - Optional persistence + cross-tab sync (`createStore`)
11
+ - Memory-only scoped stores (`useMemoryStore`, `createMemoryStore`)
12
+ - Built-in form state + validation (`useForm`, `createForm`)
13
+ - Computed, derived, and mixed read models
16
14
 
17
15
  ## Installation
18
16
 
19
17
  ```bash
20
- npm install juststore
21
- # or
22
18
  bun add juststore
23
19
  ```
24
20
 
25
21
  ## Quick Start
26
22
 
27
23
  ```tsx
28
- import { createStore } from 'juststore'
24
+ import { createStore } from "juststore";
25
+ import { toast } from "sonner";
29
26
 
30
27
  type AppState = {
31
28
  user: {
32
- name: string
29
+ name: string;
33
30
  preferences: {
34
- theme: 'light' | 'dark'
35
- }
36
- }
37
- todos: { id: number; text: string; done: boolean }[]
38
- }
31
+ theme: "light" | "dark";
32
+ };
33
+ };
34
+ todos: { id: number; text: string; done: boolean }[];
35
+ };
39
36
 
40
- const store = createStore<AppState>('app', {
37
+ const store = createStore<AppState>("app", {
41
38
  user: {
42
- name: 'Guest',
43
- preferences: { theme: 'light' }
39
+ name: "Guest",
40
+ preferences: { theme: "light" },
44
41
  },
45
- todos: []
46
- })
47
- ```
42
+ todos: [],
43
+ });
48
44
 
49
- ## Real-World Examples (GoDoxy Web UI)
45
+ async function initUserDetails() {
46
+ const response = await fetch("/api/user/details");
47
+ const data = (await response.json()) as AppState["user"];
48
+ store.user.set(data);
49
+ }
50
50
 
51
- ### Homepage navigation and search
51
+ function ThemeToggle() {
52
+ const theme = store.user.preferences.theme.use();
53
+ const nextTheme = theme === "light" ? "dark" : "light";
52
54
 
53
- ```tsx
54
- import { store } from '@/components/home/store'
55
+ const updateTheme = async () => {
56
+ try {
57
+ const response = await fetch("/api/user/preferences/theme", {
58
+ method: "PUT",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({ theme: nextTheme }),
61
+ });
55
62
 
56
- function HomepageFilters() {
57
- const categories = store.homepageCategories.use()
58
- const [activeCategory, setActiveCategory] = store.navigation.activeCategory.useState()
59
- const query = store.searchQuery.useDebounce(150)
63
+ if (!response.ok) {
64
+ throw new Error("Theme update failed");
65
+ }
60
66
 
61
- const visibleItems =
62
- categories
63
- .find(cat => cat.name === activeCategory)
64
- ?.items.filter(item => item.name.toLowerCase().includes((query ?? '').toLowerCase())) ?? []
67
+ store.user.preferences.theme.set(nextTheme);
68
+ } catch {
69
+ toast.error("Failed to update theme");
70
+ }
71
+ };
65
72
 
66
- return (
67
- <div>
68
- <input
69
- value={query ?? ''}
70
- onChange={e => store.searchQuery.set(e.target.value)}
71
- placeholder="Search services"
72
- />
73
- <div>
74
- {categories.map(name => (
75
- <button
76
- key={name}
77
- data-active={name === activeCategory}
78
- onClick={() => setActiveCategory(name)}
79
- >
80
- {name}
81
- </button>
82
- ))}
83
- </div>
84
- <ul>
85
- {visibleItems.map(item => (
86
- <li key={item.name}>{item.name}</li>
87
- ))}
88
- </ul>
89
- </div>
90
- )
73
+ return <button onClick={updateTheme}>Theme: {theme}</button>;
91
74
  }
92
75
  ```
93
76
 
94
- ### Live route uptime sidebar
77
+ ## Real-World Patterns
78
+
79
+ ### 1) Debounced search + category filter
95
80
 
96
81
  ```tsx
97
- import { useWebSocketApi } from '@/hooks/websocket'
98
- import type { RouteKey } from '@/components/routes/store'
99
- import { store } from '@/components/routes/store'
100
- import type { RouteUptimeAggregate, UptimeAggregate } from '@/lib/api'
101
-
102
- function RoutesUptimeProvider() {
103
- useWebSocketApi<UptimeAggregate>({
104
- endpoint: '/metrics/uptime',
105
- query: { period: '1d' },
106
- onMessage: uptime => {
107
- const keys = uptime.data.map(route => route.alias as RouteKey)
108
- store.set('routeKeys', keys.toSorted())
109
- store.set(
110
- 'uptime',
111
- keys.reduce(
112
- (acc, key, index) => {
113
- acc[key] = uptime.data[index] as RouteUptimeAggregate
114
- return acc
115
- },
116
- {} as Record<RouteKey, RouteUptimeAggregate>
117
- )
118
- )
119
- }
120
- })
82
+ type SearchState = {
83
+ query: string;
84
+ category: "all" | "running" | "stopped";
85
+ services: { id: string; name: string; status: "running" | "stopped" }[];
86
+ };
87
+
88
+ const searchStore = createStore<SearchState>("services-search", {
89
+ query: "",
90
+ category: "all",
91
+ services: [],
92
+ });
93
+
94
+ function SearchQueryInput() {
95
+ const query = searchStore.query.use() ?? "";
96
+ return (
97
+ <input
98
+ value={query}
99
+ onChange={(e) => searchStore.query.set(e.target.value)}
100
+ placeholder="Search services"
101
+ />
102
+ );
103
+ }
121
104
 
122
- return null
105
+ function SearchCategoryFilter() {
106
+ const category = searchStore.category.use();
107
+ return (
108
+ <select
109
+ value={category}
110
+ onChange={(e) =>
111
+ searchStore.category.set(e.target.value as SearchState["category"])
112
+ }
113
+ >
114
+ <option value="all">All</option>
115
+ <option value="running">Running</option>
116
+ <option value="stopped">Stopped</option>
117
+ </select>
118
+ );
123
119
  }
124
- ```
125
120
 
126
- ### Server metrics via WebSockets
121
+ function SearchResults() {
122
+ const query = searchStore.query.useDebounce(150) ?? "";
123
+ const category = searchStore.category.use();
124
+
125
+ const visible = searchStore.services.useCompute(
126
+ (services) => {
127
+ const list = services ?? [];
128
+ return list.filter((service) => {
129
+ const nameMatch = service.name
130
+ .toLowerCase()
131
+ .includes(query.toLowerCase());
132
+ const categoryMatch =
133
+ category === "all" ? true : service.status === category;
134
+ return nameMatch && categoryMatch;
135
+ });
136
+ },
137
+ [query, category],
138
+ );
127
139
 
128
- ```tsx
129
- import { useWebSocketApi } from '@/hooks/websocket'
130
- import { store } from '@/components/servers/store'
131
- import type { MetricsPeriod, SystemInfoAggregate, SystemInfoAggregateMode } from '@/lib/api'
132
-
133
- const MODES: SystemInfoAggregateMode[] = [
134
- 'cpu_average',
135
- 'memory_usage',
136
- 'disks_read_speed',
137
- 'disks_write_speed',
138
- 'disks_iops',
139
- 'disk_usage',
140
- 'network_speed',
141
- 'network_transfer',
142
- 'sensor_temperature'
143
- ]
144
-
145
- function SystemInfoGraphsProvider({ agent, period }: { agent: string; period: MetricsPeriod }) {
146
- MODES.forEach(mode => {
147
- useWebSocketApi<SystemInfoAggregate>({
148
- endpoint: '/metrics/system_info',
149
- query: {
150
- period,
151
- aggregate: mode,
152
- agent_name: agent === 'Main Server' ? '' : agent
153
- },
154
- onMessage: data => {
155
- store.systemInfoGraphs[agent]?.[period]?.[mode]?.set(data)
156
- }
157
- })
158
- })
140
+ return (
141
+ <ul>
142
+ {visible.map((service) => (
143
+ <li key={service.id}>{service.name}</li>
144
+ ))}
145
+ </ul>
146
+ );
147
+ }
159
148
 
160
- return null
149
+ function ServiceSearchPage() {
150
+ return (
151
+ <>
152
+ <SearchQueryInput />
153
+ <SearchCategoryFilter />
154
+ <SearchResults />
155
+ </>
156
+ );
161
157
  }
162
158
  ```
163
159
 
164
- ## Usage
165
-
166
- ### Reading State
160
+ ### 2) WebSocket ingestion into normalized state
167
161
 
168
162
  ```tsx
169
- function UserName() {
170
- // Subscribe to a specific path - re-renders only when this value changes
171
- const name = store.user.name.use()
172
- return <span>{name}</span>
163
+ type RouteUptime = { alias: string; uptime: number };
164
+ type UptimeState = {
165
+ routeKeys: string[];
166
+ uptimeByAlias: Record<string, RouteUptime>;
167
+ };
168
+
169
+ const uptimeStore = createStore<UptimeState>("uptime", {
170
+ routeKeys: [],
171
+ uptimeByAlias: {},
172
+ });
173
+
174
+ function onUptimeMessage(rows: RouteUptime[]) {
175
+ const keys = rows.map((row) => row.alias).toSorted();
176
+ uptimeStore.routeKeys.set(keys);
177
+
178
+ uptimeStore.uptimeByAlias.set(
179
+ rows.reduce<Record<string, RouteUptime>>((acc, row) => {
180
+ acc[row.alias] = row;
181
+ return acc;
182
+ }, {}),
183
+ );
173
184
  }
174
185
 
175
- function Theme() {
176
- // Deep path access
177
- const theme = store.user.preferences.theme.use()
178
- return <span>Current theme: {theme}</span>
186
+ // fine grained subscription
187
+ function UptimeComponent({ alias }: { alias: string }) {
188
+ const uptime = uptimeStore.uptimeByAlias[alias]?.uptime.use();
189
+ return <div>Uptime: {uptime ?? "Unknown"}</div>;
179
190
  }
180
191
  ```
181
192
 
182
- ### Writing State
193
+ ### 3) Dynamic object keys for editable maps
183
194
 
184
195
  ```tsx
185
- function Settings() {
186
- return <button onClick={() => store.user.preferences.theme.set('dark')}>Dark Mode</button>
187
- }
188
-
189
- // Functional updates
190
- store.user.name.set(prev => prev.toUpperCase())
196
+ type HeaderState = {
197
+ headers: Record<string, string>;
198
+ };
191
199
 
192
- // Read without subscribing
193
- const currentName = store.user.name.value
194
- ```
200
+ const headerStore = createStore<HeaderState>("route-headers", {
201
+ headers: {},
202
+ });
195
203
 
196
- ### useState-style Hook
204
+ function HeadersEditor() {
205
+ // keys is a virtual property that returns a state proxy for the keys array
206
+ // it only recomputes when the keys array changes
207
+ const keys = headerStore.headers.keys.use();
197
208
 
198
- ```tsx
199
- function EditableName() {
200
- const [name, setName] = store.user.name.useState()
201
- return <input value={name ?? ''} onChange={e => setName(e.target.value)} />
209
+ return (
210
+ <div>
211
+ {keys.map((key) => (
212
+ <div key={key}>
213
+ <input
214
+ value={key}
215
+ onChange={(e) =>
216
+ headerStore.headers.rename(key, e.target.value.trim())
217
+ }
218
+ />
219
+ {/* Render and update without cascade rerendering the entire HeadersEditor */}
220
+ <RenderWithUpdate state={headerStore.headers[key]}>
221
+ {(value, update) => (
222
+ <input value={value} onChange={(e) => update(e.target.value)} />
223
+ )}
224
+ </RenderWithUpdate>
225
+ <button onClick={() => headerStore.headers[key].reset()}>
226
+ remove
227
+ </button>
228
+ </div>
229
+ ))}
230
+ </div>
231
+ );
202
232
  }
203
233
  ```
204
234
 
205
- ### Debounced Values
235
+ ### 4) Typed form with validation and submit gating
206
236
 
207
237
  ```tsx
208
- function SearchResults() {
209
- // Value updates are debounced by 300ms
210
- const query = store.search.query.useDebounce(300)
211
- // fetch results based on debounced query...
212
- }
213
- ```
238
+ import { useForm } from "juststore";
239
+ import {
240
+ StoreFormInputField,
241
+ StoreFormPasswordField,
242
+ } from "@/components/store/Input"; // from juststore-shadcn
214
243
 
215
- ### Array Operations
244
+ type LoginForm = {
245
+ email: string;
246
+ password: string;
247
+ };
216
248
 
217
- ```tsx
218
- function TodoList() {
219
- const todos = store.todos.use()
249
+ function LoginPage() {
250
+ const form = useForm<LoginForm>(
251
+ { email: "", password: "" },
252
+ {
253
+ email: { validate: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
254
+ password: {
255
+ validate: (value) =>
256
+ value && value.length < 8 ? "Password too short" : undefined,
257
+ },
258
+ },
259
+ );
220
260
 
221
- const addTodo = () => {
222
- store.todos.push({ id: Date.now(), text: 'New todo', done: false })
223
- }
261
+ return (
262
+ <form onSubmit={form.handleSubmit((values) => console.log(values))}>
263
+ <StoreFormInputField
264
+ state={form.email}
265
+ type="email"
266
+ title="Email"
267
+ placeholder="you@example.com"
268
+ />
269
+ <StoreFormPasswordField
270
+ state={form.password}
271
+ title="Password"
272
+ placeholder="At least 8 characters"
273
+ />
274
+ <button type="submit">Sign in</button>
275
+ </form>
276
+ );
277
+ }
278
+ ```
224
279
 
225
- const removeFirst = () => {
226
- store.todos.shift()
227
- }
280
+ ### 5) Mixed read model for unified UI flags
228
281
 
229
- const toggleTodo = (index: number) => {
230
- store.todos.at(index).done.set(prev => !prev)
231
- }
282
+ ```tsx
283
+ import { createMixedState, createStore } from "juststore";
284
+
285
+ type OpsState = {
286
+ syncingConfig: boolean;
287
+ savingRoute: boolean;
288
+ reloadingAgent: boolean;
289
+ };
290
+
291
+ const opsStore = createStore<OpsState>("ops", {
292
+ syncingConfig: false,
293
+ savingRoute: false,
294
+ reloadingAgent: false,
295
+ });
296
+
297
+ const busyState = createMixedState(
298
+ opsStore.syncingConfig,
299
+ opsStore.savingRoute,
300
+ opsStore.reloadingAgent,
301
+ );
302
+
303
+ function GlobalBusyOverlay() {
304
+ const isBusy = busyState.useCompute(
305
+ ([syncingConfig, savingRoute, reloadingAgent]) =>
306
+ syncingConfig || savingRoute || reloadingAgent,
307
+ );
308
+
309
+ if (!isBusy) return null;
310
+ return <div className="overlay">Loading...</div>;
311
+ }
232
312
 
233
- return (
234
- <ul>
235
- {todos?.map((todo, i) => (
236
- <li key={todo.id} onClick={() => toggleTodo(i)}>
237
- {todo.text}
238
- </li>
239
- ))}
240
- </ul>
241
- )
313
+ function BusyLabel() {
314
+ const label = busyState.useCompute(
315
+ ([syncingConfig, savingRoute, reloadingAgent]) => {
316
+ if (syncingConfig) return "Syncing config...";
317
+ if (savingRoute) return "Saving route...";
318
+ if (reloadingAgent) return "Reloading agent...";
319
+ return "Idle";
320
+ },
321
+ );
322
+
323
+ return <span>{label}</span>;
242
324
  }
243
325
  ```
244
326
 
245
- Available array methods: `push`, `pop`, `shift`, `unshift`, `splice`, `reverse`, `sort`, `fill`, `copyWithin`, `sortedInsert`.
327
+ ## Core Usage
246
328
 
247
- ### Render Props
329
+ ### Read and write state
248
330
 
249
331
  ```tsx
250
- function Counter() {
251
- return (
252
- <store.counter.Render>
253
- {(value, update) => (
254
- <button onClick={() => update((value ?? 0) + 1)}>Count: {value ?? 0}</button>
255
- )}
256
- </store.counter.Render>
257
- )
258
- }
332
+ const name = store.user.name.use(); // subscribe
333
+ const current = store.user.name.value; // read without subscribe
334
+ store.user.name.set("Alice");
335
+ store.user.name.set((prev) => prev.toUpperCase());
259
336
  ```
260
337
 
261
- ### Conditional Rendering
338
+ ### Path-based dynamic API
262
339
 
263
340
  ```tsx
264
- function AdminPanel() {
265
- return (
266
- <store.user.role.Show on={role => role === 'admin'}>
267
- <AdminDashboard />
268
- </store.user.role.Show>
269
- )
270
- }
341
+ store.set("user.name", "Alice");
342
+ const name = store.use("user.name");
343
+ const value = store.value("user.name");
271
344
  ```
272
345
 
273
- ### Derived State
346
+ ### Arrays
274
347
 
275
- Transform values without storing the transformed version:
348
+ ```tsx
349
+ store.todos.push({ id: Date.now(), text: "new", done: false });
350
+ store.todos.at(0).done.set(true);
351
+ store.todos.sortedInsert((a, b) => a.id - b.id, {
352
+ id: 2,
353
+ text: "x",
354
+ done: false,
355
+ });
356
+
357
+ const len = store.todos.length;
358
+ const liveLen = store.todos.useLength();
359
+ ```
360
+
361
+ ### Computed and derived values
276
362
 
277
363
  ```tsx
278
- function TemperatureInput() {
279
- // Store holds Celsius, but we want to display/edit Fahrenheit
280
- const fahrenheit = store.temperature.derived({
281
- from: celsius => ((celsius ?? 0) * 9) / 5 + 32,
282
- to: fahrenheit => ((fahrenheit - 32) * 5) / 9
283
- })
284
-
285
- const [temp, setTemp] = fahrenheit.useState()
286
- return <input type="number" value={temp} onChange={e => setTemp(Number(e.target.value))} />
287
- }
364
+ const total = store.cart.items.useCompute(
365
+ (items) => items?.reduce((sum, item) => sum + item.price * item.qty, 0) ?? 0,
366
+ );
367
+
368
+ const fahrenheit = store.temperature.derived({
369
+ from: (celsius) => ((celsius ?? 0) * 9) / 5 + 32,
370
+ to: (f) => ((f - 32) * 5) / 9,
371
+ });
288
372
  ```
289
373
 
290
- ### Computed Values
374
+ ### Render helpers
291
375
 
292
376
  ```tsx
293
- function TotalPrice() {
294
- const total = store.cart.items.useCompute(
295
- items => items?.reduce((sum, item) => sum + item.price * item.qty, 0) ?? 0
296
- )
297
- return <span>Total: ${total}</span>
298
- }
377
+ import { Conditional, Render, RenderWithUpdate } from "juststore";
378
+
379
+ <Render state={store.counter}>{(value) => <span>{value}</span>}</Render>;
380
+
381
+ <RenderWithUpdate state={store.counter}>
382
+ {(value, update) => (
383
+ <button onClick={() => update((value ?? 0) + 1)}>{value}</button>
384
+ )}
385
+ </RenderWithUpdate>;
386
+
387
+ <Conditional state={store.user.role} on={(role) => role === "admin"}>
388
+ {(role) => <div>{role}</div>}
389
+ </Conditional>;
299
390
  ```
300
391
 
301
- ### Memory-Only Stores
392
+ ## API Reference
302
393
 
303
- For complex component-local state with nested structures. Useful when you need to pass state to child components without prop drilling:
394
+ ## Top-Level Exports
304
395
 
305
- ```tsx
306
- import { useMemoryStore, type MemoryStore } from 'juststore'
396
+ - `createStore(namespace, defaultValue, options?)`
397
+ - `createMemoryStore(namespace, defaultValue)`
398
+ - `useMemoryStore(defaultValue)`
399
+ - `createForm(namespace, defaultValue, fieldConfigs?)`
400
+ - `useForm(defaultValue, fieldConfigs?)`
401
+ - `createMixedState(...states)`
402
+ - `createAtom(id, defaultValue, persistent?)`
403
+ - `Render`, `RenderWithUpdate`, `Conditional`
404
+ - `isEqual`
405
+ - All public types from `path`, `types`, and `form`
307
406
 
308
- type SearchState = {
309
- query: string
310
- filters: { category: string; minPrice: number }
311
- results: { id: number; name: string }[]
312
- }
407
+ ### `createStore(namespace, defaultValue, options?)`
313
408
 
314
- function ProductSearch() {
315
- const state = useMemoryStore<SearchState>({
316
- query: '',
317
- filters: { category: 'all', minPrice: 0 },
318
- results: []
319
- })
409
+ Creates a persistent store (unless `options.memoryOnly` is true).
320
410
 
321
- return (
322
- <>
323
- <SearchInput state={state} />
324
- <FilterPanel state={state} />
325
- <ResultsList state={state} />
326
- </>
327
- )
328
- }
411
+ - `namespace: string` - storage namespace
412
+ - `defaultValue: T` - default root value
413
+ - `options?: { memoryOnly?: boolean }`
329
414
 
330
- function SearchInput({ state }: { state: MemoryStore<SearchState> }) {
331
- const query = state.query.use()
332
- return <input value={query} onChange={e => state.query.set(e.target.value)} />
333
- }
415
+ Returns a store that supports both:
334
416
 
335
- function FilterPanel({ state }: { state: MemoryStore<SearchState> }) {
336
- const category = state.filters.category.use()
337
- return (
338
- <select value={category} onChange={e => state.filters.category.set(e.target.value)}>
339
- <option value="all">All</option>
340
- <option value="electronics">Electronics</option>
341
- </select>
342
- )
343
- }
417
+ - deep proxy usage (`store.user.name.use()`)
418
+ - path-based usage (`store.use("user.name")`)
344
419
 
345
- function ResultsList({ state }: { state: MemoryStore<SearchState> }) {
346
- const results = state.results.use()
347
- return (
348
- <ul>
349
- {results?.map(r => (
350
- <li key={r.id}>{r.name}</li>
351
- ))}
352
- </ul>
353
- )
354
- }
355
- ```
420
+ ### `createMemoryStore(namespace, defaultValue)` / `useMemoryStore(defaultValue)`
356
421
 
357
- ### Form Handling
422
+ Creates memory-only stores (no localStorage persistence).
358
423
 
359
- ```tsx
360
- import { useForm } from 'juststore'
424
+ - `createMemoryStore` is useful outside React hooks or for explicit namespaces
425
+ - `useMemoryStore` creates component-scoped state keyed by `useId()`
361
426
 
362
- type LoginForm = {
363
- email: string
364
- password: string
365
- }
427
+ ### `createAtom(id, defaultValue, persistent?)`
366
428
 
367
- function LoginPage() {
368
- const form = useForm<LoginForm>(
369
- { email: '', password: '' },
370
- {
371
- email: { validate: 'not-empty' },
372
- password: {
373
- validate: value => (value && value.length < 8 ? 'Password too short' : undefined)
374
- }
375
- }
376
- )
429
+ Creates a scalar atom-like state.
377
430
 
378
- return (
379
- <form onSubmit={form.handleSubmit(values => console.log(values))}>
380
- <input value={form.email.use() ?? ''} onChange={e => form.email.set(e.target.value)} />
381
- {form.email.useError() && <span>{form.email.error}</span>}
382
-
383
- <input
384
- type="password"
385
- value={form.password.use() ?? ''}
386
- onChange={e => form.password.set(e.target.value)}
387
- />
388
- {form.password.useError() && <span>{form.password.error}</span>}
431
+ - `persistent` defaults to `false`
432
+ - methods: `.value`, `.use()`, `.set(value | updater)`, `.reset()`, `.subscribe(listener)`
389
433
 
390
- <button type="submit">Login</button>
391
- </form>
392
- )
393
- }
394
- ```
434
+ ### `createForm(namespace, defaultValue, fieldConfigs?)` / `useForm(defaultValue, fieldConfigs?)`
395
435
 
396
- Validation options:
436
+ Creates a form store with built-in error state and validation.
397
437
 
398
- - `'not-empty'` - Field must have a value
399
- - `RegExp` - Value must match the pattern
400
- - `(value, form) => string | undefined` - Custom validation function
438
+ Field validators support:
401
439
 
402
- ### Mixed State
440
+ - `"not-empty"`
441
+ - `RegExp`
442
+ - `(value, form) => string | undefined`
403
443
 
404
- Combine multiple state values into a single subscription:
444
+ Additional form methods:
405
445
 
406
- ```tsx
407
- import { createMixedState } from 'juststore'
446
+ - `.useError()`
447
+ - `.error`
448
+ - `.setError(message | undefined)`
449
+ - `.clearErrors()`
450
+ - `.handleSubmit(onSubmit)`
408
451
 
409
- function LoadingOverlay() {
410
- const loading = createMixedState(store.saving, store.fetching, store.uploading)
452
+ ### `createMixedState(...states)`
411
453
 
412
- return (
413
- <loading.Show on={([saving, fetching, uploading]) => saving || fetching || uploading}>
414
- <Spinner />
415
- </loading.Show>
416
- )
417
- }
418
- ```
454
+ Combines multiple states into one read-only tuple-like state.
419
455
 
420
- ### Path-based API
456
+ - `.value` returns current tuple
457
+ - `.use()` subscribes to all source states
458
+ - `.useCompute(fn)` computes derived values from the tuple
421
459
 
422
- The store also exposes a path-based API for dynamic access:
460
+ ### Render utilities
423
461
 
424
- ```tsx
425
- // Equivalent to store.user.name.use()
426
- const name = store.use('user.name')
462
+ - `Render` - render-prop helper for read-only usage
463
+ - `RenderWithUpdate` - render-prop helper with updater callback
464
+ - `Conditional` - conditional render helper based on predicate
427
465
 
428
- // Equivalent to store.user.name.set('Alice')
429
- store.set('user.name', 'Alice')
466
+ ## Store / State Methods
430
467
 
431
- // Equivalent to store.user.name.value
432
- const current = store.value('user.name')
433
- ```
468
+ ### Root store methods
434
469
 
435
- ## API Reference
470
+ | Method | Description |
471
+ | -------------------------------- | ----------------------------------------------- |
472
+ | `.state(path)` | Returns a state proxy for the path |
473
+ | `.use(path)` | Subscribes and returns current value |
474
+ | `.useDebounce(path, delay)` | Debounced subscription |
475
+ | `.useState(path)` | `[value, setValue]` convenience tuple |
476
+ | `.value(path)` | Reads current value without subscription |
477
+ | `.set(path, value, skipUpdate?)` | Sets value (or updater function) |
478
+ | `.reset(path)` | Resets path back to default value for that path |
479
+ | `.rename(path, oldKey, newKey)` | Renames an object key |
480
+ | `.subscribe(path, listener)` | Subscribes to path updates |
481
+ | `.useCompute(path, fn, deps?)` | Computes memoized derived values |
482
+ | `.notify(path)` | Forces listener notification for path |
483
+
484
+ ### Common state-node methods
485
+
486
+ Available on all nodes (`store.a.b.c`):
487
+
488
+ | Method | Description |
489
+ | ---------------------------- | ------------------------------- |
490
+ | `.value` | Read value without subscribing |
491
+ | `.field` | Last path segment |
492
+ | `.use()` | Subscribe and read |
493
+ | `.useDebounce(delay)` | Debounced subscribe/read |
494
+ | `.useState()` | `[value, setValue]` |
495
+ | `.set(value, skipUpdate?)` | Set value (or updater function) |
496
+ | `.reset()` | Reset path to default value |
497
+ | `.subscribe(listener)` | Subscribe to path changes |
498
+ | `.useCompute(fn, deps?)` | Compute derived value |
499
+ | `.derived({ from, to })` | Bidirectional virtual transform |
500
+ | `.ensureArray()` | Array-safe state wrapper |
501
+ | `.ensureObject()` | Object-safe state wrapper |
502
+ | `.withDefault(defaultValue)` | Fallback for nullish values |
503
+ | `.notify()` | Forces listener notification |
504
+
505
+ ### Object-state additions
506
+
507
+ | Method | Description |
508
+ | ------------------------- | --------------------------- |
509
+ | `.keys` | Read-only stable keys state |
510
+ | `.rename(oldKey, newKey)` | Rename object key |
511
+ | `[key]` | Nested field access |
512
+
513
+ ### Array-state additions
436
514
 
437
- ### createStore(namespace, defaultValue, options?)
438
-
439
- Creates a persistent store with localStorage backing and cross-tab sync.
440
-
441
- - `namespace` - Unique identifier for the store
442
- - `defaultValue` - Initial state shape
443
- - `options.memoryOnly` - Disable persistence (default: false)
444
-
445
- ### useMemoryStore(defaultValue)
446
-
447
- Creates a component-scoped store that doesn't persist.
448
-
449
- ### useForm(defaultValue, fieldConfigs?)
450
-
451
- Creates a form store with validation support.
452
-
453
- ### Root Node Methods
454
-
455
- The store root provides path-based methods for dynamic access:
456
-
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)` | Rename a key in an object |
468
- | `.keys(path)` | Get the readonly state of keys of an object |
469
- | `.subscribe(path, fn)` | Subscribe to changes (for effects) |
470
- | `.notify(path)` | Manually trigger subscribers |
471
- | `.useCompute(path, fn)` | Derive a computed value |
472
- | `.Render({ path, children })` | Render prop component |
473
- | `.Show({ path, children, on })` | Conditional render component |
474
-
475
- ### Common State Methods
476
-
477
- Available on all state types (values, objects, arrays):
478
-
479
- | Method | Description |
480
- | ---------------------------- | ------------------------------------------------------------------- |
481
- | `.value` | Read without subscribing |
482
- | `.field` | The field name for the proxy |
483
- | `.use()` | Subscribe and read value (triggers re-render on change) |
484
- | `.useDebounce(ms)` | Subscribe with debounced updates |
485
- | `.useState()` | Returns `[value, setValue]` tuple |
486
- | `.set(value)` | Update value |
487
- | `.set(fn)` | Functional update |
488
- | `.reset()` | Delete value at path |
489
- | `.subscribe(fn)` | Subscribe to changes (for effects) |
490
- | `.notify()` | Manually trigger subscribers |
491
- | `.useCompute(fn)` | Derive a computed value |
492
- | `.derived({ from, to })` | Create bidirectional transform |
493
- | `.ensureArray()` | Get array state for the value |
494
- | `.ensureObject()` | Get object state for the value |
495
- | `.withDefault(defaultValue)` | Return a new state with a default value, and make the type non-nullable |
496
- | `.Render({ children })` | Render prop component |
497
- | `.Show({ children, on })` | Conditional render component |
498
-
499
- ### Object State Methods
500
-
501
- Additional methods available on object states:
502
-
503
- | Method | Description |
504
- | ------------------------- | ----------------------------------------------------- |
505
- | `.keys` | Readonly state of object keys |
506
- | `.rename(oldKey, newKey)` | Rename a key in an object |
507
- | `[key: string]` | Access nested property state by key |
508
-
509
- ### Array State Methods
510
-
511
- Additional methods available on array states:
512
-
513
- | Method | Description |
514
- | ------------------------ | --------------------------------------------------------------------------------- |
515
- | `.length` | Read the array length without subscribing |
516
- | `.useLength()` | Subscribe to array length changes |
517
- | `.push(...items)` | Add items to the end |
518
- | `.pop()` | Remove and return the last item |
519
- | `.shift()` | Remove and return the first item |
520
- | `.unshift(...items)` | Add items to the beginning |
521
- | `.splice(start, deleteCount, ...items)` | Remove/replace items |
522
- | `.reverse()` | Reverse the array in place |
523
- | `.sort(compareFn)` | Sort the array in place |
524
- | `.fill(value, start, end)` | Fill the array with a value |
525
- | `.copyWithin(target, start, end)` | Copy part of the array within itself |
526
- | `.sortedInsert(cmp, ...items)` | Insert items in sorted order using comparison function |
527
- | `.at(index)` | Access element at index (returns proxy) |
528
- | `[index: number]` | Access element at index (returns proxy) |
515
+ | Method | Description |
516
+ | ---------------------------------------- | ------------------------ |
517
+ | `.length` | Current length |
518
+ | `.useLength()` | Subscribe to length only |
519
+ | `.at(index)` / `[index]` | Access item state |
520
+ | `.push(...items)` | Push items |
521
+ | `.pop()` | Pop item |
522
+ | `.shift()` | Shift item |
523
+ | `.unshift(...items)` | Unshift items |
524
+ | `.splice(start, deleteCount?, ...items)` | Splice items |
525
+ | `.reverse()` | Reverse array |
526
+ | `.sort(compareFn?)` | Sort array |
527
+ | `.fill(value, start?, end?)` | Fill array |
528
+ | `.copyWithin(target, start, end?)` | Copy within array |
529
+ | `.sortedInsert(cmp, ...items)` | Insert by comparator |
530
+
531
+ ## Notes
532
+
533
+ - `createStore` persists by default; use `memoryOnly` for ephemeral data.
534
+ - `reset` restores default path value passed to `createStore`, it does not delete to `undefined`.
529
535
 
530
536
  ## License
531
537