Skip to content

API Reference ​

createStore(options) ​

Creates a new Inglorious Store instance. Returns a Redux-compatible store.

javascript
const store = createStore({
  types, // Object: entity type definitions (required)
  entities, // Object: initial entities (required)
  systems, // Array: global state handlers (optional)
  middlewares, // Array: functions that wrap the store (optional)
  autoCreateEntities, // Boolean: auto-create singleton entities (optional)
  updateMode, // String: 'auto' | 'manual' (optional)
  updateStrategy, // String: 'structural-sharing' | 'copy-on-dispatch' (optional)
})

Options ​

types (required) ​

Object defining entity type behaviors. Each type is a behavior object or array of behaviors:

javascript
const types = {
  todoList: {
    addTodo(entity, text) {
      entity.todos.push({ id: Date.now(), text, done: false })
    },
    removeTodo(entity, id) {
      entity.todos = entity.todos.filter((t) => t.id !== id)
    },
  },
  counter: {
    increment(entity) {
      entity.value++
    },
  },
}

entities (required) ​

Object containing initial entity instances:

javascript
const entities = {
  workTodos: { type: "TodoList", todos: [] },
  personalTodos: { type: "TodoList", todos: [] },
  counter: { type: "Counter", value: 0 },
}

systems (optional) ​

Array of global state handlers that run after all entity handlers:

javascript
const systems = [
  {
    taskCompleted(state, prevState) {
      // Calculate derived state, validate consistency, etc.
      console.log("All handlers finished, state updated")
    },
  },
]

const store = createStore({ types, entities, systems })

middlewares (optional) ​

Array of functions that each take the store and return it, changed or wrapped. One that only cares about events replaces dispatch with a function that calls the previous one; one that cares about rendering can wrap subscribe, as withTransitions from @inglorious/ui does. The first middleware wraps all the others, so events pass through them in the order they are listed:

javascript
const logger = (store) => {
  const next = store.dispatch
  store.dispatch = (event) => {
    console.log(event.type, event.payload)
    return next(event)
  }
  return store
}

const store = createStore({
  types,
  entities,
  middlewares: [logger, withTransitions()],
})

notify goes through dispatch, so a middleware sees every event, including those handlers notify. Not calling next stops an event. Middlewares with options are factories that return the middleware, like withTransitions({ routes }) and multiplayerMiddleware(config).

autoCreateEntities (optional) ​

Boolean flag to automatically create singleton entities for types not defined in entities:

javascript
const store = createStore({
  types: {
    settings: {
      setTheme(e, t) {
        e.theme = t
      },
    },
    analytics: {
      track(e, ev) {
        e.events.push(ev)
      },
    },
  },
  entities: {},
  autoCreateEntities: true,
})

// Automatically creates:
// settings: { type: "Settings" }
// analytics: { type: "Analytics" }

Use when:

  • ✅ Building web apps with singleton services
  • ❌ Managing multiple instances (game development)
  • ❌ When you need fine-grained initial state

updateMode (optional) ​

Controls when the store notifies subscribers:

  • 'auto' (default) - Subscribers notified after each event
  • 'manual' - Call api.update() to batch updates
javascript
const store = createStore({
  types,
  entities,
  updateMode: "manual",
})

// Multiple events, single update
store.notify("playerMoved", { x: 100, y: 50 })
store.notify("enemyAttacked", { damage: 10 })
store.update() // Subscribers notified once

updateStrategy (optional) ​

Controls how the state is copied before queued events are applied:

  • 'structural-sharing' (default) - Events are applied to a draft proxy, so only the entities that actually changed are copied. Untouched entities keep their previous reference, which makes reference-equality change detection cheap. Best for UI stores.
  • 'copy-on-dispatch' - Each entity is deep-copied the first time an event is dispatched to it, or a system reads it, and events are applied to the copy without a proxy. Entities nothing touches are not copied. Writing to a plain copy costs nothing however often it happens, so it scales better when thousands of entities change every frame, as in a game simulation.
javascript
const store = createStore({
  types,
  entities,
  updateStrategy: "copy-on-dispatch",
})

Under both strategies the current state is left intact while the queued events are applied to a separate draft, and the two are swapped only once every event has been processed. Subscribers are notified only after the swap. As a result, api.getEntity() and api.getEntities() read the previous state during an update, under either strategy.

