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 +420 -414
- package/dist/atom.d.ts +4 -7
- package/dist/atom.js +8 -6
- package/dist/form.d.ts +6 -6
- package/dist/form.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mixed_state.d.ts +2 -2
- package/dist/mixed_state.js +2 -10
- package/dist/node.js +0 -9
- package/dist/root.js +0 -14
- package/dist/types.d.ts +8 -44
- package/dist/utils.d.ts +51 -0
- package/dist/utils.js +57 -0
- package/package.json +8 -18
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
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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
|
|
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:
|
|
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>(
|
|
37
|
+
const store = createStore<AppState>("app", {
|
|
41
38
|
user: {
|
|
42
|
-
name:
|
|
43
|
-
preferences: { theme:
|
|
39
|
+
name: "Guest",
|
|
40
|
+
preferences: { theme: "light" },
|
|
44
41
|
},
|
|
45
|
-
todos: []
|
|
46
|
-
})
|
|
47
|
-
```
|
|
42
|
+
todos: [],
|
|
43
|
+
});
|
|
48
44
|
|
|
49
|
-
|
|
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
|
-
|
|
51
|
+
function ThemeToggle() {
|
|
52
|
+
const theme = store.user.preferences.theme.use();
|
|
53
|
+
const nextTheme = theme === "light" ? "dark" : "light";
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const query = store.searchQuery.useDebounce(150)
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error("Theme update failed");
|
|
65
|
+
}
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
64
|
-
|
|
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
|
-
|
|
77
|
+
## Real-World Patterns
|
|
78
|
+
|
|
79
|
+
### 1) Debounced search + category filter
|
|
95
80
|
|
|
96
81
|
```tsx
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
149
|
+
function ServiceSearchPage() {
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
<SearchQueryInput />
|
|
153
|
+
<SearchCategoryFilter />
|
|
154
|
+
<SearchResults />
|
|
155
|
+
</>
|
|
156
|
+
);
|
|
161
157
|
}
|
|
162
158
|
```
|
|
163
159
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
### Reading State
|
|
160
|
+
### 2) WebSocket ingestion into normalized state
|
|
167
161
|
|
|
168
162
|
```tsx
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
return <
|
|
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
|
-
###
|
|
193
|
+
### 3) Dynamic object keys for editable maps
|
|
183
194
|
|
|
184
195
|
```tsx
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
200
|
+
const headerStore = createStore<HeaderState>("route-headers", {
|
|
201
|
+
headers: {},
|
|
202
|
+
});
|
|
195
203
|
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
###
|
|
235
|
+
### 4) Typed form with validation and submit gating
|
|
206
236
|
|
|
207
237
|
```tsx
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
```
|
|
238
|
+
import { useForm } from "juststore";
|
|
239
|
+
import {
|
|
240
|
+
StoreFormInputField,
|
|
241
|
+
StoreFormPasswordField,
|
|
242
|
+
} from "@/components/store/Input"; // from juststore-shadcn
|
|
214
243
|
|
|
215
|
-
|
|
244
|
+
type LoginForm = {
|
|
245
|
+
email: string;
|
|
246
|
+
password: string;
|
|
247
|
+
};
|
|
216
248
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
store.todos.shift()
|
|
227
|
-
}
|
|
280
|
+
### 5) Mixed read model for unified UI flags
|
|
228
281
|
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
327
|
+
## Core Usage
|
|
246
328
|
|
|
247
|
-
###
|
|
329
|
+
### Read and write state
|
|
248
330
|
|
|
249
331
|
```tsx
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
###
|
|
338
|
+
### Path-based dynamic API
|
|
262
339
|
|
|
263
340
|
```tsx
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
###
|
|
346
|
+
### Arrays
|
|
274
347
|
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
###
|
|
374
|
+
### Render helpers
|
|
291
375
|
|
|
292
376
|
```tsx
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
392
|
+
## API Reference
|
|
302
393
|
|
|
303
|
-
|
|
394
|
+
## Top-Level Exports
|
|
304
395
|
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
query: string
|
|
310
|
-
filters: { category: string; minPrice: number }
|
|
311
|
-
results: { id: number; name: string }[]
|
|
312
|
-
}
|
|
407
|
+
### `createStore(namespace, defaultValue, options?)`
|
|
313
408
|
|
|
314
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
+
Creates memory-only stores (no localStorage persistence).
|
|
358
423
|
|
|
359
|
-
|
|
360
|
-
|
|
424
|
+
- `createMemoryStore` is useful outside React hooks or for explicit namespaces
|
|
425
|
+
- `useMemoryStore` creates component-scoped state keyed by `useId()`
|
|
361
426
|
|
|
362
|
-
|
|
363
|
-
email: string
|
|
364
|
-
password: string
|
|
365
|
-
}
|
|
427
|
+
### `createAtom(id, defaultValue, persistent?)`
|
|
366
428
|
|
|
367
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
391
|
-
</form>
|
|
392
|
-
)
|
|
393
|
-
}
|
|
394
|
-
```
|
|
434
|
+
### `createForm(namespace, defaultValue, fieldConfigs?)` / `useForm(defaultValue, fieldConfigs?)`
|
|
395
435
|
|
|
396
|
-
|
|
436
|
+
Creates a form store with built-in error state and validation.
|
|
397
437
|
|
|
398
|
-
|
|
399
|
-
- `RegExp` - Value must match the pattern
|
|
400
|
-
- `(value, form) => string | undefined` - Custom validation function
|
|
438
|
+
Field validators support:
|
|
401
439
|
|
|
402
|
-
|
|
440
|
+
- `"not-empty"`
|
|
441
|
+
- `RegExp`
|
|
442
|
+
- `(value, form) => string | undefined`
|
|
403
443
|
|
|
404
|
-
|
|
444
|
+
Additional form methods:
|
|
405
445
|
|
|
406
|
-
|
|
407
|
-
|
|
446
|
+
- `.useError()`
|
|
447
|
+
- `.error`
|
|
448
|
+
- `.setError(message | undefined)`
|
|
449
|
+
- `.clearErrors()`
|
|
450
|
+
- `.handleSubmit(onSubmit)`
|
|
408
451
|
|
|
409
|
-
|
|
410
|
-
const loading = createMixedState(store.saving, store.fetching, store.uploading)
|
|
452
|
+
### `createMixedState(...states)`
|
|
411
453
|
|
|
412
|
-
|
|
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
|
-
|
|
456
|
+
- `.value` returns current tuple
|
|
457
|
+
- `.use()` subscribes to all source states
|
|
458
|
+
- `.useCompute(fn)` computes derived values from the tuple
|
|
421
459
|
|
|
422
|
-
|
|
460
|
+
### Render utilities
|
|
423
461
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
store.set('user.name', 'Alice')
|
|
466
|
+
## Store / State Methods
|
|
430
467
|
|
|
431
|
-
|
|
432
|
-
const current = store.value('user.name')
|
|
433
|
-
```
|
|
468
|
+
### Root store methods
|
|
434
469
|
|
|
435
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|