redux-sparse-kit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aleksandr Khizhnyi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,333 @@
1
- # Temporary Holding Version
1
+ # redux-sparse-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/redux-sparse-kit.svg?style=flat-square)](https://www.npmjs.com/package/redux-sparse-kit)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.md)
5
+
6
+ Dynamic reducers and epics for large Redux stores: only mounted features run on dispatch.
7
+
8
+ Every action a Redux store dispatches goes through every reducer, and with
9
+ redux-observable every epic sees it too. In a large application most of that
10
+ work is spent on features that are not on screen. `redux-sparse-kit` keeps the
11
+ store _sparse_: a feature's reducer and epics are injected when the feature
12
+ mounts and evicted some time after it is gone, so the cost of a dispatch
13
+ follows what the user is looking at, not the size of the codebase.
14
+
15
+ - **Segments** - a reducer, its epics and the segments it depends on, injected
16
+ and evicted as one unit. Each part is optional.
17
+ - **Reference counting** - a segment stays while anything holds it; shared
18
+ segments and dependencies are never evicted from under a feature.
19
+ - **Idle eviction** - a released segment waits before it is evicted, so moving
20
+ back and forth between pages does not tear features down and set them up
21
+ again. Idle segments can keep running, pause their epics or freeze.
22
+ - **Ordered, atomic changes** - dependencies are injected before their
23
+ dependents and evicted after them; every batch of changes reaches the store
24
+ as a single action.
25
+ - **Typed scopes** - an epic that reads a segment its segment does not depend
26
+ on is a type error.
27
+ - **Scope checks** - in development, reads of a segment's state outside the
28
+ part of the tree that holds it are reported.
29
+ - **Works with what you have** - a plain store enhancer, usable with
30
+ `createStore` or Redux Toolkit's `configureStore`, with any root reducer.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ npm install redux-sparse-kit
36
+ # or
37
+ yarn add redux-sparse-kit
38
+ ```
39
+
40
+ `redux` 5 is required. React bindings need `react` 18 or later and
41
+ `react-redux`; epics need `rxjs` 7 and an epic middleware such as
42
+ `redux-observable`. Types need TypeScript 5.4 or later.
43
+
44
+ ## Quick start
45
+
46
+ ### 1. Create the store
47
+
48
+ ```ts
49
+ import {
50
+ applyMiddleware,
51
+ combineReducers,
52
+ compose,
53
+ legacy_createStore as createStore,
54
+ } from 'redux';
55
+ import type { StoreEnhancer } from 'redux';
56
+ import { createEpicMiddleware } from 'redux-observable';
57
+ import type { SparseStoreExt } from 'redux-sparse-kit';
58
+ import { sparseEnhancer } from 'redux-sparse-kit';
59
+ import { createEpicRunner } from 'redux-sparse-kit/observable';
60
+
61
+ const epicMiddleware = createEpicMiddleware();
62
+
63
+ export const store = createStore(
64
+ // The static state: always present, owned by the root reducer as usual.
65
+ combineReducers({ session: sessionReducer, router: routerReducer }),
66
+ // `compose` does not keep the types enhancers add to the store.
67
+ compose(
68
+ applyMiddleware(epicMiddleware),
69
+ sparseEnhancer({ epicRunner: createEpicRunner(epicMiddleware) }),
70
+ ) as StoreEnhancer<SparseStoreExt>,
71
+ );
72
+
73
+ epicMiddleware.run(rootEpic);
74
+ ```
75
+
76
+ With Redux Toolkit, which keeps the type of `store.sparse` on its own:
77
+
78
+ ```ts
79
+ const store = configureStore({
80
+ reducer: { session: sessionReducer, router: routerReducer },
81
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(epicMiddleware),
82
+ enhancers: (getDefaultEnhancers) =>
83
+ getDefaultEnhancers().concat(sparseEnhancer({ epicRunner: createEpicRunner(epicMiddleware) })),
84
+ });
85
+ ```
86
+
87
+ ### 2. Define segments
88
+
89
+ ```ts
90
+ import { defineSegment } from 'redux-sparse-kit';
91
+
92
+ export const accountSegment = defineSegment({
93
+ key: 'account',
94
+ reducer: accountReducer,
95
+ });
96
+
97
+ export const ordersSegment = defineSegment({
98
+ key: 'orders',
99
+ reducer: ordersReducer,
100
+ deps: [accountSegment],
101
+ epics: [loadOrdersEpic],
102
+ });
103
+ ```
104
+
105
+ A segment owns the top-level state key it names. Its dependencies are present
106
+ for as long as it is.
107
+
108
+ ### 3. Hold them where they are used
109
+
110
+ ```tsx
111
+ import { SparseScope } from 'redux-sparse-kit/react';
112
+
113
+ export function OrdersPage() {
114
+ return (
115
+ <SparseScope segments={[ordersSegment]} fallback={<Spinner />}>
116
+ <OrdersTable />
117
+ </SparseScope>
118
+ );
119
+ }
120
+ ```
121
+
122
+ `OrdersTable` renders once `orders` and `account` are in the store, and reads
123
+ them with `useSelector` as usual. When `OrdersPage` unmounts, the segments go
124
+ idle and are evicted a minute later unless something holds them again.
125
+
126
+ ## Segments
127
+
128
+ ```ts
129
+ defineSegment({
130
+ key: 'orders', // the top-level state key the reducer owns
131
+ reducer: ordersReducer,
132
+ deps: [accountSegment], // present for as long as this segment is
133
+ epics: [loadOrdersEpic], // started after injection, stopped before eviction
134
+ idleTimeout: 5 * 60_000, // overrides the store's idle timeout
135
+ whileIdle: 'pause-epics', // overrides the store's idle behaviour
136
+ });
137
+ ```
138
+
139
+ Every part but the key is optional. A segment without a reducer owns no slice
140
+ of the state: it can run epics only, or only bundle the segments it depends on,
141
+ so that one scope holds a whole page.
142
+
143
+ ```ts
144
+ const pricePolling = defineSegment({ key: 'pricePolling', epics: [pollPricesEpic] });
145
+
146
+ const ordersPage = defineSegment({
147
+ key: 'ordersPage',
148
+ deps: [ordersSegment, pricesSegment, pricePolling],
149
+ });
150
+ ```
151
+
152
+ A dependency has to be defined before the segments that list it, so the
153
+ dependency graph cannot have cycles. Two different segments cannot use the same
154
+ key in one store at the same time, and a segment with a reducer cannot use a key
155
+ of the static state: acquiring it throws.
156
+
157
+ ### Typing epics
158
+
159
+ The state a segment's epics may read is the static state, the segment's own
160
+ slice and the slices of its direct dependencies. An epic that declares a state
161
+ outside of that is rejected:
162
+
163
+ ```ts
164
+ import type { Epic } from 'redux-observable';
165
+ import type { StateOfSegments } from 'redux-sparse-kit';
166
+
167
+ type OrdersState = StateOfSegments<typeof accountSegment> & { orders: Orders };
168
+
169
+ const loadOrdersEpic: Epic<Action, Action, OrdersState> = (action$, state$) => /* ... */;
170
+
171
+ defineSegment({ key: 'orders', reducer: ordersReducer, deps: [accountSegment], epics: [loadOrdersEpic] }); // ok
172
+ defineSegment({ key: 'orders', reducer: ordersReducer, epics: [loadOrdersEpic] }); // error: reads `account`
173
+ ```
174
+
175
+ Epics typed with a `void`, `unknown` or `any` state do not read it and are
176
+ always accepted. Register the static state once to let epics read it too:
177
+
178
+ ```ts
179
+ declare module 'redux-sparse-kit' {
180
+ interface StaticState {
181
+ session: SessionState;
182
+ router: RouterState;
183
+ }
184
+ }
185
+ ```
186
+
187
+ `ScopeStateOf<typeof segment>` is the full state a segment may read.
188
+
189
+ ## Lifecycle
190
+
191
+ ```
192
+ acquire release (last holder)
193
+ absent ─────────────▶ active ◀──────────────────────────▶ idle
194
+ ▲ ▲ acquire (revive) │
195
+ │ └──────────────────────────────────┤
196
+ │ │ idle timeout,
197
+ └───────────────────────── evict ───────────────────────┘ maxIdle
198
+ ```
199
+
200
+ - **Injecting** dispatches `@@sparse/INJECT` with the keys of the slices the
201
+ call brings in, dependencies first, then starts the epics of the injected
202
+ segments in that order.
203
+ - **Releasing** the last hold makes a segment idle. Its dependencies stay held
204
+ by it.
205
+ - **Reviving** an idle segment costs nothing: its slice and epics are still
206
+ there.
207
+ - **Evicting** stops the epics of the evicted segments, dependents first, then
208
+ dispatches `@@sparse/EVICT`, which removes their slices.
209
+ - Segments without a reducer add and remove no slice: when a change involves
210
+ only such segments, no action is dispatched. Dependencies that
211
+ only the evicted segments held are evicted in the same action. Segments that
212
+ expire together are swept together, when the browser is idle.
213
+
214
+ Reducers outside the segments can handle `SparseActionTypes.EVICT` to drop
215
+ data that refers to an evicted segment. A preloaded slice for a segment that is
216
+ not present yet is kept, and the segment starts from it when injected.
217
+
218
+ ## Store options
219
+
220
+ ```ts
221
+ sparseEnhancer({
222
+ epicRunner, // required once a held segment has epics
223
+ idleTimeout: 60_000, // ms a released segment waits; 0 = evict now, Infinity = never
224
+ whileIdle: 'keep-running', // what an idle segment does
225
+ maxIdle: Infinity, // idle segments kept at most; the least recently used go first
226
+ pauseWhenHidden: true, // stop the idle clock while the page is hidden
227
+ });
228
+ ```
229
+
230
+ | `whileIdle` | Reducer | Epics | Use for |
231
+ | -------------- | ------- | ------- | ----------------------------------------------------- |
232
+ | `keep-running` | runs | run | data kept current by a stream, e.g. live quotes |
233
+ | `pause-epics` | runs | stopped | epics that hold subscriptions or poll; restart on use |
234
+ | `freeze` | skipped | stopped | state that is cheap to keep but costly to update |
235
+
236
+ A frozen segment resumes from the state it had when it went idle, so actions
237
+ dispatched in the meantime are lost to it.
238
+
239
+ ## The store API
240
+
241
+ `sparseEnhancer` adds `store.sparse`:
242
+
243
+ ```ts
244
+ const release = store.sparse.acquire(ordersSegment, pricesSegment);
245
+ release(); // idempotent
246
+
247
+ store.sparse.has(ordersSegment); // or has('orders')
248
+ store.sparse.stats(); // { active: ['orders', 'account'], idle: ['prices'] }
249
+
250
+ store.sparse.batch(() => {
251
+ // injections and evictions caused in here reach the store as one action each
252
+ });
253
+
254
+ const unsubscribe = store.sparse.subscribe((event) => {
255
+ // { type: 'inject' | 'evict', keys } | { type: 'idle' | 'revive', key }
256
+ });
257
+ ```
258
+
259
+ `acquire` is all or nothing: if any of the segments, or their dependencies,
260
+ conflicts with a different segment already using its key, it throws and holds
261
+ nothing.
262
+
263
+ ## React
264
+
265
+ ```ts
266
+ import { SparseScope, useSparseSegments, createSparseReact } from 'redux-sparse-kit/react';
267
+ ```
268
+
269
+ - `<SparseScope segments fallback>` holds the segments while mounted and
270
+ renders its children once they are present. Segments still present from an
271
+ earlier visit render straight away. Holding happens in a layout effect, so
272
+ the fallback never reaches the screen when the segments can be injected
273
+ right away.
274
+ - `useSparseSegments(segments)` holds the segments while the calling component
275
+ is mounted and returns whether they are present yet.
276
+ - A new array of the same segments on every render does not acquire them
277
+ again.
278
+
279
+ The bindings find the store in react-redux's context, so they work with any
280
+ react-redux 8.1 or later. Pass `createSparseReact({ context })` when the
281
+ `<Provider>` was given a custom context.
282
+
283
+ ### Scope checks
284
+
285
+ In development, `SparseScope` reports reads of a segment's slice made outside
286
+ every scope that holds it: such a read works only while some other part of the
287
+ tree happens to hold the segment, and breaks once it is evicted.
288
+
289
+ ```tsx
290
+ <Provider store={store}>
291
+ {/* reports reads below that no scope holds */}
292
+ <SparseScope segments={[]}>
293
+ <App />
294
+ </SparseScope>
295
+ </Provider>
296
+ ```
297
+
298
+ A scope makes its segments and their dependencies available to everything
299
+ below it, including nested scopes. Reads of static state are never reported.
300
+
301
+ By default each key is logged once per selector with `console.error`. Pass a
302
+ handler to collect them instead, for example to fail end-to-end tests:
303
+
304
+ ```ts
305
+ const { SparseScope } = createSparseReact({
306
+ onViolation: ({ key, selector, scope }) => violations.push({ key, selector, scope }),
307
+ checks: true, // default: on outside production builds
308
+ });
309
+ ```
310
+
311
+ How reads are observed depends on react-redux. When its context value carries
312
+ `stateAccessTracking: true`, react-redux reports the first read of each
313
+ top-level key by each selector through an `onStateAccess(key, selector)`
314
+ callback on the context value; scopes replace that callback for their subtree
315
+ and report the selector that made the read. Otherwise scopes pass down a
316
+ wrapper of the store whose state is a proxy that sees each top-level read; the
317
+ selector is then not known. Checks are compiled out of production builds that
318
+ replace `process.env.NODE_ENV`.
319
+
320
+ ## Caveats
321
+
322
+ - **Server rendering** - segments are injected on the client. On the server
323
+ `SparseScope` renders its fallback.
324
+ - **`connect`** - with `onStateAccess`, reads made by `connect` are not
325
+ reported: react-redux reports only the reads of `useSelector`. With the store
326
+ wrapper they are.
327
+ - **Reading the whole state** - with the store wrapper, a selector that spreads
328
+ or iterates the whole state reads every key and is reported for each
329
+ segment slice.
330
+
331
+ ## License
332
+
333
+ [MIT](LICENSE.md)
@@ -0,0 +1,2 @@
1
+ import { _ as StaticState, a as SparseEvent, b as isSegmentKey, c as SparseStoreExt, d as ScopeStateOf, f as Segment, g as StateOfSegments, h as SegmentOptions, i as SparseController, l as sparseEnhancer, m as SegmentEpicStateError, n as SparseAction, o as SparseOptions, p as SegmentEpic, r as SparseActionTypes, s as SparseStats, t as EpicRunner, u as AnySegment, v as WhileIdle, y as defineSegment } from "./sparseEnhancer-CIcdhh-P.mjs";
2
+ export { type AnySegment, type EpicRunner, type ScopeStateOf, type Segment, type SegmentEpic, type SegmentEpicStateError, type SegmentOptions, type SparseAction, SparseActionTypes, type SparseController, type SparseEvent, type SparseOptions, type SparseStats, type SparseStoreExt, type StateOfSegments, type StaticState, type WhileIdle, defineSegment, isSegmentKey, sparseEnhancer };
@@ -0,0 +1,2 @@
1
+ import { _ as StaticState, a as SparseEvent, b as isSegmentKey, c as SparseStoreExt, d as ScopeStateOf, f as Segment, g as StateOfSegments, h as SegmentOptions, i as SparseController, l as sparseEnhancer, m as SegmentEpicStateError, n as SparseAction, o as SparseOptions, p as SegmentEpic, r as SparseActionTypes, s as SparseStats, t as EpicRunner, u as AnySegment, v as WhileIdle, y as defineSegment } from "./sparseEnhancer-CIcdhh-P.js";
2
+ export { type AnySegment, type EpicRunner, type ScopeStateOf, type Segment, type SegmentEpic, type SegmentEpicStateError, type SegmentOptions, type SparseAction, SparseActionTypes, type SparseController, type SparseEvent, type SparseOptions, type SparseStats, type SparseStoreExt, type StateOfSegments, type StaticState, type WhileIdle, defineSegment, isSegmentKey, sparseEnhancer };