Caveats for 'copy-on-dispatch':

  • Plain objects, arrays, vectors and primitives are copied directly. Anything else must be structured-cloneable: DOM nodes and class instances with non-cloneable internals throw a DataCloneError. Keep such values in module scope or in a behavior factory closure instead of in the state.
  • Each entity a system reads through its first argument is copied, in case it is written to. A system that only needs to look should read from api.getEntities().
  • The state is not frozen, even in devMode.
  • Writing to an entity obtained from api.getEntity() during an update mutates the previous state. Mutate the entity passed to your handler instead.

An unsupported strategy throws a TypeError when the store is created.

Store Methods ​

notify(eventType, payload) ​

Trigger an event. All entities with a handler for that event will process it.

javascript
// Broadcast to all entities
store.notify("taskCompleted", "task123")

// Targeted notifications
store.notify("#entityId:eventName", payload) // Specific entity
store.notify("typeName:eventName", payload) // Specific type
store.notify("type#id:eventName", payload) // Type + entity

dispatch(action) ​

Redux-compatible dispatch. Use notify() instead (cleaner):

javascript
store.dispatch({ type: "eventName", payload })
// Same as: store.notify("eventName", payload)

subscribe(listener) ​

Redux-compatible subscription:

javascript
const unsubscribe = store.subscribe(() => {
  console.log("State changed!", store.getState())
})

// Later:
unsubscribe()

getState() ​

Get the current state object:

javascript
const state = store.getState()
console.log(state.workTodos.todos)

update() ​

For manual update mode only. Process queued events and notify subscribers:

javascript
const store = createStore({ types, entities, updateMode: "manual" })

store.notify("event1", payload1)
store.notify("event2", payload2)
store.update() // Subscribers notified once

replaceReducer(nextReducer) ​

Redux-compatible method. Not typically used with Inglorious Store. For advanced use cases only.

Event Handler API ​

Event handlers receive three arguments:

javascript
const types = {
  todoList: {
    // Handler with all three arguments
    addTodo(entity, payload, api) {
      // entity: the entity being updated (mutate freely)
      // payload: data passed to notify()
      // api: store methods
    },

    // Handlers can omit unused arguments
    refresh(entity) {
      entity.lastRefresh = Date.now()
    },
  },
}

Handler Arguments ​

entity ​

The entity instance. Mutate freely — immutability is guaranteed:

javascript
toggle(entity, id) {
  const item = entity.items.find((i) => i.id === id)
  if (item) item.done = !item.done // Looks like mutation, is immutable
}

payload ​

The data passed with the event:

javascript
store.notify("addTodo", "Buy milk")
// Handler receives "Buy milk" as payload

addTodo(entity, payload) {
  entity.todos.push({ text: payload, done: false })
}

api ​

Access to store operations:

javascript
async fetchData(entity, userId, api) {
  entity.loading = true

  const data = await fetch(`/api/${userId}`).then((r) => r.json())

  // Trigger another event
  api.notify("dataLoaded", data)

  // Read other entities
  const user = api.getEntity("user")
  const allEntities = api.getEntities()

  // Access type definitions (advanced)
  const typeDef = api.getType("todoList")
  const allTypes = api.getTypes()

  // Change type behavior at runtime (advanced)
  api.setType("counter", { /* new behavior */ })

  // Manual batch control (if updateMode: "manual")
  api.update()
}

API Methods ​

api.getEntities(typeName?) ​

Returns entities from current state (read-only):

javascript
// Full entities map (keyed by id)
const allEntities = api.getEntities()
const workTodos = allEntities.workTodos

// Filter by entity type (array)
const todos = api.getEntities("todo")

api.getEntity(id) ​

Get a specific entity (read-only):

javascript
const user = api.getEntity("user")
const userId = user.id

api.select(selector) ​

Run a selector against the current state:

javascript
const activeFilter = (state) => state.toolbar.activeFilter
const filter = api.select(activeFilter)

api.notify(eventType, payload) ​

Trigger another event (queued with current event):

javascript
async login(entity, credentials, api) {
  const user = await authenticateUser(credentials)
  api.notify("loginSuccess", user)
  api.notify("analytics:logLogin", user)
}

loginSuccess(entity, user) {
  entity.user = user
  entity.loggedIn = true
}

api.dispatch(action) ​

Redux-style dispatch (less common, use notify instead):

