Skip to content
On this page

Event System

Inglorious Store's event system is the core mechanism for state updates. It uses a pub/sub architecture with targeted notifications and an event queue for predictable, batch-able updates.


Event Broadcasting (Pub/Sub)

By default, events are broadcast to all entities with a handler for that event:

javascript
const types = {
  todoList: {
    taskCompleted(entity, taskId) {
      // This runs for every todoList entity
      const task = entity.tasks.find((t) => t.id === taskId)
      if (task) task.completed = true
    },
  },
  stats: {
    taskCompleted(entity, taskId) {
      // This also runs (if defined on stats)
      entity.completedCount++
    },
  },
  notifications: {
    taskCompleted(entity, taskId) {
      // And this runs too
      entity.messages.push("Task completed!")
    },
  },
}

// One event, three handlers fire
store.notify("taskCompleted", "task123")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

Key benefit: New entity types can react to existing events without changing other code.


Targeted Notifications

Sometimes you want to limit which entities receive an event. Inglorious Store supports three levels of targeting:

1. Broadcast to All (Default)

javascript
store.notify("eventName", payload)
1

All entities with an eventName handler receive it.

javascript
const types = {
  counter: {
    increment(e) {
      e.value++
    },
  },
  timer: {
    increment(e) {
      e.elapsed++
    },
  },
}

store.notify("increment", null)
// Both counter and timer increment handlers fire
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

2. Target by Type

javascript
store.notify("typeName:eventName", payload)
1

Only entities of typeName receive the event.

javascript
store.notify("counter:increment", null)
// Only counter entities increment
// timer does NOT receive it
1
2
3

3. Target by Entity ID

javascript
store.notify("#entityId:eventName", payload)
1

Only the entity with that ID receives the event.

javascript
store.notify("#counter1:increment", null)
// Only counter1 increments
// counter2 does NOT receive it
1
2
3

4. Target by Type and ID (Explicit)

javascript
store.notify("typeName#entityId:eventName", payload)
1

Only the specific entity of that type receives it. Useful if multiple types might share entity IDs.

javascript
store.notify("counter#counter1:increment", null)
// Explicitly targets counter1 of type counter
1
2

Common Patterns

Pattern 1: Broadcast with Self-Check

Use broadcasting with a guard clause when you want multiple types to react but need selective behavior:

javascript
const types = {
  todoList: {
    toggle(entity, todoId) {
      // Run for ALL todoList entities
      // But only update if this list owns the todo
      if (!entity.todos.find((t) => t.id === todoId)) {
        return // Skip if not in this list
      }

      const todo = entity.todos.find((t) => t.id === todoId)
      if (todo) todo.completed = !todo.completed
    },
  },
  stats: {
    toggle(entity) {
      // Stats always reacts
      entity.totalToggled++
    },
  },
}

store.notify("toggle", "todo123")
// todoList handlers check internally
// stats always increments
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

Pattern 2: Targeted Notification

Use targeted notifications when you want to avoid broadcasting entirely:

javascript
const types = {
  todoList: {
    toggle(entity, todoId) {
      const todo = entity.todos.find((t) => t.id === todoId)
      if (todo) todo.completed = !todo.completed
    },
  },
}

// Target only the list that owns this todo
store.notify("#workTodos:toggle", "todo123")
// Only workTodos list handles it
1
2
3
4
5
6
7
8
9
10
11
12

Pattern 3: Cross-Entity Communication

Use targeted notifications for one entity to trigger another:

javascript
const types = {
  form: {
    submit(entity, data, api) {
      // Form submits
      api.notify("formSubmitted", data)
      // Notify a specific list to update
      api.notify("#workTodos:refreshAfterSubmit", data)
    },
  },
  todoList: {
    refreshAfterSubmit(entity, data) {
      // Only called when form targets this list
      entity.refresh()
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Event Queue

Inglorious Store queues events before processing them. This ensures:

  1. Predictable order - Events process in the order they were notified
  2. Atomic updates - All events in a batch process together before subscribers are notified
  3. No interleaving - Sync and async handlers don't race

Auto Update Mode (Default)

javascript
const store = createStore({
  types,
  entities,
  updateMode: "auto", // Events trigger immediately
})

store.notify("event1", payload1) // Re-render after this
store.notify("event2", payload2) // Re-render after this
// Result: 2 re-renders
1
2
3
4
5
6
7
8
9

Manual Update Mode

javascript
const store = createStore({
  types,
  entities,
  updateMode: "manual", // You control when events process
})

// Queue multiple events
store.notify("playerMoved", { x: 100, y: 50 })
store.notify("enemySpotted", { enemyId: "e1" })
store.notify("sound:play", { type: "footstep" })

// Process all at once
store.update() // Single re-render
1
2
3
4
5
6
7
8
9
10
11
12
13

Why this matters: In games or complex UIs, batching updates prevents intermediate states from rendering.


Event Lifecycle

When you dispatch an event, here's what happens:

1. notify(eventType, payload)

2. Event queued

3. In updateMode: "auto" → immediately execute next step
   In updateMode: "manual" → wait for update() call

4. Find all entities that handle this event

5. Execute each handler synchronously

6. If handler calls api.notify(), queue those events

7. Continue with next queued event

8. All events processed

9. Notify React/Vue subscribers (one batched notification)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Example with Multiple Events

javascript
store.notify("event1", p1)
// Queued: [event1]

api.notify("event2", p2) // Called from event1 handler
// Queued: [event2]

// event1 finishes, event2 starts
// When event2 handler calls api.notify("event3", p3)
// Queued: [event3]

// All three process, then subscribers notified once
1
2
3
4
5
6
7
8
9
10
11

Async Events in the Queue

Async handlers work seamlessly with the queue:

javascript
const types = {
  todoList: {
    async fetchTodos(entity, userId, api) {
      entity.loading = true

      // Await outside the queue
      const data = await fetch(`/api/users/${userId}/todos`).then((r) =>
        r.json(),
      )

      // Back into queue: notify other handlers
      api.notify("fetchSuccess", data)
    },

    fetchSuccess(entity, data) {
      entity.todos = data
      entity.loading = false
    },
  },
}

// Dispatch
store.notify("fetchTodos", userId)

// Flow:
// 1. fetchTodos handler starts
// 2. Sets entity.loading = true
// 3. Awaits fetch (queued handlers pause)
// 4. fetch completes
// 5. api.notify("fetchSuccess") queues new event
// 6. fetchSuccess handler runs
// 7. Sets todos and loading = false
// 8. All subscribers notified once
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

Event Scoping with handleAsync

The handleAsync helper generates multiple events with different scopes:

javascript
import { handleAsync } from "@inglorious/store/async"

const types = {
  todoList: {
    ...handleAsync(
      "fetchTodos",
      {
        async run(entity, userId, api) {
          return fetch(`/api/${userId}/todos`).then((r) => r.json())
        },
        success(entity, data) {
          entity.todos = data
        },
      },
      {
        scope: "entity", // Default: only affects this entity
      },
    ),
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

Think of the flow like a newspaper article:

  • start writes the headline and sets the scene
  • run gathers the reporting
  • success publishes the story
  • error prints the correction
  • finally archives the notes

Generated events by scope:

Entity scope (default):

#entityId:fetchTodos        (initial event)
#entityId:fetchTodosRun     (when running)
#entityId:fetchTodosSuccess (on success)
#entityId:fetchTodosError   (on error)
1
2
3
4

Type scope:

todoList:fetchTodos         (initial event)
todoList:fetchTodosRun      (when running)
todoList:fetchTodosSuccess  (on success)
todoList:fetchTodosError    (on error)
1
2
3
4

Global scope:

fetchTodos                  (initial event)
fetchTodosRun               (when running)
fetchTodosSuccess           (on success)
fetchTodosError             (on error)
1
2
3
4

Best Practices

✅ Do

  • Use broadcast for cross-cutting concerns (logging, analytics, UI updates)
  • Use targeted for specific entity updates (loading a particular list)
  • Use manual update for game loops or high-frequency updates
  • Keep handlers pure - no side effects except state mutation
  • Use api.notify() for event chaining - it maintains queue order

❌ Don't

  • Don't mutate state outside handlers - always use events
  • Don't call notify() without awaiting in async - queue won't wait
  • Don't directly access other entities in handlers - use api.getEntity()
  • Don't assume handler order - broadcast order is undefined
  • Don't create circular event chains - can cause infinite loops

Advanced: Custom Event Routing

You can implement custom event routing by extending the store behavior:

javascript
const store = createStore({
  types,
  entities,
  systems: [
    {
      myCustomEvent(state, prevState, event) {
        // Run custom logic after all handlers
        console.log(`Event ${event.type} processed`)
        console.log(`State changed:`, state !== prevState)
      },
    },
  ],
})
1
2
3
4
5
6
7
8
9
10
11
12
13

Comparison with Redux

AspectReduxInglorious
BroadcastingAll reducers see all actionsSelective handlers
Targeting❌ Not supported✅ By type/ID/both
QueueImplicitExplicit, controllable
BatchingAutomaticAuto or manual
AsyncThunks + middlewareNative, queue-aware

Next Steps

Released under the MIT License.