stroid 0.0.2 → 0.0.3

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
@@ -1,46 +1,1056 @@
1
- # stroid
1
+ # Stroid
2
+
3
+ > Compact, batteries-included state management for JavaScript & React.
4
+
5
+ Mutable-friendly updates · Selectors · Persistence · Async caching · Sync · Drop-in presets — all in one ergonomic package.
6
+
7
+ > **Note:** This state management library is not for beginners.
8
+ [![npm version](https://img.shields.io/npm/v/stroid)](https://www.npmjs.com/package/stroid)
9
+ [![npm downloads](https://img.shields.io/npm/dm/stroid)](https://www.npmjs.com/package/stroid)
10
+ [![bundlephobia minzip](https://img.shields.io/bundlephobia/minzip/stroid)](https://bundlephobia.com/package/stroid)
11
+ [![Codecov](https://codecov.io/gh/Himesh-Bhattarai/stroid/branch/main/graph/badge.svg)](https://app.codecov.io/gh/Himesh-Bhattarai/stroid)
12
+ [![GitHub stars](https://img.shields.io/github/stars/Himesh-Bhattarai/stroid?style=social)](https://github.com/Himesh-Bhattarai/stroid/stargazers)
13
+ [![open issues](https://img.shields.io/github/issues/Himesh-Bhattarai/stroid)](https://github.com/Himesh-Bhattarai/stroid/issues)
14
+ [![open PRs](https://img.shields.io/github/issues-pr/Himesh-Bhattarai/stroid)](https://github.com/Himesh-Bhattarai/stroid/pulls)
15
+ [![license](https://img.shields.io/github/license/Himesh-Bhattarai/stroid)](./LICENSE)
16
+ [![ESM only](https://img.shields.io/badge/ESM-only-blue)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
17
+ [![tree-shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)](https://bundlephobia.com/package/stroid)
18
+ [![side-effect free](https://img.shields.io/badge/side--effect%20free-yes-brightgreen)](https://bundlephobia.com/package/stroid)
19
+ [![no dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/stroid?activeTab=dependencies)
20
+
21
+ Jump to: [Installation](#installation) | [Quick Start](#quick-start) | [Core API](#core-api) | [React Hooks](#react-hooks) | [Persistence](#persistence) | [Async Helper](#async-helper) | [Testing](#testing) | [Roadmap](#roadmap)
22
+
23
+ ---
24
+
25
+ ## Package Stats
26
+
27
+ | Metric | Value |
28
+ |---|---|
29
+ | Version | `0.0.2` |
30
+ | Maintainer | [@himesh.hcb](https://www.npmjs.com/~himesh.hcb) |
31
+ | Dependencies | **0** |
32
+ | Bundle size (minified) | **22.7 kB** |
33
+ | Bundle size (minified + gzipped) | **8.6 kB** |
34
+ | Unpacked size | **43 kB** |
35
+ | Download time — Slow 3G | **171 ms** |
36
+ | Download time — Emerging 4G | **10 ms** |
37
+ | Vulnerability score | **100 / 100** |
38
+ | Quality score | **83 / 100** |
39
+ | Maintenance score | **86 / 100** |
40
+ | License score | **100 / 100** |
41
+
42
+ > Verified on [BundlePhobia](https://bundlephobia.com/package/stroid) · [Socket.dev](https://socket.dev/npm/package/stroid)
43
+
44
+ ---
45
+
46
+ ## Table of Contents
47
+
48
+ - [Package Stats](#package-stats)
49
+ - [Why Stroid?](#why-stroid)
50
+ - [Installation](#installation)
51
+ - [Quick Start](#quick-start)
52
+ - [Subpath Imports](#subpath-imports)
53
+ - [Core API](#core-api)
54
+ - [createStore](#createstore)
55
+ - [getStore](#getstore)
56
+ - [setStore](#setstore)
57
+ - [mergeStore](#mergestore)
58
+ - [resetStore](#resetstore)
59
+ - [deleteStore](#deletestore)
60
+ - [setStoreBatch](#setstorebatch)
61
+ - [subscribeWithSelector](#subscribewithselector)
62
+ - [createStoreForRequest](#createstoreforresquest)
63
+ - [React Hooks](#react-hooks)
64
+ - [useStore](#usestore)
65
+ - [useSelector](#useselector)
66
+ - [useAsyncStore](#useasyncstore)
67
+ - [useStoreField](#usestorefield)
68
+ - [useFormStore](#useformstore)
69
+ - [useStoreStatic](#usestorestatic)
70
+ - [Nested Updates](#nested-updates)
71
+ - [Chain API](#chain-api)
72
+ - [Path String / Array](#path-string--array)
73
+ - [Async Helper](#async-helper)
74
+ - [Persistence](#persistence)
75
+ - [Sync via BroadcastChannel](#sync-via-broadcastchannel)
76
+ - [Presets](#presets)
77
+ - [DevTools & Metrics](#devtools--metrics)
78
+ - [SSR / Next.js](#ssr--nextjs)
79
+ - [Testing](#testing)
80
+ - [Validation & Middleware](#validation--middleware)
81
+ - [TypeScript](#typescript)
82
+ - [Limitations & Gotchas](#limitations--gotchas)
83
+ - [Common Problems & Solutions](#common-problems--solutions)
84
+ - [Roadmap](#roadmap)
85
+ - [Versioning](#versioning)
86
+
87
+ ---
88
+
89
+ ## Why Stroid?
90
+
91
+ **Why we built it this way**
92
+ - One consistent API for every state need so teams avoid multiple mental models.
93
+ - No providers, no boilerplate, no magic — setup stays explicit and debuggable.
94
+ - Batteries included but opt-out capable; features like persistence or sync can be disabled per store.
95
+ - Mutable draft updates keep developer ergonomics high while producing safe immutable results.
96
+ - Tiny core, extensible via middleware instead of hidden globals.
97
+
98
+ Clear design principles make the trade-offs obvious and help developers decide when Stroid is the right fit.
99
+
100
+ Most state libraries make you choose between simplicity and power. Stroid gives you both — mutable-friendly updates, built-in async/SWR, persistence with migrations, tab sync, SSR safety, drop-in presets, and ESM subpath imports, all in one package with no plugins required.
101
+
102
+ ---
103
+
104
+ ## Installation
105
+
106
+ ```bash
107
+ # npm
108
+ npm install stroid
109
+
110
+ # yarn
111
+ yarn add stroid
112
+
113
+ # pnpm
114
+ pnpm add stroid
115
+ ```
116
+
117
+ **Requirements:** Node 18+ · ESM-only (no CommonJS)
118
+
119
+ ---
120
+
121
+ ## Quick Start
122
+
123
+ ```js
124
+ import { createStore, setStore, useStore } from "stroid";
125
+
126
+ // 1. Create a store with optional devtools + persistence
127
+ createStore("user", { name: "Alex", theme: "dark" }, { devtools: true, persist: true });
128
+
129
+ // 2. Update with a mutable-friendly draft
130
+ setStore("user", (draft) => {
131
+ draft.name = "Jordan";
132
+ });
133
+
134
+ // 3. Read in a React component
135
+ function Profile() {
136
+ const name = useStore("user", "name");
137
+ return <h1>{name}</h1>;
138
+ }
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Subpath Imports
144
+
145
+ Stroid is ESM-only and ships focused subpaths to keep bundles lean.
146
+
147
+ | Subpath | Contents |
148
+ |---|---|
149
+ | `stroid` | Everything (convenience re-export) |
150
+ | `stroid/core` | `createStore`, `getStore`, `setStore`, `mergeStore`, `resetStore`, `deleteStore`, `setStoreBatch`, `subscribeWithSelector`, `createStoreForRequest` |
151
+ | `stroid/react` | All React hooks (`useStore`, `useSelector`, `useAsyncStore`, …) |
152
+ | `stroid/async` | Async helper with SWR, TTL, dedupe, retries, abort |
153
+ | `stroid/testing` | `createMockStore`, `resetAllStoresForTest` |
154
+
155
+ > **Note:** Subpath imports currently share an internal chunk. True per-feature isolation is planned for **v1.1**.
156
+
157
+ For non-React / Node.js usage, prefer `stroid/core`.
158
+
159
+ ---
160
+
161
+ ## Core API
162
+
163
+ ### `createStore`
164
+
165
+ ```ts
166
+ createStore(name: string, initialState: object, options?: StoreOptions): void
167
+ ```
168
+
169
+ Creates a named store. Safe to call multiple times — calling it again with an existing name is a no-op (state is preserved).
170
+
171
+ ```js
172
+ import { createStore } from "stroid/core";
173
+
174
+ createStore("settings", { theme: "light", language: "en" }, {
175
+ devtools: true, // connect to Redux DevTools
176
+ persist: true, // persist to localStorage (or custom adapter)
177
+ });
178
+ ```
179
+
180
+ **Options**
181
+
182
+ | Option | Type | Default | Description |
183
+ |---|---|---|---|
184
+ | `devtools` | `boolean` | `false` | Enable Redux DevTools bridge |
185
+ | `persist` | `boolean \| PersistOptions` | `false` | Persist state across page loads |
186
+ | `sync` | `boolean \| SyncOptions` | `false` | Sync state across browser tabs via BroadcastChannel |
187
+ | `schema` | `ZodSchema \| YupSchema \| predicate` | — | Validate state shape on every update |
188
+ | `validator` | `boolean` | `false` | Enable built-in validator (requires `schema`) |
189
+ | `historyLimit` | `number` | `50` | Max shallow diffs to keep for time-travel in DevTools |
190
+ | `middleware` | `Middleware[]` | `[]` | Array of middleware hooks — see [Validation & Middleware](#validation--middleware) |
191
+ | `allowSSRGlobalStore` | `boolean` | `false` | Suppress SSR warning for global stores |
192
+
193
+ ---
194
+
195
+ ### `getStore`
196
+
197
+ ```ts
198
+ getStore(name: string): object
199
+ getStore(name: string, path: string): any
200
+ ```
201
+
202
+ Returns a **snapshot** (plain object) of the current store state, or a specific value at a dot-notation path.
203
+
204
+ ```js
205
+ import { getStore } from "stroid/core";
206
+
207
+ const state = getStore("user"); // full clone
208
+ const theme = getStore("user", "profile.theme"); // "dark"
209
+ ```
210
+
211
+ ---
212
+
213
+ ### `setStore`
214
+
215
+ ```ts
216
+ setStore(name: string, updater: object | ((draft: Draft) => void)): void
217
+ setStore(name: string, path: string | string[], value: any): void
218
+ ```
219
+
220
+ Replaces or mutably updates the store state. Supports three calling styles:
221
+
222
+ ```js
223
+ import { setStore } from "stroid/core";
224
+
225
+ // 1. Object update (shallow merge)
226
+ setStore("settings", { theme: "dark" });
227
+
228
+ // 2. Mutable draft (Immer-style)
229
+ setStore("settings", (draft) => {
230
+ draft.theme = "dark";
231
+ draft.language = "fr";
232
+ });
233
+
234
+ // 3. Path update — dot string or array (path must already exist)
235
+ setStore("user", "profile.name", "Kai");
236
+ setStore("user", ["profile", "name"], "Kai");
237
+ ```
238
+
239
+ For readable chained access on deeply nested state, see the [Chain API](#chain-api).
240
+
241
+ ---
242
+
243
+ ### `mergeStore`
244
+
245
+ ```ts
246
+ mergeStore(name: string, partial: object): void
247
+ ```
248
+
249
+ Deep-merges a partial object into the store.
250
+
251
+ ```js
252
+ import { mergeStore } from "stroid/core";
253
+
254
+ mergeStore("user", { profile: { city: "Kathmandu" } });
255
+ ```
256
+
257
+ ---
258
+
259
+ ### `resetStore`
260
+
261
+ ```ts
262
+ resetStore(name: string): void
263
+ ```
264
+
265
+ Resets the store to its original `initialState`.
266
+
267
+ ```js
268
+ import { resetStore } from "stroid/core";
269
+
270
+ resetStore("user");
271
+ ```
272
+
273
+ ---
274
+
275
+ ### `deleteStore`
276
+
277
+ ```ts
278
+ deleteStore(name: string): void
279
+ ```
280
+
281
+ Completely removes the store and all its subscribers.
282
+
283
+ ```js
284
+ import { deleteStore } from "stroid/core";
285
+
286
+ deleteStore("temp_store");
287
+ ```
288
+
289
+ ---
290
+
291
+ ### `clearAllStores` / `hasStore` / `listStores`
292
+
293
+ Utility helpers for store introspection and bulk teardown.
294
+
295
+ ```js
296
+ import { clearAllStores, hasStore, listStores } from "stroid/core";
297
+
298
+ hasStore("user"); // true | false
299
+ listStores(); // ["user", "settings", ...]
300
+ clearAllStores(); // removes every store and all subscribers
301
+ ```
302
+
303
+ > `clearAllStores` is destructive — use `resetAllStoresForTest` from `stroid/testing` in tests instead.
304
+
305
+ ---
306
+
307
+ ### `setStoreBatch`
308
+
309
+ ```ts
310
+ setStoreBatch(fn: () => void): void
311
+ ```
312
+
313
+ Runs multiple store updates in a single batch — subscribers are notified only **once** at the end.
314
+
315
+ ```js
316
+ import { setStoreBatch, setStore, mergeStore } from "stroid/core";
317
+
318
+ setStoreBatch(() => {
319
+ setStore("user", { name: "Jordan" });
320
+ mergeStore("settings", { theme: "dark" });
321
+ });
322
+ ```
323
+
324
+ ---
325
+
326
+ ### `subscribeWithSelector`
327
+
328
+ ```ts
329
+ subscribeWithSelector(
330
+ name: string,
331
+ selector: (state: object) => any,
332
+ callback: (value: any) => void
333
+ ): () => void
334
+ ```
335
+
336
+ Subscribe to a specific slice of state. Returns an `unsubscribe` function.
337
+
338
+ ```js
339
+ import { subscribeWithSelector } from "stroid/core";
340
+
341
+ const unsub = subscribeWithSelector(
342
+ "user",
343
+ (state) => state.name,
344
+ (name) => console.log("Name changed:", name)
345
+ );
346
+
347
+ // Later:
348
+ unsub();
349
+ ```
350
+
351
+ ---
352
+
353
+ ### `createStoreForRequest`
354
+
355
+ ```ts
356
+ createStoreForRequest(name: string, initialState: object): Store
357
+ ```
358
+
359
+ Creates a **request-scoped** store, safe for SSR environments (Next.js, Remix, etc.). The store is isolated per request and does not pollute global state.
360
+
361
+ ```js
362
+ import { createStoreForRequest, setStore, getStore } from "stroid/core";
363
+
364
+ // Inside a Next.js API route or Server Component
365
+ const store = createStoreForRequest("req_store", { token: null });
366
+ setStore("req_store", { token: "abc123" });
367
+ console.log(getStore("req_store")); // { token: "abc123" }
368
+ ```
369
+
370
+ ---
371
+
372
+ ## React Hooks
373
+
374
+ All hooks live in `stroid/react` (or the main `stroid` entry).
375
+
376
+ > **Rule:** Hooks must only be called inside React function components. Calling them in Node.js or class components will throw.
377
+
378
+ ---
379
+
380
+ ### `useStore`
381
+
382
+ ```ts
383
+ useStore(name: string, field?: string): any
384
+ ```
385
+
386
+ Subscribes to the full store — or a single top-level field — and re-renders on changes.
387
+
388
+ ```jsx
389
+ import { useStore } from "stroid/react";
390
+
391
+ function Profile() {
392
+ // ⚠️ Dev warning: subscribing to the whole store can cause extra re-renders
393
+ const user = useStore("user");
394
+
395
+ // ✅ Preferred: subscribe to a specific field
396
+ const name = useStore("user", "name");
397
+
398
+ return <p>{name}</p>;
399
+ }
400
+ ```
401
+
402
+ > In development, subscribing to the whole store (no `field`) emits a warning. Use `useSelector` or pass a field name for fine-grained subscriptions.
403
+
404
+ ---
405
+
406
+ ### `useSelector`
407
+
408
+ ```ts
409
+ useSelector(name: string, selector: (state: object) => any): any
410
+ ```
411
+
412
+ Subscribes to a derived value. Only re-renders when the selected value changes.
413
+
414
+ ```jsx
415
+ import { useSelector } from "stroid/react";
416
+
417
+ function ThemeBadge() {
418
+ const isDark = useSelector("settings", (s) => s.theme === "dark");
419
+ return <span>{isDark ? "🌙 Dark" : "☀️ Light"}</span>;
420
+ }
421
+ ```
422
+
423
+ ---
424
+
425
+ ### `useAsyncStore`
426
+
427
+ ```ts
428
+ useAsyncStore(name: string): { data: any, loading: boolean, error: string | null, status: string, cached: boolean }
429
+ ```
430
+
431
+ Reads the async state shape from a store. Pair with `fetchStore` from `stroid/async` for full SWR/TTL support.
432
+
433
+ ```jsx
434
+ import { useAsyncStore } from "stroid/react";
435
+
436
+ function UserCard() {
437
+ const { data, loading, error, status, cached } = useAsyncStore("async_user");
438
+
439
+ if (loading) return <p>Loading… {cached && "(showing cached)"}</p>;
440
+ if (error) return <p>Error: {error}</p>;
441
+ return <p>{data?.name}</p>;
442
+ }
443
+ ```
444
+
445
+ | Field | Description |
446
+ |---|---|
447
+ | `data` | The resolved value, or `null` |
448
+ | `loading` | `true` while a fetch is in flight |
449
+ | `error` | Error message string, or `null` |
450
+ | `status` | `"idle"` \| `"loading"` \| `"success"` \| `"error"` |
451
+ | `cached` | `true` if data was served from cache (stale-while-revalidate) |
452
+
453
+ ---
454
+
455
+ ### `useStoreField`
456
+
457
+ ```ts
458
+ useStoreField(name: string, field: string): [value: any, setter: (v: any) => void]
459
+ ```
460
+
461
+ Returns a `[value, setter]` tuple for a single field — similar to `useState`.
462
+
463
+ ```jsx
464
+ import { useStoreField } from "stroid/react";
465
+
466
+ function ThemeToggle() {
467
+ const [theme, setTheme] = useStoreField("settings", "theme");
468
+ return (
469
+ <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
470
+ Toggle Theme
471
+ </button>
472
+ );
473
+ }
474
+ ```
475
+
476
+ ---
477
+
478
+ ### `useFormStore`
479
+
480
+ ```ts
481
+ useFormStore(name: string, path: string): { value: any, onChange: (e: ChangeEvent) => void }
482
+ ```
483
+
484
+ Lightweight input binding backed by a store field. Returns `value` and `onChange` — wire directly to an `<input>`.
485
+
486
+ ```jsx
487
+ import { useFormStore } from "stroid/react";
488
+
489
+ createStore("profile", { email: "", bio: "" });
490
+
491
+ function ProfileForm() {
492
+ const emailField = useFormStore("profile", "email");
493
+ const bioField = useFormStore("profile", "bio");
494
+
495
+ return (
496
+ <form>
497
+ <input type="email" {...emailField} />
498
+ <textarea {...bioField} />
499
+ </form>
500
+ );
501
+ }
502
+ ```
503
+
504
+ ---
505
+
506
+ ### `useStoreStatic`
507
+
508
+ ```ts
509
+ useStoreStatic(name: string): object
510
+ ```
511
+
512
+ Returns the store state **without** subscribing. The component will **not** re-render on changes. Useful for reading state inside event handlers.
513
+
514
+ ```jsx
515
+ import { useStoreStatic } from "stroid/react";
516
+
517
+ function SubmitButton() {
518
+ const state = useStoreStatic("form");
519
+
520
+ const handleSubmit = () => {
521
+ // Reads latest state at call time, no subscription needed
522
+ console.log(state.email);
523
+ };
524
+
525
+ return <button onClick={handleSubmit}>Submit</button>;
526
+ }
527
+ ```
528
+
529
+ ---
530
+
531
+ ## Nested Updates
532
+
533
+ Stroid gives you three ways to update deeply nested state. Pick whichever fits your style.
534
+
535
+ ---
536
+
537
+ ### Chain API
538
+
539
+ Import `chain` from `stroid/core` (or the root `stroid` entry) for a fluent, readable API when working with deeply nested paths.
540
+
541
+ ```js
542
+ import { chain, createStore } from "stroid/core";
543
+
544
+ createStore("user", { profile: { name: "Ana", theme: "light" } });
545
+
546
+ // Write a single field
547
+ chain("user").nested("profile").target("name").set("Eli");
548
+
549
+ // Read a single field
550
+ const name = chain("user").nested("profile").target("name").value;
551
+
552
+ // Write an entire branch
553
+ chain("user").nested("profile").set({ name: "Jo", theme: "dark" });
554
+
555
+ // Read an entire branch
556
+ const profile = chain("user").nested("profile").value;
557
+ ```
558
+
559
+ **Behaviour & rules**
560
+
561
+ | Rule | Detail |
562
+ |---|---|
563
+ | Keys must be non-empty strings | Empty keys (`""`) are rejected with a warning |
564
+ | Paths must already exist | Chain does **not** auto-create missing keys — use `mergeStore` to add new keys first |
565
+ | Store must exist | If the store hasn't been created yet, chain warns and no-ops until it exists |
566
+ | `target()` requires a key | Calling `.target()` without an argument warns and no-ops |
567
+
568
+ ---
569
+
570
+ ### Path String / Array
571
+
572
+ Pass a dot-notation string or an array of keys as the second argument to `setStore` for a quick one-liner update.
573
+
574
+ ```js
575
+ import { setStore } from "stroid/core";
576
+
577
+ // Dot-notation string
578
+ setStore("user", "profile.name", "Kai");
579
+
580
+ // Array path — useful when keys contain dots
581
+ setStore("user", ["profile", "name"], "Kai");
582
+ ```
583
+
584
+ Both forms are equivalent and follow the same rules as the Chain API — the path must already exist in the store.
585
+
586
+ ---
587
+
588
+ **Choosing the right approach**
589
+
590
+ | Approach | Best for |
591
+ |---|---|
592
+ | Draft updater `(draft) => {}` | Multiple fields at once, conditional logic |
593
+ | `chain()` | Readable single-field reads/writes in complex nested structures |
594
+ | Path string / array | Quick one-liner updates in scripts or handlers |
595
+
596
+ ---
597
+
598
+ ## Async Helper
599
+
600
+ The async helper (`stroid/async`) wraps fetch calls with SWR-style caching, deduplication, retries, and abort support. Use `useAsyncStore` in React to read the result reactively.
601
+
602
+ ```js
603
+ import { fetchStore, refetchStore, enableRevalidateOnFocus } from "stroid/async";
604
+
605
+ // Fetch and cache into a store
606
+ await fetchStore("todos", "/api/todos", {
607
+ ttl: 30_000, // Cache fresh for 30 seconds
608
+ staleWhileRevalidate: true, // Serve stale data while refreshing in background
609
+ dedupe: true, // Deduplicate concurrent requests with same cacheKey
610
+ retry: 3, // Retry on failure
611
+ retryDelay: 500, // ms between retries
612
+ retryBackoff: true, // Exponential backoff
613
+ cacheKey: "list", // Dedupe key — defaults to store name
614
+ transform: (data) => data.items, // Transform response before storing
615
+ onSuccess: (data) => console.log("Loaded:", data),
616
+ onError: (err) => console.error("Failed:", err),
617
+ signal: abortController.signal, // Abort support
618
+ });
619
+
620
+ // Re-run the last fetch (uses last URL + options)
621
+ await refetchStore("todos");
622
+
623
+ // Optionally revalidate when the window regains focus or comes back online
624
+ enableRevalidateOnFocus("todos");
625
+ ```
626
+
627
+ **Options**
628
+
629
+ | Option | Type | Default | Description |
630
+ |---|---|---|---|
631
+ | `ttl` | `number` (ms) | `0` | How long cached data stays fresh |
632
+ | `staleWhileRevalidate` | `boolean` | `false` | Serve stale data while fetching fresh |
633
+ | `dedupe` | `boolean` | `true` | Deduplicate in-flight requests by `name:cacheKey` |
634
+ | `retry` | `number` | `0` | Number of retry attempts on error |
635
+ | `retryDelay` | `number` (ms) | `0` | Delay between retries |
636
+ | `retryBackoff` | `boolean` | `false` | Exponential backoff on retries |
637
+ | `cacheKey` | `string` | store name | Dedupe key — reusing the same key with different URLs reuses the cache |
638
+ | `transform` | `(data) => any` | — | Transform response before writing to store |
639
+ | `onSuccess` | `(data) => void` | — | Called after a successful fetch |
640
+ | `onError` | `(err) => void` | — | Called after a failed fetch |
641
+ | `signal` | `AbortSignal` | — | Abort the request |
642
+
643
+ > **Note:** Dedupe is keyed by `name:cacheKey`. If you reuse a `cacheKey` with different URLs, the cache from the first call will be returned.
644
+
645
+ ---
646
+
647
+ ## Persistence
648
+
649
+ Enable persistence by passing `persist: true` (uses `localStorage` by default), `"session"` for `sessionStorage`, or a full options object.
650
+
651
+ ```js
652
+ // Shorthand — localStorage
653
+ createStore("user", { name: "Alex" }, { persist: true });
654
+
655
+ // Shorthand — sessionStorage
656
+ createStore("token", { value: null }, { persist: "session" });
657
+
658
+ // Full options
659
+ createStore("auth", { token: null }, {
660
+ persist: {
661
+ key: "auth_v2", // Storage key (default: store name)
662
+ driver: localStorage, // Custom storage driver
663
+ version: 2, // Schema version — triggers migration on mismatch
664
+ encrypt: (str) => myEncrypt(str), // Optional encryption
665
+ decrypt: (str) => myDecrypt(str), // Optional decryption
666
+ migrate: (oldState, oldVersion) => {
667
+ if (oldVersion === 1) return { ...oldState, role: "user" };
668
+ return oldState;
669
+ },
670
+ }
671
+ });
672
+ ```
673
+
674
+ **Custom Driver**
675
+
676
+ Any object implementing `getItem`, `setItem`, and `removeItem` works as a driver:
677
+
678
+ ```js
679
+ createStore("prefs", initialState, {
680
+ persist: {
681
+ driver: {
682
+ getItem: (key) => myAsyncStorage.get(key),
683
+ setItem: (key, value) => myAsyncStorage.set(key, value),
684
+ removeItem: (key) => myAsyncStorage.remove(key),
685
+ }
686
+ }
687
+ });
688
+ ```
689
+
690
+ > ⚠️ **Gotchas**
691
+ > - Persistence is **synchronous** — large states can block the main thread.
692
+ > - State is validated against `schema` on load if one is provided; invalid persisted state triggers `onError`.
693
+ > - Stroid warns on storage key collisions across stores.
694
+
695
+ ---
696
+
697
+ ## Sync via BroadcastChannel
698
+
699
+ Keep multiple browser tabs in sync automatically. Enable per-store with `sync: true` or pass options for conflict resolution.
700
+
701
+ ```js
702
+ // Simple — last write wins
703
+ createStore("cart", { items: [] }, { sync: true });
704
+
705
+ // With conflict resolver
706
+ createStore("cart", { items: [] }, {
707
+ sync: {
708
+ conflictResolver: ({ local, incoming }) => incoming // always prefer incoming
709
+ }
710
+ });
711
+ ```
712
+
713
+ > **Notes**
714
+ > - Falls back to a no-op silently if `BroadcastChannel` is not available (e.g. older browsers).
715
+ > - The default conflict strategy is **last-write-wins** using `Date.now()` — clock skew between tabs can cause unexpected results. Use a custom `conflictResolver` for critical data.
716
+
717
+ ---
718
+
719
+ ## Presets
720
+
721
+ Presets are factory helpers that wire up common store patterns for you.
722
+
723
+ ### Counter
724
+
725
+ ```js
726
+ import { createCounterStore } from "stroid";
727
+
728
+ const counter = createCounterStore("score", { initial: 0, step: 1 });
729
+
730
+ counter.increment();
731
+ counter.decrement();
732
+ counter.reset();
733
+ console.log(counter.get()); // 0
734
+ ```
735
+
736
+ ### List
737
+
738
+ ```js
739
+ import { createListStore } from "stroid";
740
+
741
+ const todos = createListStore("todos");
742
+
743
+ todos.add({ id: 1, text: "Buy milk" });
744
+ todos.remove(1); // by id
745
+ todos.update(1, { text: "Buy oat milk" });
746
+ console.log(todos.get()); // []
747
+ ```
748
+
749
+ ### Entity
750
+
751
+ ```js
752
+ import { createEntityStore } from "stroid";
753
+
754
+ const users = createEntityStore("users", { idField: "id" });
755
+
756
+ users.upsert({ id: "u1", name: "Alex" });
757
+ users.remove("u1");
758
+ ```
759
+
760
+ ---
761
+
762
+ ## DevTools & Metrics
763
+
764
+ Pass `devtools: true` to connect a store to the [Redux DevTools Extension](https://github.com/reduxjs/redux-devtools). Every `setStore` / `mergeStore` call is recorded as a named action for time-travel debugging.
765
+
766
+ ```js
767
+ createStore("user", initialState, {
768
+ devtools: true,
769
+ historyLimit: 100, // Number of shallow diffs to keep (default: 50)
770
+ });
771
+ ```
772
+
773
+ > **Note:** History diffs are **shallow only** — deeply nested changes are captured at the top level.
774
+
775
+ **Async Metrics**
776
+
777
+ Fetch counts and timings are tracked automatically in store meta. Read them with `getAsyncMetrics`:
778
+
779
+ ```js
780
+ import { getAsyncMetrics } from "stroid/async";
781
+
782
+ const metrics = getAsyncMetrics("todos");
783
+ // { fetchCount: 4, lastFetchAt: 1714000000000, avgDuration: 123 }
784
+ ```
785
+
786
+ ---
787
+
788
+ ## SSR / Next.js
789
+
790
+ Global stores are not safe to share across requests on the server. Stroid warns when `createStore` is called in a server environment (blocked in production Node unless opted in).
791
+
792
+ **Option A — Request-scoped stores (recommended)**
793
+
794
+ ```js
795
+ import { createStoreForRequest } from "stroid/core";
796
+
797
+ // app/api/route.ts (Next.js App Router)
798
+ export async function GET(req) {
799
+ const store = createStoreForRequest("req", { user: null });
800
+ setStore("req", { user: await getUser(req) });
801
+ return Response.json(getStore("req"));
802
+ }
803
+ ```
804
+
805
+ **Option B — Server snapshot + client hydration**
806
+
807
+ Serialize stores on the server and hydrate them on the client. Hydration shallow-merges objects and replaces primitives.
808
+
809
+ ```js
810
+ // Server
811
+ import { getStore } from "stroid/core";
812
+ const snapshot = { user: getStore("user"), settings: getStore("settings") };
813
+
814
+ // Client (e.g. in _app.tsx or a layout)
815
+ import { hydrateStores } from "stroid/core";
816
+ hydrateStores(snapshot);
817
+ ```
818
+
819
+ **Option C — Suppress the warning (use with care)**
820
+
821
+ ```js
822
+ createStore("global_config", { flags: {} }, { allowSSRGlobalStore: true });
823
+ ```
824
+
825
+ ---
826
+
827
+ ## Testing
828
+
829
+ ```js
830
+ import { createMockStore, resetAllStoresForTest, withMockedTime } from "stroid/testing";
831
+
832
+ beforeEach(() => {
833
+ resetAllStoresForTest(); // Clears all stores between tests
834
+ });
835
+
836
+ test("increments counter", () => {
837
+ createMockStore("counter", { count: 0 });
838
+ setStore("counter", (d) => { d.count++; });
839
+ expect(getStore("counter").count).toBe(1);
840
+ });
841
+
842
+ test("TTL expiry", () => {
843
+ withMockedTime(() => {
844
+ // Advance virtual timers here to test TTL, retries, etc.
845
+ });
846
+ });
847
+ ```
848
+
849
+ | Utility | Description |
850
+ |---|---|
851
+ | `createMockStore` | Like `createStore` but skips persistence, sync, and DevTools |
852
+ | `resetAllStoresForTest` | Tears down every store — use in `beforeEach` |
853
+ | `withMockedTime` | Runs a callback with mocked timers for testing TTLs and retries |
854
+
855
+ ---
856
+
857
+ ## Validation & Middleware
858
+
859
+ ### Schema Validation
860
+
861
+ Pass any Zod, Yup, Valibot schema, or a plain predicate function as `schema`. Validation runs on every write. Failures call `onError` and **block the write**.
862
+
863
+ ```js
864
+ import { z } from "zod";
865
+
866
+ createStore("user", { name: "Alex", age: 22 }, {
867
+ schema: z.object({
868
+ name: z.string(),
869
+ age: z.number().min(0),
870
+ }),
871
+ middleware: [{
872
+ onError: (err, context) => console.error("Validation failed:", err, context),
873
+ }]
874
+ });
875
+
876
+ // This write will be blocked — age must be a number
877
+ setStore("user", { age: "not-a-number" });
878
+ ```
879
+
880
+ ### Middleware Hooks
881
+
882
+ Middleware is an array of objects with any combination of lifecycle hooks:
883
+
884
+ ```js
885
+ createStore("user", initialState, {
886
+ middleware: [
887
+ {
888
+ onSet: (next, current, name) => {
889
+ console.log(`[${name}] writing`, next);
890
+ },
891
+ onReset: (name) => console.log(`[${name}] reset`),
892
+ onDelete: (name) => console.log(`[${name}] deleted`),
893
+ onCreate: (name, state) => console.log(`[${name}] created`, state),
894
+ onError: (err, context) => console.error(err),
895
+ redactor: (state) => ({ ...state, password: "***" }), // redact from DevTools
896
+ }
897
+ ]
898
+ });
899
+ ```
900
+
901
+ | Hook | Signature | Description |
902
+ |---|---|---|
903
+ | `onSet` | `(next, current, name) => void` | Called before every write |
904
+ | `onReset` | `(name) => void` | Called on `resetStore` |
905
+ | `onDelete` | `(name) => void` | Called on `deleteStore` |
906
+ | `onCreate` | `(name, state) => void` | Called once when store is created |
907
+ | `onError` | `(err, context) => void` | Called when validation fails |
908
+ | `redactor` | `(state) => state` | Transforms state before sending to DevTools |
909
+
910
+ ---
911
+
912
+ ## TypeScript
913
+
914
+ Stroid ships full TypeScript types. For the best type safety, use `StoreDefinition`:
915
+
916
+ ```ts
917
+ import { createStore, getStore, setStore } from "stroid/core";
918
+ import type { StoreDefinition, Path, PathValue } from "stroid/core";
919
+
920
+ // Define a typed store
921
+ type UserState = { name: string; profile: { theme: "light" | "dark" } };
922
+ type UserStore = StoreDefinition<"user", UserState>;
923
+
924
+ createStore("user", { name: "Alex", profile: { theme: "dark" } });
925
+
926
+ // Typed path access
927
+ const theme = getStore("user", "profile.theme"); // type: "light" | "dark"
928
+ setStore("user", "profile.theme", "light"); // type-checked value
929
+
930
+ // Path utilities
931
+ type ThemePath = Path<UserState>; // "name" | "profile" | "profile.theme"
932
+ type ThemeValue = PathValue<UserState, "profile.theme">; // "light" | "dark"
933
+ ```
934
+
935
+ > String-name overloads (passing a plain string like `"user"`) are **runtime-only** and have looser types. Use `StoreDefinition` for full inference.
936
+
937
+ ---
938
+
939
+ ## Limitations & Gotchas
940
+
941
+ These are important to know before using Stroid in production:
942
+
943
+ | Limitation | Detail |
944
+ |---|---|
945
+ | ESM-only | No CommonJS support. Requires Node 18+ or a modern bundler. |
946
+ | Subpaths share a chunk | `stroid/core`, `stroid/react`, etc. currently share an internal chunk. True isolation is planned for v1.1. |
947
+ | No path auto-create | `setStore` path writes and `chain()` require the path to already exist. Use `mergeStore` to add new keys first. |
948
+ | `useStore` without selector | Subscribes to the full store — re-renders on **every** change. Always prefer a field or selector. |
949
+ | Date / Map / Set serialization | These types are serialized as JSON-friendly forms: `Date` → ISO string, `Map` → object, `Set` → array. Re-wrap them after reading from a persisted store. |
950
+ | Persistence is synchronous | Large state objects can block the main thread. Keep persisted state lean. |
951
+ | History diffs are shallow | `historyLimit` captures shallow diffs only. Deep nested changes may appear collapsed in DevTools. |
952
+ | BroadcastChannel clock skew | Default LWW sync uses `Date.now()`. Clock differences between tabs can cause unexpected conflict resolution. Use `conflictResolver` for critical stores. |
953
+ | `fetchStore` cacheKey reuse | Reusing the same `cacheKey` with different URLs returns the cached result from the first call. |
954
+ | Pre-v1 size promise | We’re actively keeping the ESM bundle under **8 KB gzip** while focusing on bug fixes and edge cases. |
2
955
 
3
- Compact state management for JavaScript/React with batteries included: mutable-friendly updates, selectors, persistence, async caching, sync, and drop-in presets. ESM-only; import subpaths like `stroid/core`, `stroid/react`, `stroid/async`, `stroid/testing` as needed. For non-React/Node usage, prefer `stroid/core`.
4
-
5
- ## Quick start
6
-
7
- ```js
8
- import { createStore, setStore, useStore } from "stroid";
9
-
10
- createStore("user", { name: "Alex", theme: "dark" }, { devtools: true, persist: true });
11
- setStore("user", (draft) => { draft.name = "Jordan"; });
12
-
13
- function Profile() {
14
- const name = useStore("user", "name");
15
- return <div>{name}</div>;
16
- }
17
- ```
18
-
19
- Install: `npm install stroid`
20
-
21
- ## Highlights
22
- - Mutator-friendly updates and batched notifications.
23
- - Selectors (`createSelector`, `useSelector`) and presets (counter/list/entity).
24
- - Persistence adapters with checksum + migrations; sync via BroadcastChannel.
25
- - Async helper with SWR, TTL, dedupe, retries, abort, focus/online revalidate; metrics.
26
- - React hooks (`useStore`, `useStoreField`, `useSelector`, `useAsyncStore`, `useFormStore`, `useStoreStatic`); `useStore` warns in dev when subscribing to the whole store.
27
- - DevTools bridge (Redux DevTools), middleware hooks, schema validation.
28
- - Subpath imports share a common internal chunk today; true per-feature isolation is planned for v1.1.
29
-
30
- ## Testing
31
- Import testing helpers without bundling them into apps:
32
- ```js
33
- import { createMockStore, resetAllStoresForTest } from "stroid/testing";
34
- ```
35
-
36
- ## More docs
37
- - SSR/RSC patterns, sync conflict resolution, and the full demo: `docs/DETAILS.md`.
38
- - LWW sync uses `Date.now()`; significant clock skew between tabs/devices can reorder updates.
39
-
40
- ## Roadmap (packaging)
41
- - Phase 1 (done): sideEffects flag, testing subpath, dev-only verbose warnings, lazy CRC init.
42
- - Phase 2 (done): hooks split, subpath exports, focus/online revalidate helper, useStore selector overload.
43
- - Phase 3 (planned): modularize persistence/history/devtools/sync into opt-in chunks.
44
-
45
- ## Versioning / Semver
46
- Follows semver. Breaking changes bump MAJOR; features MINOR; fixes PATCH. See CHANGELOG.md.
956
+ ### Roadmap to v1
957
+ - Remain **dependency-free** while delivering full current functionality.
958
+ - Ship **v1 bundle < 8 KB gzip** without dropping features.
959
+ - Until v1: prioritize stability, bug fixes, failure handling, edge/rare cases over new features.
960
+
961
+ ---
962
+
963
+ ## Common Problems & Solutions
964
+
965
+ ### ❌ `Invalid hook call` in Node.js
966
+
967
+ Hooks (`useStore`, `useSelector`, etc.) only work inside React function components. For Node.js scripts, use the core API from `stroid/core`.
968
+
969
+ ---
970
+
971
+ ### ⚠️ SSR warning: `createStore(...) called in a server environment`
972
+
973
+ Use `createStoreForRequest` for per-request state, or pass `{ allowSSRGlobalStore: true }` if you intentionally want a global store on the server.
974
+
975
+ ---
976
+
977
+ ### 🐛 Stale nested state after update
978
+
979
+ Always use a draft updater (or spread) for nested objects:
980
+
981
+ ```js
982
+ // ✅ Correct
983
+ setStore("user", (draft) => {
984
+ draft.profile.city = "Pokhara";
985
+ });
986
+
987
+ // ✅ Also correct
988
+ setStore("user", (prev) => ({
989
+ ...prev,
990
+ profile: { ...prev.profile, city: "Pokhara" }
991
+ }));
992
+
993
+ // Wrong — overwrites the whole store
994
+ setStore("user", { profile: { city: "Pokhara" } });
995
+ ```
996
+
997
+ ---
998
+
999
+ ### 🗃️ Date / Map / Set lost after persistence
1000
+
1001
+ These types are not JSON-serializable and are stored in transformed forms. Re-wrap them after reading from a persisted store:
1002
+
1003
+ ```js
1004
+ const state = getStore("events");
1005
+
1006
+ // Date was stored as ISO string — re-wrap
1007
+ const date = new Date(state.createdAt);
1008
+
1009
+ // Set was stored as array — re-wrap
1010
+ const tags = new Set(state.tags);
1011
+
1012
+ // Map was stored as object — re-wrap
1013
+ const lookup = new Map(Object.entries(state.lookup));
1014
+ ```
1015
+
1016
+ ---
1017
+
1018
+ Avoid subscribing to the whole store in components that only need one field:
1019
+
1020
+ ```js
1021
+ // ❌ Re-renders on any field change
1022
+ const user = useStore("user");
1023
+
1024
+ // ✅ Only re-renders when `name` changes
1025
+ const name = useStore("user", "name");
1026
+ // or
1027
+ const name = useSelector("user", (s) => s.name);
1028
+ ```
1029
+
1030
+ ---
1031
+
1032
+ ## Roadmap
1033
+
1034
+ | Phase | Status | Description |
1035
+ |---|---|---|
1036
+ | Phase 1 | ✅ Done | `sideEffects` flag, testing subpath, dev-only warnings, lazy CRC init |
1037
+ | Phase 2 | ✅ Done | Hooks split, subpath exports, focus/online revalidate, `useStore` selector overload |
1038
+ | Phase 3 | 🔜 Planned | Modularize persistence / history / devtools / sync into opt-in chunks (v1.1) |
1039
+
1040
+ ---
1041
+
1042
+ ## Versioning
1043
+
1044
+ Stroid follows [Semantic Versioning](https://semver.org/):
1045
+
1046
+ - **MAJOR** — breaking changes
1047
+ - **MINOR** — new backwards-compatible features
1048
+ - **PATCH** — bug fixes
1049
+
1050
+ See [CHANGELOG.md](./CHANGELOG.md) for the full history.
1051
+
1052
+ ---
1053
+
1054
+ ## License
1055
+
1056
+ MIT © Stroid Contributors