javascript
api.dispatch({ type: "customEvent", payload })

api.getTypes() ​

Get all type definitions (read-only):

javascript
const types = api.getTypes()
const todoListType = types.todoList

api.getType(typeName) ​

Get a specific type definition:

javascript
const type = api.getType("todoList")

api.setType(typeName, newType) ​

Change a type's behavior at runtime (advanced):

javascript
store.notify("upgradeType", {
  typeName: "counter",
  behavior: { increment: () => {}, doubleIncrement: () => {} }
})

upgradeType(entity, { typeName, behavior }, api) {
  api.setType(typeName, behavior)
}

api.update() ​

For manual update mode: process events and notify subscribers:

javascript
// This only exists/works if updateMode: "manual"
api.update()

Built-in Events ​

create ​

Triggered when an entity is added via the add event. Visible only to the created entity (not broadcast):

javascript
const types = {
  todoList: {
    create(entity) {
      // Initialize new list
      entity.createdAt = Date.now()
      entity.todos = entity.todos || []
    },
  },
}

store.notify("add", { id: "work", type: "TodoList", todos: [] })
// create handler fires only for "work" entity

destroy ​

Triggered when an entity is removed via the remove event. Visible only to the destroyed entity (not broadcast):

javascript
const types = {
  file: {
    destroy(entity, payload, api) {
      // Cleanup
      console.log(`Destroying file: ${entity.id}`)
      // Save to database, close connections, etc.
    },
  },
}

store.notify("remove", { id: "tempFile" })
// destroy handler fires only for "tempFile" entity

add ​

Built-in event to add a new entity:

javascript
store.notify("add", {
  id: "newList",
  type: "TodoList",
  todos: [],
  priority: "high",
})

// Triggers: create event for newList
// Result: newList entity appears in state

remove ​

Built-in event to remove an entity:

javascript
store.notify("remove", { id: "workTodos" })

// Triggers: destroy event for workTodos
// Result: workTodos entity removed from state

stateChange ​

Broadcast by fsm() when a handler has moved an entity to another state. The payload names the entity, since every entity hears it:

javascript
const types = {
  logger: {
    stateChange(entity, { entityId, from, to }) {
      console.log(`${entityId}: ${from} → ${to}`)
    },
  },
}

fsm(states) ​

Turns a type into a finite state machine. See State Machines.

javascript
import { fsm } from "@inglorious/store/fsm"

const types = {
  door: [
    { knock: (entity) => entity.knocks++ },
    fsm({
      closed: { open: (entity) => (entity.state = "open") },
      open: { close: (entity) => (entity.state = "closed") },
    }),
  ],
}
  • states: each state's handlers, by state name. A state's handler replaces the type's handler for the same event.
  • The entity's state field says which state is in charge, "default" when it has none. Handlers move the entity by assigning it.
  • Returns a behavior (a decorator) to put in a type's array after the behaviors it falls back to.
  • Broadcasts stateChange whenever the state actually changes.

scenes(scenesByState) ​

Puts each state's entities up and takes the previous state's down. See Scenes.

javascript
import { scenes } from "@inglorious/store/scenes"

scenes({
  account: () => [{ id: "accountForm", type: "AccountForm" }],
  billing: (wizard) => [{ id: "billingForm", type: "BillingForm" }],
})
  • scenesByState: for each state, a function of the entity running the machine that returns the entities to have standing, each with an id.
  • Entities only the new state has are added, entities only the old one had are removed, and entities both have are patched with whatever changed.
  • Handles create and the entity's own stateChange, so pair it with fsm().

handleAsync(eventName, handlers, options?) ​

Helper to generate lifecycle events for async operations:

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

const types = {
  todoList: {
    ...handleAsync("fetchTodos", {
      // run receives the payload, not the entity; what it returns goes to success
      async run(userId) {
        const response = await fetch(`/api/users/${userId}/todos`)
        return response.json()
      },
      start(entity) {
        entity.loading = true
      },
      success(entity, todos) {
        entity.todos = todos
        entity.loading = false
      },
      error(entity, error) {
        entity.error = error
        entity.loading = false
      },
    }),
  },
}

store.notify("fetchTodos", userId)

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:

  • fetchTodos - Trigger the async operation
  • fetchTodosStart - Start handler fires (only when start is given)
  • fetchTodosRun - The async function runs
  • fetchTodosSuccess - Success handler fires
  • fetchTodosError - Error handler fires
  • fetchTodosFinally - Always fires (optional)

