@inglorious/store 8.0.1 → 9.0.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/README.md CHANGED
@@ -1,881 +1,880 @@
1
- # Inglorious Store
2
-
3
- [![NPM version](https://img.shields.io/npm/v/@inglorious/store.svg)](https://www.npmjs.com/package/@inglorious/store)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
-
6
- A Redux-compatible, ECS-inspired state library that makes state management as elegant as game logic.
7
-
8
- **Drop-in replacement for Redux.** Works with `react-redux` and Redux DevTools. Borrows concepts from Entity-Component-System architectures and Functional Programming to provide an environment where you can write simple, predictable, and testable code.
9
-
10
- ```javascript
11
- // from redux
12
- import { createStore } from "redux"
13
- // to
14
- import { createStore } from "@inglorious/store"
15
- ```
16
-
17
- ---
18
-
19
- ## Why Inglorious Store?
20
-
21
- Redux is powerful but verbose. You need action creators, reducers, middleware for async operations, and a bunch of decisions about where logic should live. Redux Toolkit cuts the boilerplate, but you're still writing a lot of ceremony.
22
-
23
- Inglorious Store eliminates the boilerplate entirely with an **entity-based architecture** inspired by game engines. Some of the patterns that power AAA games now power your state management.
24
-
25
- Game engines solved state complexity years ago — Inglorious Store brings those lessons to web development.
26
-
27
- **Key benefits:**
28
-
29
- - ✅ Drop-in Redux replacement (same API with `react-redux`)
30
- - ✅ Entity-based state (manage multiple instances effortlessly)
31
- - ✅ No action creators, thunks, or slices
32
- - ✅ Predictable, testable, purely functional code
33
- - ✅ Built-in lifecycle events (`add`, `remove`, `morph`)
34
- - ✅ 10x faster immutability than Redux Toolkit (Mutative vs Immer)
35
-
36
- ---
37
-
38
- ## Quick Comparison: Redux vs RTK vs Inglorious Store
39
-
40
- ### Redux
41
-
42
- ```javascript
43
- // Action creators
44
- const addTodo = (text) => ({ type: "ADD_TODO", payload: text })
45
-
46
- // Reducer
47
- const todosReducer = (state = [], action) => {
48
- switch (action.type) {
49
- case "ADD_TODO":
50
- return [...state, { id: Date.now(), text: action.payload }]
51
-
52
- case "OTHER_ACTION":
53
- // Handle other action
54
-
55
- default:
56
- return state
57
- }
58
- }
59
-
60
- // Store setup
61
- const store = configureStore({
62
- reducer: {
63
- work: todosReducer,
64
- personal: todosReducer,
65
- },
66
- })
67
-
68
- store.dispatch({ type: "ADD_TODO", payload: "Buy groceries" })
69
- store.dispatch({ type: "OTHER_ACTION" })
70
- ```
71
-
72
- ### Redux Toolkit
73
-
74
- ```javascript
75
- const otherAction = createAction("app:otherAction")
76
-
77
- const todosSlice = createSlice({
78
- name: "todos",
79
- initialState: [],
80
- reducers: {
81
- addTodo: (state, action) => {
82
- state.push({ id: Date.now(), text: action.payload })
83
- },
84
- },
85
- extraReducers: (builder) => {
86
- builder.addCase(otherAction, (state, action) => {
87
- // Handle external action
88
- })
89
- },
90
- })
91
-
92
- const store = configureStore({
93
- reducer: {
94
- work: todosSlice.reducer,
95
- personal: todosSlice.reducer,
96
- },
97
- })
98
-
99
- store.dispatch(slice.actions.addTodo("Buy groceries"))
100
- store.dispatch(otherAction())
101
- ```
102
-
103
- ### Inglorious Store
104
-
105
- ```javascript
106
- // Define entity types and their behavior
107
- const types = {
108
- todoList: {
109
- addTodo(entity, text) {
110
- entity.todos.push({ id: Date.now(), text })
111
- },
112
-
113
- otherAction(entity) {
114
- // Handle other action
115
- },
116
- },
117
- }
118
-
119
- // Define initial entities
120
- const entities = {
121
- work: { type: "todoList", todos: [] },
122
- personal: { type: "todoList", todos: [] },
123
- }
124
-
125
- // Create store
126
- const store = createStore({ types, entities })
127
-
128
- store.dispatch({ type: "addTodo", payload: "Buy groceries" })
129
- store.dispatch({ type: "otherAction" })
130
-
131
- // or, even better:
132
- store.notify("addTodo", "Buy groceries")
133
- store.notify("otherAction")
134
-
135
- // same result, 10x simpler
136
- ```
137
-
138
- **Key differences:**
139
-
140
- - ❌ No action creators
141
- - ❌ No switch statements or cases
142
- - ❌ No slice definitions with extraReducers
143
- - ✅ Define what each entity type can do
144
- - ✅ Add multiple instances by adding entities, not code
145
-
146
- ---
147
-
148
- ## Core Concepts
149
-
150
- ### 🎮 Entities and Types
151
-
152
- State consists of **entities** (instances) that have a **type** (behavior definition). Think of a type as a class and entities as instances:
153
-
154
- ```javascript
155
- const types = {
156
- todoList: {
157
- addTodo(entity, text) {
158
- entity.todos.push({ id: Date.now(), text })
159
- },
160
- toggle(entity, id) {
161
- const todo = entity.todos.find((t) => t.id === id)
162
- if (todo) todo.completed = !todo.completed
163
- },
164
- },
165
-
166
- settings: {
167
- setTheme(entity, theme) {
168
- entity.theme = theme
169
- },
170
- },
171
- }
172
-
173
- const entities = {
174
- workTodos: { type: "todoList", todos: [], priority: "high" },
175
- personalTodos: { type: "todoList", todos: [], priority: "low" },
176
- settings: { type: "settings", theme: "dark", language: "en" },
177
- }
178
- ```
179
-
180
- **Why this matters:**
181
-
182
- - Same behavior applies to all instances of that type
183
- - No need to write separate code for each instance
184
- - Your mental model matches your code structure
185
-
186
- ### 🔄 Event Handlers (Not Methods)
187
-
188
- Even though it looks like types expose methods, they are actually **event handlers**, very similar to Redux reducers. There are a few differences though:
189
-
190
- 1. Just like RTK reducers, you can mutate the entity directly since event handlers are using an immutability library under the hood. Not Immer, but Mutative — which claims to be 10x faster than Immer.
191
-
192
- ```javascript
193
- const types = {
194
- counter: {
195
- increment(counter) {
196
- counter.value++ // Looks like mutation, immutable in reality
197
- },
198
- },
199
- }
200
- ```
201
-
202
- 2. Event handlers accept as arguments the current entity, the event payload, and an API object that exposes a few convenient methods:
203
-
204
- ```javascript
205
- const types = {
206
- counter: {
207
- increment(counter, value, api) {
208
- api.getEntities() // access the whole state in read-only mode
209
- api.getEntity(id) // access some other entity in read-only mode
210
- api.notify(type, payload) // similar to dispatch. Yes, you can dispatch inside of a reducer!
211
- api.dispatch(action) // optional, if you prefer Redux-style dispatching
212
- },
213
- },
214
- }
215
- ```
216
-
217
- ---
218
-
219
- ## Installation & Setup
220
-
221
- The Inglorious store, just like Redux, can be used standalone. However, it's commonly used together with component libraries such as React.
222
-
223
- ### Basic Setup with `react-redux`
224
-
225
- ```javascript
226
- import { createStore } from "@inglorious/store"
227
- import { Provider, useSelector, useDispatch } from "react-redux"
228
-
229
- // 1. Define entity types
230
- const types = {
231
- counter: {
232
- increment(counter) {
233
- counter.value++
234
- },
235
- decrement(counter) {
236
- counter.value--
237
- },
238
- },
239
- }
240
-
241
- // 2. Define initial entities
242
- const entities = {
243
- counter1: { type: "counter", value: 0 },
244
- }
245
-
246
- // 3. Create the store
247
- const store = createStore({ types, entities })
248
-
249
- // 4. Provide the store with react-redux
250
- function App() {
251
- return (
252
- <Provider store={store}>
253
- <Counter />
254
- </Provider>
255
- )
256
- }
257
-
258
- // 5. Wire components to the store
259
- function Counter() {
260
- const dispatch = useDispatch()
261
- const count = useSelector((state) => state.counter1.value)
262
-
263
- return (
264
- <div>
265
- <p>{count}</p>
266
- <button onClick={() => dispatch({ type: "increment" })}>+</button>
267
- <button onClick={() => dispatch({ type: "decrement" })}>-</button>
268
- </div>
269
- )
270
- }
271
- ```
272
-
273
- ### With `@inglorious/react-store` (Recommended)
274
-
275
- For React applications, `@inglorious/react-store` provides a set of hooks and a Provider that are tightly integrated with the store. It's a lightweight wrapper around `react-redux` that offers a more ergonomic API.
276
-
277
- ```javascript
278
- import { createStore } from "@inglorious/store"
279
- import { createReactStore } from "@inglorious/react-store"
280
-
281
- const store = createStore({ types, entities })
282
-
283
- export const { Provider, useSelector, useNotify } = createReactStore(store)
284
-
285
- function App() {
286
- return (
287
- // No store prop needed!
288
- <Provider>
289
- <Counter />
290
- </Provider>
291
- )
292
- }
293
-
294
- function Counter() {
295
- const notify = useNotify() // less verbose than dispatch
296
- const count = useSelector((state) => state.counter1.value)
297
-
298
- return (
299
- <div>
300
- <p>{count}</p>
301
- <button onClick={() => notify("increment")}>+</button> // simplified
302
- syntax
303
- <button onClick={() => notify("decrement")}>-</button>
304
- </div>
305
- )
306
- }
307
- ```
308
-
309
- The package is fully typed, providing a great developer experience with TypeScript.
310
-
311
- ---
312
-
313
- ## Core Features
314
-
315
- ### 🎮 Entity-Based State
316
-
317
- The real power: add entities dynamically without code changes.
318
-
319
- **Redux/RTK:** To manage three counters, you can reuse a reducer. But what if you want to add a new counter at runtime? Your best option is probably to reshape the whole state.
320
-
321
- ```javascript
322
- // The original list of counters:
323
- const store = configureStore({
324
- reducer: {
325
- counter1: counterReducer,
326
- counter2: counterReducer,
327
- counter3: counterReducer,
328
- },
329
- })
330
-
331
- // becomes:
332
- const store = configureStore({
333
- reducer: {
334
- counters: countersReducer,
335
- },
336
- })
337
-
338
- // with extra actions to manage adding/removing counters:
339
- store.dispatch({ type: "addCounter", payload: "counter4" })
340
- ```
341
-
342
- **Inglorious Store** makes it trivial:
343
-
344
- ```javascript
345
- const types = {
346
- counter: {
347
- increment(entity) {
348
- entity.value++
349
- },
350
- },
351
- }
352
-
353
- const entities = {
354
- counter1: { type: "counter", value: 0 },
355
- counter2: { type: "counter", value: 0 },
356
- counter3: { type: "counter", value: 0 },
357
- }
358
-
359
- store.notify("add", { id: "counter4", type: "counter", value: 0 })
360
- ```
361
-
362
- Inglorious Store has a few built-in events that you can use:
363
-
364
- - `add`: adds a new entity to the state. Triggers a `create` lifecycle event.
365
- - `remove`: removes an entity from the state. Triggers a `destroy` lifecycle event.
366
- - `morph`: changes the behavior of a type (advanced, used by middlewares/rendering systems)
367
-
368
- The lifecycle events can be used to define event handlers similar to constructor and destructor methods in OOP:
369
-
370
- > Note: these special lifecycle events are not broadcast: they are visible only to the added/removed entity!
371
-
372
- ```javascript
373
- const types = {
374
- counter: {
375
- create(entity) {
376
- entity.createdAt = Date.now()
377
- },
378
-
379
- destroy(entity) {
380
- entity.destroyedAt = Date.now()
381
- },
382
- },
383
- }
384
- ```
385
-
386
- ### 🔊 Event Broadcasting
387
-
388
- Events are broadcast to all entities via pub/sub. Every entity handler receives every event of that type, just like it does in Redux.
389
-
390
- ```javascript
391
- const types = {
392
- todoList: {
393
- taskCompleted(entity, taskId) {
394
- const task = entity.tasks.find((t) => t.id === taskId)
395
- if (task) task.completed = true
396
- },
397
- },
398
- stats: {
399
- taskCompleted(entity, taskId) {
400
- entity.completedCount++
401
- },
402
- },
403
- notifications: {
404
- taskCompleted(entity, taskId) {
405
- entity.messages.push("Nice! Task completed.")
406
- },
407
- },
408
- }
409
-
410
- // One notify call, all three entity types respond
411
- store.notify("taskCompleted", "task123")
412
- ```
413
-
414
- In RTK, such action would have be to be defined outside of the slice with `createAction` and then processed with the builder callback notation inside of the `extraReducers` section.
415
-
416
- - What if you want to notify the event only to entities of one specific type? Define an event handler for that event only on that type.
417
- - What if you want to notify the event only on one entity of that type? Add an if that checks if the entity should be bothered or not by it.
418
-
419
- ```javascript
420
- const types = {
421
- todoList: {
422
- toggle(entity, id) {
423
- // This runs for EVERY todoList entity, but only acts if it's the right one
424
- if (entity.id !== id) return
425
-
426
- const todo = entity.todos.find((t) => t.id === id)
427
- if (todo) todo.completed = !todo.completed
428
- },
429
- },
430
- }
431
-
432
- // Broadcast to all todo lists
433
- store.notify("toggle", "todo1")
434
- // Each list's toggle handler runs; only the one with todo1 actually updates
435
- ```
436
-
437
- ### ⚡ Async Operations
438
-
439
- In **Redux/RTK**, logic should be written inside pure functions as much as possible — specifically in reducers, not action creators. But what if I need to access some other part of the state that is not visible to the reducer? What if I need to combine async behavior with sync behavior? This is where the choice of "where does my logic live?" matters.
440
-
441
- In **Inglorious Store:** your event handlers can be async, and you get deterministic behavior automatically. Inside an async handler, you can access other parts of state (read-only), and you can trigger other events via `api.notify()`. Even if we give up on some purity, everything still maintains predictability because of the underlying **event queue**:
442
-
443
- ```javascript
444
- const types = {
445
- todoList: {
446
- async loadTodos(entity, payload, api) {
447
- try {
448
- entity.loading = true
449
- const { name } = api.getEntity("user")
450
- const response = await fetch(`/api/todos/${name}`)
451
- const data = await response.json()
452
- api.notify("todosLoaded", todos)
453
- } catch (error) {
454
- api.notify("loadFailed", error.message)
455
- }
456
- },
457
-
458
- todosLoaded(entity, todos) {
459
- entity.todos = todos
460
- entity.loading = false
461
- },
462
-
463
- loadFailed(entity, error) {
464
- entity.error = error
465
- entity.loading = false
466
- },
467
- },
468
- }
469
- ```
470
-
471
- Notice: you don't need pending/fulfilled/rejected actions. You stay in control of the flow — no hidden action chains. The `api` object passed to handlers provides:
472
-
473
- - **`api.getEntities()`** - read entire state
474
- - **`api.getEntity(id)`** - read one entity
475
- - **`api.notify(type, payload)`** - trigger other events (queued, not immediate)
476
- - **`api.dispatch(action)`** - optional, if you prefer Redux-style dispatching
477
- - **`api.getTypes()`** - access type definitions (mainly for middleware/plugins)
478
- - **`api.getType(typeName)`** - access type definition (mainly for overrides)
479
-
480
- All events triggered via `api.notify()` enter the queue and process together, maintaining predictability and testability.
481
-
482
- ### 🧪 Testing
483
-
484
- Event handlers are pure functions (or can be treated as such), making them easy to test in isolation, much like Redux reducers. The `@inglorious/store/test` module provides utility functions to make this even simpler.
485
-
486
- #### `trigger(entity, handler, payload, api?)`
487
-
488
- The `trigger` function executes an event handler on a single entity and returns the new state and any events that were dispatched.
489
-
490
- ```javascript
491
- import { trigger } from "@inglorious/store/test"
492
-
493
- // Define your entity handler
494
- function increment(entity, payload, api) {
495
- entity.value += payload.amount
496
- if (entity.value > 100) {
497
- api.notify("overflow", { id: entity.id })
498
- }
499
- }
500
-
501
- // Test it
502
- const { entity, events } = trigger(
503
- { type: "counter", id: "counter1", value: 99 },
504
- increment,
505
- { amount: 5 },
506
- )
507
-
508
- expect(entity.value).toBe(104)
509
- expect(events).toEqual([{ type: "overflow", payload: { id: "counter1" } }])
510
- ```
511
-
512
- #### `createMockApi(entities)`
513
-
514
- If your handler needs to interact with other entities via the `api`, you can create a mock API. This is useful for testing handlers that read from other parts of the state.
515
-
516
- ```javascript
517
- import { createMockApi, trigger } from "@inglorious/store/test"
518
-
519
- // Create a mock API with some initial entities
520
- const api = createMockApi({
521
- counter1: { type: "counter", value: 10 },
522
- counter2: { type: "counter", value: 20 },
523
- })
524
-
525
- // A handler that copies a value from another entity
526
- function copyValue(entity, payload, api) {
527
- const source = api.getEntity(payload.sourceId)
528
- entity.value = source.value
529
- }
530
-
531
- // Trigger the handler with the custom mock API
532
- const { entity } = trigger(
533
- { type: "counter", id: "counter2", value: 20 },
534
- copyValue,
535
- { sourceId: "counter1" },
536
- api,
537
- )
538
-
539
- expect(entity.value).toBe(10)
540
- ```
541
-
542
- The mock API provides:
543
-
544
- - `getEntities()`: Returns all entities (frozen).
545
- - `getEntity(id)`: Returns a specific entity by ID (frozen).
546
- - `dispatch(event)`: Records an event for later assertions.
547
- - `notify(type, payload)`: A convenience wrapper around `dispatch`.
548
- - `getEvents()`: Returns all events that were dispatched.
549
-
550
- ### 🌍 Systems for Global Logic
551
-
552
- When you need to coordinate updates across multiple entities (not just respond to individual events), use systems. Systems run after all entity handlers for the same event, ensuring global consistency, and have write access to the entire state. This concept is the 'S' in the ECS Architecture (Entity-Component-System)!
553
-
554
- ```javascript
555
- const systems = [
556
- {
557
- taskCompleted(state, taskId) {
558
- // Read from multiple todo lists
559
- const allTodos = Object.values(state)
560
- .filter((e) => e.type === "todoList")
561
- .flatMap((e) => e.todos)
562
-
563
- // Update global stats
564
- state.stats.total = allTodos.length
565
- state.stats.completed = allTodos.filter((t) => t.completed).length
566
- },
567
- },
568
- ]
569
-
570
- const store = createStore({ types, entities, systems })
571
- ```
572
-
573
- Systems receive the entire state and can modify any entity. They're useful for cross-cutting concerns, maintaining derived state, or coordinating complex state updates that can't be expressed as individual entity handlers.
574
-
575
- ### 🔗 Behavior Composition
576
-
577
- A type can be a single behavior object, or an array of behaviors.
578
-
579
- ```javascript
580
- // single-behavior type
581
- const counter = {
582
- increment(entity) {
583
- entity.value++
584
- },
585
-
586
- decrement(entity) {
587
- entity.value--
588
- },
589
- }
590
-
591
- // multiple behavior type
592
- const resettableCounter = [
593
- counter,
594
- {
595
- reset(entity) {
596
- entity.value = 0
597
- },
598
- },
599
- ]
600
- ```
601
-
602
- A behavior is defined as either an object with event handlers, or a function that takes a type and returns an enhanced behavior (decorator pattern):
603
-
604
- ```javascript
605
- // Base behavior
606
- const resettable = {
607
- submit(entity, value) {
608
- entity.value = ""
609
- },
610
- }
611
-
612
- // Function that wraps and enhances a behavior
613
- const validated = (type) => ({
614
- submit(entity, value, api) {
615
- if (!value.trim()) return
616
- type.submit?.(entity, value, api) // remember to always pass all args!
617
- },
618
- })
619
-
620
- // Another wrapper
621
- const withLoading = (type) => ({
622
- submit(entity, value, api) {
623
- entity.loading = true
624
- type.submit?.(entity, value, api)
625
- entity.loading = false
626
- },
627
- })
628
-
629
- // Compose them together to form a type
630
- const form = [resettable, validated, withLoading]
631
- ```
632
-
633
- When multiple behaviors define the same event, they all run in order. This allows you to build middleware-like patterns: validation, logging, error handling, loading states, etc.
634
-
635
- ### ⏱️ Batched Mode
636
-
637
- The Inglorious Store features an **event queue**. In the default `auto` update mode, each notified event will trigger and update of the state (same as Redux). But in `manual` update mode, you can process multiple events together before re-rendering:
638
-
639
- ```javascript
640
- const store = createStore({ types, entities, updateMode: "manual" })
641
-
642
- // add events to the event queue
643
- store.notify("playerMoved", { x: 100, y: 50 })
644
- store.notify("enemyAttacked", { damage: 10 })
645
- store.notify("particleCreated", { type: "explosion" })
646
-
647
- // process them all in batch
648
- store.update()
649
- ```
650
-
651
- Instead of re-rendering after each event, you can batch them and re-render once. This is what powers high-performance game engines and smooth animations.
652
-
653
- ---
654
-
655
- ## Comparison with Other State Libraries
656
-
657
- | Feature | Redux | RTK | Zustand | Jotai | Pinia | MobX | Inglorious Store |
658
- | ------------------------- | ------------ | ------------ | ---------- | ---------- | ---------- | ---------- | ---------------- |
659
- | **Boilerplate** | 🔴 High | 🟡 Medium | 🟢 Low | 🟢 Low | 🟡 Medium | 🟢 Low | 🟢 Low |
660
- | **Multiple instances** | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🟡 Medium | 🟡 Medium | 🟢 Built-in |
661
- | **Lifecycle events** | 🔴 No | 🔴 No | 🔴 No | 🔴 No | 🔴 No | 🔴 No | 🟢 Yes |
662
- | **Async logic placement** | 🟡 Thunks | 🟡 Complex | 🟢 Free | 🟢 Free | 🟢 Free | 🟢 Free | 🟢 In handlers |
663
- | **Redux DevTools** | 🟢 Yes | 🟢 Yes | 🟡 Partial | 🟡 Partial | 🟡 Partial | 🟢 Yes | 🟢 Yes |
664
- | **Time-travel debugging** | 🟢 Yes | 🟢 Yes | 🔴 No | 🔴 No | 🔴 No | 🟡 Limited | 🟢 Yes |
665
- | **Testability** | 🟢 Excellent | 🟢 Excellent | 🟡 Good | 🟡 Good | 🟡 Good | 🟡 Medium | 🟢 Excellent |
666
- | **Immutability** | 🔴 Manual | 🟢 Immer | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🟢 Mutative |
667
-
668
- ---
669
-
670
- ## API Reference
671
-
672
- ### `createStore(options)`
673
-
674
- ```javascript
675
- const store = createStore({
676
- types, // Object: entity type definitions
677
- entities, // Object: initial entities
678
- systems, // Array (optional): global state handlers
679
- updateMode, // String (optional): 'auto' (default) or 'manual'
680
- })
681
- ```
682
-
683
- **Returns:** A Redux-compatible store
684
-
685
- ### Types Definition
686
-
687
- ```javascript
688
- const types = {
689
- entityType: [
690
- // Behavior objects
691
- {
692
- eventName(entity, payload, api) {
693
- entity.value = payload
694
- api.notify("otherEvent", data)
695
- },
696
- },
697
- // Behavior functions (decorators)
698
- (behavior) => ({
699
- eventName(entity, payload, api) {
700
- // Wrap the behavior
701
- behavior.eventName?.(entity, payload, api)
702
- },
703
- }),
704
- ],
705
- }
706
- ```
707
-
708
- ### Event Handler API
709
-
710
- Each handler receives three arguments:
711
-
712
- - **`entity`** - the entity instance (mutate freely, immutability guaranteed)
713
- - **`payload`** - data passed with the event
714
- - **`api`** - access to store methods:
715
- - `getEntities()` - entire state (read-only)
716
- - `getEntity(id)` - single entity (read-only)
717
- - `notify(type, payload)` - trigger other events
718
- - `dispatch(action)` - optional, if you prefer Redux-style dispatching
719
- - `getTypes()` - type definitions (for middleware)
720
- - `getType(typeName)` - type definition (for overriding)
721
-
722
- ### Built-in Events
723
-
724
- - **`create(entity)`** - triggered when entity added via `add` event, visible only to that entity
725
- - **`destroy(entity)`** - triggered when entity removed via `remove` event, visible only to that entity
726
- - **`morph(typeName, newType)`** - used to change the behavior of a type on the fly
727
-
728
- ### Notify vs Dispatch
729
-
730
- Both work (`dispatch` is provided just for Redux compatibility), but `notify` is cleaner (and uses `dispatch` internally):
731
-
732
- ```javascript
733
- store.notify("eventName", payload)
734
- store.dispatch({ type: "eventName", payload }) // Redux-compatible alternative
735
- ```
736
-
737
- ### 🧩 Type Safety
738
-
739
- Inglorious Store is written in JavaScript but comes with powerful TypeScript support out of the box, allowing for a fully type-safe experience similar to Redux Toolkit, but with less boilerplate.
740
-
741
- You can achieve strong type safety by defining an interface for your `types` configuration. This allows you to statically define the shape of your entity handlers, ensuring that all required handlers are present and correctly typed.
742
-
743
- Here’s how you can set it up for a TodoMVC-style application:
744
-
745
- **1. Define Your Types**
746
-
747
- First, create an interface that describes your entire `types` configuration. This interface will enforce the structure of your event handlers.
748
-
749
- ```typescript
750
- // src/store/types.ts
751
- import type {
752
- FormEntity,
753
- ListEntity,
754
- FooterEntity,
755
- // ... other payload types
756
- } from "../../types"
757
-
758
- // Define the static shape of the types configuration
759
- interface TodoListTypes {
760
- form: {
761
- inputChange: (entity: FormEntity, value: string) => void
762
- formSubmit: (entity: FormEntity) => void
763
- }
764
- list: {
765
- formSubmit: (entity: ListEntity, value: string) => void
766
- toggleClick: (entity: ListEntity, id: number) => void
767
- // ... other handlers
768
- }
769
- footer: {
770
- filterClick: (entity: FooterEntity, id: string) => void
771
- }
772
- }
773
-
774
- export const types: TodoListTypes = {
775
- form: {
776
- inputChange(entity, value) {
777
- entity.value = value
778
- },
779
- formSubmit(entity) {
780
- entity.value = ""
781
- },
782
- },
783
- // ... other type implementations
784
- }
785
- ```
786
-
787
- With `TodoListTypes`, TypeScript will throw an error if you forget a handler (e.g., `formSubmit`) or if its signature is incorrect.
788
-
789
- **2. Create the Store**
790
-
791
- When creating your store, you'll pass the `types` object. To satisfy the store's generic `TypesConfig`, you may need to use a double cast (`as unknown as`). This is a safe and intentional way to bridge your specific, statically-checked configuration with the store's more generic type.
792
-
793
- ```typescript
794
- // src/store/index.ts
795
- import { createStore, type TypesConfig } from "@inglorious/store"
796
- import { types } from "./types"
797
- import type { TodoListEntity, TodoListState } from "../../types"
798
-
799
- export const store = createStore<TodoListEntity, TodoListState>({
800
- types: types as unknown as TypesConfig<TodoListEntity>,
801
- // ... other store config
802
- })
803
- ```
804
-
805
- **3. Enjoy Full Type Safety**
806
-
807
- Now, your store is fully type-safe. The hooks provided by `@inglorious/react-store` will also be correctly typed.
808
-
809
- ---
810
-
811
- ## Use Cases
812
-
813
- ### Perfect For
814
-
815
- - 🎮 Apps with multiple instances of the same entity type
816
- - 🎯 Real-time collaborative features
817
- - Complex state coordination and async operations
818
- - 📊 High-frequency updates (animations, games)
819
- - 🔄 Undo/redo, time-travel debugging
820
-
821
- ### Still Great For
822
-
823
- - Any Redux use case (true drop-in replacement)
824
- - Migration path from Redux (keep using react-redux)
825
-
826
- ---
827
-
828
- ### Demos
829
-
830
- Check out the following demos to see the Inglorious Store in action on real-case scenarios:
831
-
832
- **React Examples:**
833
-
834
- - **[React TodoMVC](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc)** - An (ugly) clone of Kent Dodds' [TodoMVC](https://todomvc.com/) experiments, showing the full compatibility with react-redux and The Redux DevTools.
835
- - **[React TodoMVC-CS](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc-cs)** - A client-server version of the TodoMVC, which showcases the use of `notify` as a cleaner alternative to `dispatch` and async event handlers.
836
- - **[React TodoMVC-RT](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc-rt)** - A "multiplayer" version, in which multiple clients are able to synchronize through a real-time server.
837
- - **[React TodoMVC-TS](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc-ts)** - A typesafe version of the base TodoMVC.
838
-
839
- For more demos and examples with `@inglorious/web`, see the [`@inglorious/web` README](../web/README.md).
840
-
841
- ---
842
-
843
- ## Frequently Unsolicited Complaints (FUCs)
844
-
845
- It's hard to accept the new, especially on Reddit. Here are the main objections to the Inglorious Store.
846
-
847
- **"This is not ECS."**
848
-
849
- It's not. The Inglorious Store is _inspired_ by ECS, but doesn't strictly follow ECS. Heck, not even the major game engines out there follow ECS by the book!
850
-
851
- Let's compare the two:
852
-
853
- | ECS Architecture | Inglorious Store |
854
- | ------------------------------------- | -------------------------------------- |
855
- | Entities are ids | Entities have an id |
856
- | Components are pure, consecutive data | Entities are pure bags of related data |
857
- | Data and behavior are separated | Data and behavior are separated |
858
- | Systems operate on the whole state | Systems operate on the whole state |
859
- | Usually written in an OOP environment | Written in an FP environment |
860
-
861
- **"This is not FP."**
862
-
863
- It looks like it's not, and that's a feature. If you're used to classes and instances, the Inglorious Store will feel natural to you. Even behavior composition looks like inheritance, but it's actually function composition. The same [Three Principles](https://redux.js.org/understanding/thinking-in-redux/three-principles) that describe Redux are applied here (with some degree of freedom on function purity).
864
-
865
- **"This is not Data-Oriented Design."**
866
-
867
- It's not. Please grep this README and count how many occurrences of DoD you can find. This is not [Data-Oriented Design](https://en.wikipedia.org/wiki/Data-oriented_design), which is related to low-level CPU cache optimization. It's more similar to [Data-Driven Programming](https://en.wikipedia.org/wiki/Data-driven_programming), which is related to separating data and behavior. The Inglorious Store separates behavior in... behaviors (grouped into so-called types), while the data is stored in plain objects called entities.
868
-
869
- ---
870
-
871
- ## License
872
-
873
- MIT © [Matteo Antony Mistretta](https://github.com/IngloriousCoderz)
874
-
875
- Free to use, modify, and distribute.
876
-
877
- ---
878
-
879
- ## Contributing
880
-
881
- Contributions welcome! Please read our [Contributing Guidelines](../../CONTRIBUTING.md) first.
1
+ # Inglorious Store
2
+
3
+ [![NPM version](https://img.shields.io/npm/v/@inglorious/store.svg)](https://www.npmjs.com/package/@inglorious/store)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ A Redux-compatible, ECS-inspired state library that makes state management as elegant as game logic.
7
+
8
+ **Drop-in replacement for Redux.** Works with `react-redux` and Redux DevTools. Borrows concepts from Entity-Component-System architectures and Functional Programming to provide an environment where you can write simple, predictable, and testable code.
9
+
10
+ ```javascript
11
+ // from redux
12
+ import { createStore } from "redux"
13
+ // to
14
+ import { createStore } from "@inglorious/store"
15
+ ```
16
+
17
+ ---
18
+
19
+ ## Why Inglorious Store?
20
+
21
+ Redux is powerful but verbose. You need action creators, reducers, middleware for async operations, and a bunch of decisions about where logic should live. Redux Toolkit cuts the boilerplate, but you're still writing a lot of ceremony.
22
+
23
+ Inglorious Store eliminates the boilerplate entirely with an **entity-based architecture** inspired by game engines. Some of the patterns that power AAA games now power your state management.
24
+
25
+ Game engines solved state complexity years ago — Inglorious Store brings those lessons to web development.
26
+
27
+ **Key benefits:**
28
+
29
+ - ✅ Drop-in Redux replacement (same API with `react-redux`)
30
+ - ✅ Entity-based state (manage multiple instances effortlessly)
31
+ - ✅ No action creators, thunks, or slices
32
+ - ✅ Predictable, testable, purely functional code
33
+ - ✅ Built-in lifecycle events (`add`, `remove`)
34
+ - ✅ 10x faster immutability than Redux Toolkit (Mutative vs Immer)
35
+
36
+ ---
37
+
38
+ ## Quick Comparison: Redux vs RTK vs Inglorious Store
39
+
40
+ ### Redux
41
+
42
+ ```javascript
43
+ // Action creators
44
+ const addTodo = (text) => ({ type: "ADD_TODO", payload: text })
45
+
46
+ // Reducer
47
+ const todosReducer = (state = [], action) => {
48
+ switch (action.type) {
49
+ case "ADD_TODO":
50
+ return [...state, { id: Date.now(), text: action.payload }]
51
+
52
+ case "OTHER_ACTION":
53
+ // Handle other action
54
+
55
+ default:
56
+ return state
57
+ }
58
+ }
59
+
60
+ // Store setup
61
+ const store = configureStore({
62
+ reducer: {
63
+ work: todosReducer,
64
+ personal: todosReducer,
65
+ },
66
+ })
67
+
68
+ store.dispatch({ type: "ADD_TODO", payload: "Buy groceries" })
69
+ store.dispatch({ type: "OTHER_ACTION" })
70
+ ```
71
+
72
+ ### Redux Toolkit
73
+
74
+ ```javascript
75
+ const otherAction = createAction("app:otherAction")
76
+
77
+ const todosSlice = createSlice({
78
+ name: "todos",
79
+ initialState: [],
80
+ reducers: {
81
+ addTodo: (state, action) => {
82
+ state.push({ id: Date.now(), text: action.payload })
83
+ },
84
+ },
85
+ extraReducers: (builder) => {
86
+ builder.addCase(otherAction, (state, action) => {
87
+ // Handle external action
88
+ })
89
+ },
90
+ })
91
+
92
+ const store = configureStore({
93
+ reducer: {
94
+ work: todosSlice.reducer,
95
+ personal: todosSlice.reducer,
96
+ },
97
+ })
98
+
99
+ store.dispatch(slice.actions.addTodo("Buy groceries"))
100
+ store.dispatch(otherAction())
101
+ ```
102
+
103
+ ### Inglorious Store
104
+
105
+ ```javascript
106
+ // Define entity types and their behavior
107
+ const types = {
108
+ todoList: {
109
+ addTodo(entity, text) {
110
+ entity.todos.push({ id: Date.now(), text })
111
+ },
112
+
113
+ otherAction(entity) {
114
+ // Handle other action
115
+ },
116
+ },
117
+ }
118
+
119
+ // Define initial entities
120
+ const entities = {
121
+ work: { type: "todoList", todos: [] },
122
+ personal: { type: "todoList", todos: [] },
123
+ }
124
+
125
+ // Create store
126
+ const store = createStore({ types, entities })
127
+
128
+ store.dispatch({ type: "addTodo", payload: "Buy groceries" })
129
+ store.dispatch({ type: "otherAction" })
130
+
131
+ // or, even better:
132
+ store.notify("addTodo", "Buy groceries")
133
+ store.notify("otherAction")
134
+
135
+ // same result, 10x simpler
136
+ ```
137
+
138
+ **Key differences:**
139
+
140
+ - ❌ No action creators
141
+ - ❌ No switch statements or cases
142
+ - ❌ No slice definitions with extraReducers
143
+ - ✅ Define what each entity type can do
144
+ - ✅ Add multiple instances by adding entities, not code
145
+
146
+ ---
147
+
148
+ ## Core Concepts
149
+
150
+ ### 🎮 Entities and Types
151
+
152
+ State consists of **entities** (instances) that have a **type** (behavior definition). Think of a type as a class and entities as instances:
153
+
154
+ ```javascript
155
+ const types = {
156
+ todoList: {
157
+ addTodo(entity, text) {
158
+ entity.todos.push({ id: Date.now(), text })
159
+ },
160
+ toggle(entity, id) {
161
+ const todo = entity.todos.find((t) => t.id === id)
162
+ if (todo) todo.completed = !todo.completed
163
+ },
164
+ },
165
+
166
+ settings: {
167
+ setTheme(entity, theme) {
168
+ entity.theme = theme
169
+ },
170
+ },
171
+ }
172
+
173
+ const entities = {
174
+ workTodos: { type: "todoList", todos: [], priority: "high" },
175
+ personalTodos: { type: "todoList", todos: [], priority: "low" },
176
+ settings: { type: "settings", theme: "dark", language: "en" },
177
+ }
178
+ ```
179
+
180
+ **Why this matters:**
181
+
182
+ - Same behavior applies to all instances of that type
183
+ - No need to write separate code for each instance
184
+ - Your mental model matches your code structure
185
+
186
+ ### 🔄 Event Handlers (Not Methods)
187
+
188
+ Even though it looks like types expose methods, they are actually **event handlers**, very similar to Redux reducers. There are a few differences though:
189
+
190
+ 1. Just like RTK reducers, you can mutate the entity directly since event handlers are using an immutability library under the hood. Not Immer, but Mutative — which claims to be 10x faster than Immer.
191
+
192
+ ```javascript
193
+ const types = {
194
+ counter: {
195
+ increment(counter) {
196
+ counter.value++ // Looks like mutation, immutable in reality
197
+ },
198
+ },
199
+ }
200
+ ```
201
+
202
+ 2. Event handlers accept as arguments the current entity, the event payload, and an API object that exposes a few convenient methods:
203
+
204
+ ```javascript
205
+ const types = {
206
+ counter: {
207
+ increment(counter, value, api) {
208
+ api.getEntities() // access the whole state in read-only mode
209
+ api.getEntity(id) // access some other entity in read-only mode
210
+ api.notify(type, payload) // similar to dispatch. Yes, you can dispatch inside of a reducer!
211
+ api.dispatch(action) // optional, if you prefer Redux-style dispatching
212
+ },
213
+ },
214
+ }
215
+ ```
216
+
217
+ ---
218
+
219
+ ## Installation & Setup
220
+
221
+ The Inglorious store, just like Redux, can be used standalone. However, it's commonly used together with component libraries such as React.
222
+
223
+ ### Basic Setup with `react-redux`
224
+
225
+ ```javascript
226
+ import { createStore } from "@inglorious/store"
227
+ import { Provider, useSelector, useDispatch } from "react-redux"
228
+
229
+ // 1. Define entity types
230
+ const types = {
231
+ counter: {
232
+ increment(counter) {
233
+ counter.value++
234
+ },
235
+ decrement(counter) {
236
+ counter.value--
237
+ },
238
+ },
239
+ }
240
+
241
+ // 2. Define initial entities
242
+ const entities = {
243
+ counter1: { type: "counter", value: 0 },
244
+ }
245
+
246
+ // 3. Create the store
247
+ const store = createStore({ types, entities })
248
+
249
+ // 4. Provide the store with react-redux
250
+ function App() {
251
+ return (
252
+ <Provider store={store}>
253
+ <Counter />
254
+ </Provider>
255
+ )
256
+ }
257
+
258
+ // 5. Wire components to the store
259
+ function Counter() {
260
+ const dispatch = useDispatch()
261
+ const count = useSelector((state) => state.counter1.value)
262
+
263
+ return (
264
+ <div>
265
+ <p>{count}</p>
266
+ <button onClick={() => dispatch({ type: "increment" })}>+</button>
267
+ <button onClick={() => dispatch({ type: "decrement" })}>-</button>
268
+ </div>
269
+ )
270
+ }
271
+ ```
272
+
273
+ ### With `@inglorious/react-store` (Recommended)
274
+
275
+ For React applications, `@inglorious/react-store` provides a set of hooks and a Provider that are tightly integrated with the store. It's a lightweight wrapper around `react-redux` that offers a more ergonomic API.
276
+
277
+ ```javascript
278
+ import { createStore } from "@inglorious/store"
279
+ import { createReactStore } from "@inglorious/react-store"
280
+
281
+ const store = createStore({ types, entities })
282
+
283
+ export const { Provider, useSelector, useNotify } = createReactStore(store)
284
+
285
+ function App() {
286
+ return (
287
+ // No store prop needed!
288
+ <Provider>
289
+ <Counter />
290
+ </Provider>
291
+ )
292
+ }
293
+
294
+ function Counter() {
295
+ const notify = useNotify() // less verbose than dispatch
296
+ const count = useSelector((state) => state.counter1.value)
297
+
298
+ return (
299
+ <div>
300
+ <p>{count}</p>
301
+ <button onClick={() => notify("increment")}>+</button> // simplified
302
+ syntax
303
+ <button onClick={() => notify("decrement")}>-</button>
304
+ </div>
305
+ )
306
+ }
307
+ ```
308
+
309
+ The package is fully typed, providing a great developer experience with TypeScript.
310
+
311
+ ---
312
+
313
+ ## Core Features
314
+
315
+ ### 🎮 Entity-Based State
316
+
317
+ The real power: add entities dynamically without code changes.
318
+
319
+ **Redux/RTK:** To manage three counters, you can reuse a reducer. But what if you want to add a new counter at runtime? Your best option is probably to reshape the whole state.
320
+
321
+ ```javascript
322
+ // The original list of counters:
323
+ const store = configureStore({
324
+ reducer: {
325
+ counter1: counterReducer,
326
+ counter2: counterReducer,
327
+ counter3: counterReducer,
328
+ },
329
+ })
330
+
331
+ // becomes:
332
+ const store = configureStore({
333
+ reducer: {
334
+ counters: countersReducer,
335
+ },
336
+ })
337
+
338
+ // with extra actions to manage adding/removing counters:
339
+ store.dispatch({ type: "addCounter", payload: "counter4" })
340
+ ```
341
+
342
+ **Inglorious Store** makes it trivial:
343
+
344
+ ```javascript
345
+ const types = {
346
+ counter: {
347
+ increment(entity) {
348
+ entity.value++
349
+ },
350
+ },
351
+ }
352
+
353
+ const entities = {
354
+ counter1: { type: "counter", value: 0 },
355
+ counter2: { type: "counter", value: 0 },
356
+ counter3: { type: "counter", value: 0 },
357
+ }
358
+
359
+ store.notify("add", { id: "counter4", type: "counter", value: 0 })
360
+ ```
361
+
362
+ Inglorious Store has a few built-in events that you can use:
363
+
364
+ - `add`: adds a new entity to the state. Triggers a `create` lifecycle event.
365
+ - `remove`: removes an entity from the state. Triggers a `destroy` lifecycle event.
366
+
367
+ The lifecycle events can be used to define event handlers similar to constructor and destructor methods in OOP:
368
+
369
+ > Note: these special lifecycle events are not broadcast: they are visible only to the added/removed entity!
370
+
371
+ ```javascript
372
+ const types = {
373
+ counter: {
374
+ create(entity) {
375
+ entity.createdAt = Date.now()
376
+ },
377
+
378
+ destroy(entity) {
379
+ entity.destroyedAt = Date.now()
380
+ },
381
+ },
382
+ }
383
+ ```
384
+
385
+ ### 🔊 Event Broadcasting
386
+
387
+ Events are broadcast to all entities via pub/sub. Every entity handler receives every event of that type, just like it does in Redux.
388
+
389
+ ```javascript
390
+ const types = {
391
+ todoList: {
392
+ taskCompleted(entity, taskId) {
393
+ const task = entity.tasks.find((t) => t.id === taskId)
394
+ if (task) task.completed = true
395
+ },
396
+ },
397
+ stats: {
398
+ taskCompleted(entity, taskId) {
399
+ entity.completedCount++
400
+ },
401
+ },
402
+ notifications: {
403
+ taskCompleted(entity, taskId) {
404
+ entity.messages.push("Nice! Task completed.")
405
+ },
406
+ },
407
+ }
408
+
409
+ // One notify call, all three entity types respond
410
+ store.notify("taskCompleted", "task123")
411
+ ```
412
+
413
+ In RTK, such action would have be to be defined outside of the slice with `createAction` and then processed with the builder callback notation inside of the `extraReducers` section.
414
+
415
+ - What if you want to notify the event only to entities of one specific type? Define an event handler for that event only on that type.
416
+ - What if you want to notify the event only on one entity of that type? Add an if that checks if the entity should be bothered or not by it.
417
+
418
+ ```javascript
419
+ const types = {
420
+ todoList: {
421
+ toggle(entity, id) {
422
+ // This runs for EVERY todoList entity, but only acts if it's the right one
423
+ if (entity.id !== id) return
424
+
425
+ const todo = entity.todos.find((t) => t.id === id)
426
+ if (todo) todo.completed = !todo.completed
427
+ },
428
+ },
429
+ }
430
+
431
+ // Broadcast to all todo lists
432
+ store.notify("toggle", "todo1")
433
+ // Each list's toggle handler runs; only the one with todo1 actually updates
434
+ ```
435
+
436
+ ### ⚡ Async Operations
437
+
438
+ In **Redux/RTK**, logic should be written inside pure functions as much as possible — specifically in reducers, not action creators. But what if I need to access some other part of the state that is not visible to the reducer? What if I need to combine async behavior with sync behavior? This is where the choice of "where does my logic live?" matters.
439
+
440
+ In **Inglorious Store:** your event handlers can be async, and you get deterministic behavior automatically. Inside an async handler, you can access other parts of state (read-only), and you can trigger other events via `api.notify()`. Even if we give up on some purity, everything still maintains predictability because of the underlying **event queue**:
441
+
442
+ ```javascript
443
+ const types = {
444
+ todoList: {
445
+ async loadTodos(entity, payload, api) {
446
+ try {
447
+ entity.loading = true
448
+ const { name } = api.getEntity("user")
449
+ const response = await fetch(`/api/todos/${name}`)
450
+ const data = await response.json()
451
+ api.notify("todosLoaded", todos)
452
+ } catch (error) {
453
+ api.notify("loadFailed", error.message)
454
+ }
455
+ },
456
+
457
+ todosLoaded(entity, todos) {
458
+ entity.todos = todos
459
+ entity.loading = false
460
+ },
461
+
462
+ loadFailed(entity, error) {
463
+ entity.error = error
464
+ entity.loading = false
465
+ },
466
+ },
467
+ }
468
+ ```
469
+
470
+ Notice: you don't need pending/fulfilled/rejected actions. You stay in control of the flow — no hidden action chains. The `api` object passed to handlers provides:
471
+
472
+ - **`api.getEntities()`** - read entire state
473
+ - **`api.getEntity(id)`** - read one entity
474
+ - **`api.notify(type, payload)`** - trigger other events (queued, not immediate)
475
+ - **`api.dispatch(action)`** - optional, if you prefer Redux-style dispatching
476
+ - **`api.getTypes()`** - access type definitions (mainly for middleware/plugins)
477
+ - **`api.getType(typeName)`** - access type definition (mainly for overrides)
478
+
479
+ All events triggered via `api.notify()` enter the queue and process together, maintaining predictability and testability.
480
+
481
+ ### 🧪 Testing
482
+
483
+ Event handlers are pure functions (or can be treated as such), making them easy to test in isolation, much like Redux reducers. The `@inglorious/store/test` module provides utility functions to make this even simpler.
484
+
485
+ #### `trigger(entity, handler, payload, api?)`
486
+
487
+ The `trigger` function executes an event handler on a single entity and returns the new state and any events that were dispatched.
488
+
489
+ ```javascript
490
+ import { trigger } from "@inglorious/store/test"
491
+
492
+ // Define your entity handler
493
+ function increment(entity, payload, api) {
494
+ entity.value += payload.amount
495
+ if (entity.value > 100) {
496
+ api.notify("overflow", { id: entity.id })
497
+ }
498
+ }
499
+
500
+ // Test it
501
+ const { entity, events } = trigger(
502
+ { type: "counter", id: "counter1", value: 99 },
503
+ increment,
504
+ { amount: 5 },
505
+ )
506
+
507
+ expect(entity.value).toBe(104)
508
+ expect(events).toEqual([{ type: "overflow", payload: { id: "counter1" } }])
509
+ ```
510
+
511
+ #### `createMockApi(entities)`
512
+
513
+ If your handler needs to interact with other entities via the `api`, you can create a mock API. This is useful for testing handlers that read from other parts of the state.
514
+
515
+ ```javascript
516
+ import { createMockApi, trigger } from "@inglorious/store/test"
517
+
518
+ // Create a mock API with some initial entities
519
+ const api = createMockApi({
520
+ counter1: { type: "counter", value: 10 },
521
+ counter2: { type: "counter", value: 20 },
522
+ })
523
+
524
+ // A handler that copies a value from another entity
525
+ function copyValue(entity, payload, api) {
526
+ const source = api.getEntity(payload.sourceId)
527
+ entity.value = source.value
528
+ }
529
+
530
+ // Trigger the handler with the custom mock API
531
+ const { entity } = trigger(
532
+ { type: "counter", id: "counter2", value: 20 },
533
+ copyValue,
534
+ { sourceId: "counter1" },
535
+ api,
536
+ )
537
+
538
+ expect(entity.value).toBe(10)
539
+ ```
540
+
541
+ The mock API provides:
542
+
543
+ - `getEntities()`: Returns all entities (frozen).
544
+ - `getEntity(id)`: Returns a specific entity by ID (frozen).
545
+ - `dispatch(event)`: Records an event for later assertions.
546
+ - `notify(type, payload)`: A convenience wrapper around `dispatch`.
547
+ - `getEvents()`: Returns all events that were dispatched.
548
+
549
+ ### 🌍 Systems for Global Logic
550
+
551
+ When you need to coordinate updates across multiple entities (not just respond to individual events), use systems. Systems run after all entity handlers for the same event, ensuring global consistency, and have write access to the entire state. This concept is the 'S' in the ECS Architecture (Entity-Component-System)!
552
+
553
+ ```javascript
554
+ const systems = [
555
+ {
556
+ taskCompleted(state, taskId) {
557
+ // Read from multiple todo lists
558
+ const allTodos = Object.values(state)
559
+ .filter((e) => e.type === "todoList")
560
+ .flatMap((e) => e.todos)
561
+
562
+ // Update global stats
563
+ state.stats.total = allTodos.length
564
+ state.stats.completed = allTodos.filter((t) => t.completed).length
565
+ },
566
+ },
567
+ ]
568
+
569
+ const store = createStore({ types, entities, systems })
570
+ ```
571
+
572
+ Systems receive the entire state and can modify any entity. They're useful for cross-cutting concerns, maintaining derived state, or coordinating complex state updates that can't be expressed as individual entity handlers.
573
+
574
+ ### 🔗 Behavior Composition
575
+
576
+ A type can be a single behavior object, or an array of behaviors.
577
+
578
+ ```javascript
579
+ // single-behavior type
580
+ const counter = {
581
+ increment(entity) {
582
+ entity.value++
583
+ },
584
+
585
+ decrement(entity) {
586
+ entity.value--
587
+ },
588
+ }
589
+
590
+ // multiple behavior type
591
+ const resettableCounter = [
592
+ counter,
593
+ {
594
+ reset(entity) {
595
+ entity.value = 0
596
+ },
597
+ },
598
+ ]
599
+ ```
600
+
601
+ A behavior is defined as either an object with event handlers, or a function that takes a type and returns an enhanced behavior (decorator pattern):
602
+
603
+ ```javascript
604
+ // Base behavior
605
+ const resettable = {
606
+ submit(entity, value) {
607
+ entity.value = ""
608
+ },
609
+ }
610
+
611
+ // Function that wraps and enhances a behavior
612
+ const validated = (type) => ({
613
+ submit(entity, value, api) {
614
+ if (!value.trim()) return
615
+ type.submit?.(entity, value, api) // remember to always pass all args!
616
+ },
617
+ })
618
+
619
+ // Another wrapper
620
+ const withLoading = (type) => ({
621
+ submit(entity, value, api) {
622
+ entity.loading = true
623
+ type.submit?.(entity, value, api)
624
+ entity.loading = false
625
+ },
626
+ })
627
+
628
+ // Compose them together to form a type
629
+ const form = [resettable, validated, withLoading]
630
+ ```
631
+
632
+ When multiple behaviors define the same event, they all run in order. This allows you to build middleware-like patterns: validation, logging, error handling, loading states, etc.
633
+
634
+ ### ⏱️ Batched Mode
635
+
636
+ The Inglorious Store features an **event queue**. In the default `auto` update mode, each notified event will trigger and update of the state (same as Redux). But in `manual` update mode, you can process multiple events together before re-rendering:
637
+
638
+ ```javascript
639
+ const store = createStore({ types, entities, updateMode: "manual" })
640
+
641
+ // add events to the event queue
642
+ store.notify("playerMoved", { x: 100, y: 50 })
643
+ store.notify("enemyAttacked", { damage: 10 })
644
+ store.notify("particleCreated", { type: "explosion" })
645
+
646
+ // process them all in batch
647
+ store.update()
648
+ ```
649
+
650
+ Instead of re-rendering after each event, you can batch them and re-render once. This is what powers high-performance game engines and smooth animations.
651
+
652
+ ---
653
+
654
+ ## Comparison with Other State Libraries
655
+
656
+ | Feature | Redux | RTK | Zustand | Jotai | Pinia | MobX | Inglorious Store |
657
+ | ------------------------- | ------------ | ------------ | ---------- | ---------- | ---------- | ---------- | ---------------- |
658
+ | **Boilerplate** | 🔴 High | 🟡 Medium | 🟢 Low | 🟢 Low | 🟡 Medium | 🟢 Low | 🟢 Low |
659
+ | **Multiple instances** | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🟡 Medium | 🟡 Medium | 🟢 Built-in |
660
+ | **Lifecycle events** | 🔴 No | 🔴 No | 🔴 No | 🔴 No | 🔴 No | 🔴 No | 🟢 Yes |
661
+ | **Async logic placement** | 🟡 Thunks | 🟡 Complex | 🟢 Free | 🟢 Free | 🟢 Free | 🟢 Free | 🟢 In handlers |
662
+ | **Redux DevTools** | 🟢 Yes | 🟢 Yes | 🟡 Partial | 🟡 Partial | 🟡 Partial | 🟢 Yes | 🟢 Yes |
663
+ | **Time-travel debugging** | 🟢 Yes | 🟢 Yes | 🔴 No | 🔴 No | 🔴 No | 🟡 Limited | 🟢 Yes |
664
+ | **Testability** | 🟢 Excellent | 🟢 Excellent | 🟡 Good | 🟡 Good | 🟡 Good | 🟡 Medium | 🟢 Excellent |
665
+ | **Immutability** | 🔴 Manual | 🟢 Immer | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🔴 Manual | 🟢 Mutative |
666
+
667
+ ---
668
+
669
+ ## API Reference
670
+
671
+ ### `createStore(options)`
672
+
673
+ ```javascript
674
+ const store = createStore({
675
+ types, // Object: entity type definitions
676
+ entities, // Object: initial entities
677
+ systems, // Array (optional): global state handlers
678
+ updateMode, // String (optional): 'auto' (default) or 'manual'
679
+ })
680
+ ```
681
+
682
+ **Returns:** A Redux-compatible store
683
+
684
+ ### Types Definition
685
+
686
+ ```javascript
687
+ const types = {
688
+ entityType: [
689
+ // Behavior objects
690
+ {
691
+ eventName(entity, payload, api) {
692
+ entity.value = payload
693
+ api.notify("otherEvent", data)
694
+ },
695
+ },
696
+ // Behavior functions (decorators)
697
+ (behavior) => ({
698
+ eventName(entity, payload, api) {
699
+ // Wrap the behavior
700
+ behavior.eventName?.(entity, payload, api)
701
+ },
702
+ }),
703
+ ],
704
+ }
705
+ ```
706
+
707
+ ### Event Handler API
708
+
709
+ Each handler receives three arguments:
710
+
711
+ - **`entity`** - the entity instance (mutate freely, immutability guaranteed)
712
+ - **`payload`** - data passed with the event
713
+ - **`api`** - access to store methods:
714
+ - `getEntities()` - entire state (read-only)
715
+ - `getEntity(id)` - single entity (read-only)
716
+ - `notify(type, payload)` - trigger other events
717
+ - `dispatch(action)` - optional, if you prefer Redux-style dispatching
718
+ - `getTypes()` - type definitions (for middleware)
719
+ - `getType(typeName)` - type definition (for overriding)
720
+ - `setType(typeName, type)` - change the behavior of a type
721
+
722
+ ### Built-in Events
723
+
724
+ - **`create(entity)`** - triggered when entity added via `add` event, visible only to that entity
725
+ - **`destroy(entity)`** - triggered when entity removed via `remove` event, visible only to that entity
726
+
727
+ ### Notify vs Dispatch
728
+
729
+ Both work (`dispatch` is provided just for Redux compatibility), but `notify` is cleaner (and uses `dispatch` internally):
730
+
731
+ ```javascript
732
+ store.notify("eventName", payload)
733
+ store.dispatch({ type: "eventName", payload }) // Redux-compatible alternative
734
+ ```
735
+
736
+ ### 🧩 Type Safety
737
+
738
+ Inglorious Store is written in JavaScript but comes with powerful TypeScript support out of the box, allowing for a fully type-safe experience similar to Redux Toolkit, but with less boilerplate.
739
+
740
+ You can achieve strong type safety by defining an interface for your `types` configuration. This allows you to statically define the shape of your entity handlers, ensuring that all required handlers are present and correctly typed.
741
+
742
+ Here’s how you can set it up for a TodoMVC-style application:
743
+
744
+ **1. Define Your Types**
745
+
746
+ First, create an interface that describes your entire `types` configuration. This interface will enforce the structure of your event handlers.
747
+
748
+ ```typescript
749
+ // src/store/types.ts
750
+ import type {
751
+ FormEntity,
752
+ ListEntity,
753
+ FooterEntity,
754
+ // ... other payload types
755
+ } from "../../types"
756
+
757
+ // Define the static shape of the types configuration
758
+ interface TodoListTypes {
759
+ form: {
760
+ inputChange: (entity: FormEntity, value: string) => void
761
+ formSubmit: (entity: FormEntity) => void
762
+ }
763
+ list: {
764
+ formSubmit: (entity: ListEntity, value: string) => void
765
+ toggleClick: (entity: ListEntity, id: number) => void
766
+ // ... other handlers
767
+ }
768
+ footer: {
769
+ filterClick: (entity: FooterEntity, id: string) => void
770
+ }
771
+ }
772
+
773
+ export const types: TodoListTypes = {
774
+ form: {
775
+ inputChange(entity, value) {
776
+ entity.value = value
777
+ },
778
+ formSubmit(entity) {
779
+ entity.value = ""
780
+ },
781
+ },
782
+ // ... other type implementations
783
+ }
784
+ ```
785
+
786
+ With `TodoListTypes`, TypeScript will throw an error if you forget a handler (e.g., `formSubmit`) or if its signature is incorrect.
787
+
788
+ **2. Create the Store**
789
+
790
+ When creating your store, you'll pass the `types` object. To satisfy the store's generic `TypesConfig`, you may need to use a double cast (`as unknown as`). This is a safe and intentional way to bridge your specific, statically-checked configuration with the store's more generic type.
791
+
792
+ ```typescript
793
+ // src/store/index.ts
794
+ import { createStore, type TypesConfig } from "@inglorious/store"
795
+ import { types } from "./types"
796
+ import type { TodoListEntity, TodoListState } from "../../types"
797
+
798
+ export const store = createStore<TodoListEntity, TodoListState>({
799
+ types: types as unknown as TypesConfig<TodoListEntity>,
800
+ // ... other store config
801
+ })
802
+ ```
803
+
804
+ **3. Enjoy Full Type Safety**
805
+
806
+ Now, your store is fully type-safe. The hooks provided by `@inglorious/react-store` will also be correctly typed.
807
+
808
+ ---
809
+
810
+ ## Use Cases
811
+
812
+ ### Perfect For
813
+
814
+ - 🎮 Apps with multiple instances of the same entity type
815
+ - 🎯 Real-time collaborative features
816
+ - Complex state coordination and async operations
817
+ - 📊 High-frequency updates (animations, games)
818
+ - 🔄 Undo/redo, time-travel debugging
819
+
820
+ ### Still Great For
821
+
822
+ - Any Redux use case (true drop-in replacement)
823
+ - Migration path from Redux (keep using react-redux)
824
+
825
+ ---
826
+
827
+ ### Demos
828
+
829
+ Check out the following demos to see the Inglorious Store in action on real-case scenarios:
830
+
831
+ **React Examples:**
832
+
833
+ - **[React TodoMVC](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc)** - An (ugly) clone of Kent Dodds' [TodoMVC](https://todomvc.com/) experiments, showing the full compatibility with react-redux and The Redux DevTools.
834
+ - **[React TodoMVC-CS](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc-cs)** - A client-server version of the TodoMVC, which showcases the use of `notify` as a cleaner alternative to `dispatch` and async event handlers.
835
+ - **[React TodoMVC-RT](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc-rt)** - A "multiplayer" version, in which multiple clients are able to synchronize through a real-time server.
836
+ - **[React TodoMVC-TS](https://github.com/IngloriousCoderz/inglorious-forge/tree/main/examples/apps/react-todomvc-ts)** - A typesafe version of the base TodoMVC.
837
+
838
+ For more demos and examples with `@inglorious/web`, see the [`@inglorious/web` README](../web/README.md).
839
+
840
+ ---
841
+
842
+ ## Frequently Unsolicited Complaints (FUCs)
843
+
844
+ It's hard to accept the new, especially on Reddit. Here are the main objections to the Inglorious Store.
845
+
846
+ **"This is not ECS."**
847
+
848
+ It's not. The Inglorious Store is _inspired_ by ECS, but doesn't strictly follow ECS. Heck, not even the major game engines out there follow ECS by the book!
849
+
850
+ Let's compare the two:
851
+
852
+ | ECS Architecture | Inglorious Store |
853
+ | ------------------------------------- | -------------------------------------- |
854
+ | Entities are ids | Entities have an id |
855
+ | Components are pure, consecutive data | Entities are pure bags of related data |
856
+ | Data and behavior are separated | Data and behavior are separated |
857
+ | Systems operate on the whole state | Systems operate on the whole state |
858
+ | Usually written in an OOP environment | Written in an FP environment |
859
+
860
+ **"This is not FP."**
861
+
862
+ It looks like it's not, and that's a feature. If you're used to classes and instances, the Inglorious Store will feel natural to you. Even behavior composition looks like inheritance, but it's actually function composition. The same [Three Principles](https://redux.js.org/understanding/thinking-in-redux/three-principles) that describe Redux are applied here (with some degree of freedom on function purity).
863
+
864
+ **"This is not Data-Oriented Design."**
865
+
866
+ It's not. Please grep this README and count how many occurrences of DoD you can find. This is not [Data-Oriented Design](https://en.wikipedia.org/wiki/Data-oriented_design), which is related to low-level CPU cache optimization. It's more similar to [Data-Driven Programming](https://en.wikipedia.org/wiki/Data-driven_programming), which is related to separating data and behavior. The Inglorious Store separates behavior in... behaviors (grouped into so-called types), while the data is stored in plain objects called entities.
867
+
868
+ ---
869
+
870
+ ## License
871
+
872
+ MIT © [Matteo Antony Mistretta](https://github.com/IngloriousCoderz)
873
+
874
+ Free to use, modify, and distribute.
875
+
876
+ ---
877
+
878
+ ## Contributing
879
+
880
+ Contributions welcome! Please read our [Contributing Guidelines](../../CONTRIBUTING.md) first.