Every event of one call carries its callId: Start and Run get { payload, callId }, Success { result, callId }, Error { error, callId } and Finally { callId }. The handlers you pass still receive the payload, result or error alone.

TypeScript ​

handleAsync names and types each generated event. With the satisfies shorthand, the handlers get the entity and the app's api from the type, and success gets the result of run:

typescript
export const TodoList = {
  ...handleAsync("fetchTodos", {
    run: (userId: string) => fetchTodos(userId),
    success(entity, todos, api) {
      entity.todos = todos
    },
  }),
} satisfies EntityType<TodoListEntity, AppApi>

TodoList.fetchTodosSuccess // typed; a misspelled event is an error

To declare a type with a type alias, AsyncEvents lists the generated events in one go: the event name, then the entity, the payload, the result and the api:

typescript
import { type AsyncEvents, handleAsync } from "@inglorious/store/async"

type TodoListType = {
  render: (entity: TodoListEntity, api: AppApi) => TemplateResult
} & AsyncEvents<"fetchTodos", TodoListEntity, string, Todo[], AppApi>

optimistic keeps the events' names and types. Explicit type arguments, handleAsync<TodoListEntity, string, Todo[]>(...), still work, but the event names are then plain strings.

Optimistic Updates ​

For optimistic UI state, wrap the generated behavior with optimistic. This helper lives at @inglorious/store/optimistic, so only users who need it import it:

typescript
import { handleAsync } from "@inglorious/store/async"
import { optimistic } from "@inglorious/store/optimistic"

const todoList = {
  ...optimistic(
    handleAsync("saveTodo", {
      async run(payload) {
        const res = await fetch("/api/todos", {
          method: "POST",
          body: JSON.stringify(payload),
        })
        if (!res.ok) throw new Error("Failed")
        return res.json()
      },
      success(entity, todo) {
        entity.todos = entity.todos.map((item) =>
          item.id === todo.tempId ? todo : item,
        )
      },
    }),
    (entity, payload) => ({
      todos: [
        ...entity.todos,
        {
          id: payload.tempId,
          title: payload.title,
          completed: payload.completed,
          pending: true,
        },
      ],
    }),
  ),
}

Each call keeps its own snapshot, so calls can overlap. Pass { rollback(entity, payload, api) } as the third argument to undo a failed call's change yourself, such as removing the temporary item it added to a list, in place of restoring the snapshot.

Options ​

typescript
handleAsync(
  "fetchTodos",
  {/* handlers */},
  {
    scope: "entity", // "entity" | "type" | "global"
    // "entity": #entityId:fetchTodosSuccess
    // "type": todoList:fetchTodosSuccess
    // "global": fetchTodosSuccess
  },
)

compute(fn, selectors) ​

Create memoized derived state:

javascript
import { compute } from "@inglorious/store"

const selectTotal = compute(
  (work, personal) => work.length + personal.length,
  [(state) => state.workTodos.todos, (state) => state.personalTodos.todos],
)

// Works with selectors
const total = useSelector(selectTotal)

createSelector(inputSelectors, resultFn) ​

Redux-compatible selector (alias for compute):

javascript
import { createSelector } from "@inglorious/store"

const selectTotalTodos = createSelector(
  [(state) => state.workTodos.todos, (state) => state.personalTodos.todos],
  (work, personal) => work.length + personal.length,
)

const total = useSelector(selectTotalTodos)

Notify vs Dispatch ​

Both work, but notify() is the preferred API:

javascript
// Preferred: cleaner and more explicit
store.notify("addTodo", text)

// Still works: Redux-compatible
store.dispatch({ type: "addTodo", payload: text })

React Integration ​

Inglorious Store works with react-redux:

javascript
import { Provider, useSelector, useDispatch } from "react-redux"
import { createStore } from "@inglorious/store"

const store = createStore({ types, entities })

function App() {
  return (
    <Provider store={store}>
      <TodoList />
    </Provider>
  )
}

function TodoList() {
  const todos = useSelector((state) => state.workTodos.todos)
  const dispatch = useDispatch()

  return (
    <button onClick={() => dispatch({ type: "addTodo", payload: "..." })}>
      Add
    </button>
  )
}

For a better React experience, use @inglorious/react-store:

javascript
import { createReactStore } from "@inglorious/react-store"

const { Provider, useSelector, useNotify } = createReactStore(store)

function TodoList() {
  const todos = useSelector((state) => state.workTodos.todos)
  const notify = useNotify()

  return <button onClick={() => notify("addTodo", "...")}>Add</button>
}

Type Safety (TypeScript) ​

Describe your entities and state, declare each type's events and payloads with a type alias, and write the type against it: its handlers' parameters are inferred, and createStore checks each type against its entities, with no casts.

typescript
import {
  type Api,
  type BaseEntity,
  createStore,
  type EntitiesState,
} from "@inglorious/store"

interface Todo {
  id: number
  text: string
  done: boolean
}

interface TodoListEntity extends BaseEntity {
  type: "TodoList"
  todos: Todo[]
}

interface TodoState extends EntitiesState<TodoListEntity> {
  workTodos: TodoListEntity
  personalTodos: TodoListEntity
}

type TodoApi = Api<TodoListEntity, TodoState>

// The events and their payloads. Use `type`: an interface does not fit `types`.
type TodoListType = {
  addTodo: (entity: TodoListEntity, text: string) => void
  toggle: (entity: TodoListEntity, id: number) => void
}

const TodoList: TodoListType = {
  addTodo(entity, text) {
    entity.todos.push({ id: Date.now(), text, done: false })
  },
  toggle(entity, id) {
    const todo = entity.todos.find((todo) => todo.id === id)
    if (todo) todo.done = !todo.done
  },
}

const store = createStore<TodoListEntity, TodoState>({
  types: { TodoList },
  entities: {
    workTodos: { type: "TodoList", todos: [] },
    personalTodos: { type: "TodoList", todos: [] },
  },
})
  • A misspelled or missing handler is an error, and so is a wrong payload: TodoList.addTodo(todos, 42).
  • As a shorthand, write const TodoList = { ... } satisfies EntityType<TodoListEntity, TodoApi>: it infers the entity and the api, but neither lists the events nor types payloads you do not annotate.
  • Every entity field is declared: BaseEntity holds only type, id and updatesWhilePaused, so a misspelled field is an error.
  • api.getEntity("workTodos") gives a TodoListEntity; an unknown id gives the union.
  • A name no entity has, such as a page, takes any type. A type can be an array of behaviors: Live: [Chart, withRealtime].
  • An entity drawn by a library type extends its entity interface: interface AppRouterEntity extends RouterEntity { type: "Router" }.
  • trigger(entity, TodoList.addTodo, "Write docs") from @inglorious/store/test checks the payload and returns the updated entity.
  • Api<TodoListEntity, TodoState, EventsOf<TodoListType>> checks what notify sends: the event's name, after any target (`#${entity.id}:addTodo`), and its payload. The store's own events are always allowed. createStore takes the same third type argument.
  • defineType<TodoListEntity, TodoApi>()({ ... }) checks each member by its name, so a template's sub-renderers can take props: render(props, api) takes the entity as its props, a sub-renderer renderX(props, ownProps, api) keeps the types it declares, and the rest are handlers.

RTK Migration Utilities ​

Inglorious Store provides utilities to help migrate from Redux Toolkit:

convertAsyncThunk(eventName, asyncFn, options?) ​

typescript
import { convertAsyncThunk } from "@inglorious/store/migration/rtk"

const handlers = convertAsyncThunk("fetchTodos", fetchTodosAsync, {
  onPending: (entity) => (entity.loading = true),
  onError: (entity, error) => (entity.error = error),
  onSuccess: (entity, data) => (entity.todos = data),
})

const types = {
  todoList: { ...handlers },
}

convertSlice(slice, options?) ​

typescript
import { convertSlice } from "@inglorious/store/migration/rtk"

const todoListType = convertSlice(todosSlice, {
  asyncThunks: {
    fetchTodos: fetchTodosAsync,
  },
})

const types = {
  todoList: todoListType,
}

createRTKCompatDispatch(api, entityId) ​

typescript
import { createRTKCompatDispatch } from "@inglorious/store/migration/rtk"

const dispatch = createRTKCompatDispatch(api, "todos")
dispatch({ type: "todos/addTodo", payload: "Buy milk" })

For more details, see Migrating from Redux & RTK.

Released under the MIT License.