API Reference
createStore(options)
Creates a new Inglorious Store instance. Returns a Redux-compatible store.
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:
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:
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:
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:
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:
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'- Callapi.update()to batch updates
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 onceupdateStrategy (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.
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.
// 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 + entitydispatch(action)
Redux-compatible dispatch. Use notify() instead (cleaner):
store.dispatch({ type: "eventName", payload })
// Same as: store.notify("eventName", payload)subscribe(listener)
Redux-compatible subscription:
const unsubscribe = store.subscribe(() => {
console.log("State changed!", store.getState())
})
// Later:
unsubscribe()getState()
Get the current state object:
const state = store.getState()
console.log(state.workTodos.todos)update()
For manual update mode only. Process queued events and notify subscribers:
const store = createStore({ types, entities, updateMode: "manual" })
store.notify("event1", payload1)
store.notify("event2", payload2)
store.update() // Subscribers notified oncereplaceReducer(nextReducer)
Redux-compatible method. Not typically used with Inglorious Store. For advanced use cases only.
Event Handler API
Event handlers receive three arguments:
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:
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:
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:
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):
// 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):
const user = api.getEntity("user")
const userId = user.idapi.select(selector)
Run a selector against the current state:
const activeFilter = (state) => state.toolbar.activeFilter
const filter = api.select(activeFilter)api.notify(eventType, payload)
Trigger another event (queued with current event):
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):
api.dispatch({ type: "customEvent", payload })api.getTypes()
Get all type definitions (read-only):
const types = api.getTypes()
const todoListType = types.todoListapi.getType(typeName)
Get a specific type definition:
const type = api.getType("todoList")api.setType(typeName, newType)
Change a type's behavior at runtime (advanced):
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:
// 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):
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" entitydestroy
Triggered when an entity is removed via the remove event. Visible only to the destroyed entity (not broadcast):
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" entityadd
Built-in event to add a new entity:
store.notify("add", {
id: "newList",
type: "TodoList",
todos: [],
priority: "high",
})
// Triggers: create event for newList
// Result: newList entity appears in stateremove
Built-in event to remove an entity:
store.notify("remove", { id: "workTodos" })
// Triggers: destroy event for workTodos
// Result: workTodos entity removed from statestateChange
Broadcast by fsm() when a handler has moved an entity to another state. The payload names the entity, since every entity hears it:
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.
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
statefield 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
stateChangewhenever the state actually changes.
scenes(scenesByState)
Puts each state's entities up and takes the previous state's down. See Scenes.
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 anid.- 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
createand the entity's ownstateChange, so pair it withfsm().
handleAsync(eventName, handlers, options?)
Helper to generate lifecycle events for async operations:
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:
startwrites the headline and sets the scenerungathers the reportingsuccesspublishes the storyerrorprints the correctionfinallyarchives the notes
Generated events:
fetchTodos- Trigger the async operationfetchTodosStart- Start handler fires (only whenstartis given)fetchTodosRun- The async function runsfetchTodosSuccess- Success handler firesfetchTodosError- Error handler firesfetchTodosFinally- 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:
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 errorTo 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:
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:
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
handleAsync(
"fetchTodos",
{/* handlers */},
{
scope: "entity", // "entity" | "type" | "global"
// "entity": #entityId:fetchTodosSuccess
// "type": todoList:fetchTodosSuccess
// "global": fetchTodosSuccess
},
)compute(fn, selectors)
Create memoized derived state:
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):
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:
// 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:
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:
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.
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:
BaseEntityholds onlytype,idandupdatesWhilePaused, so a misspelled field is an error. api.getEntity("workTodos")gives aTodoListEntity; 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/testchecks the payload and returns the updated entity.Api<TodoListEntity, TodoState, EventsOf<TodoListType>>checks whatnotifysends: the event's name, after any target (`#${entity.id}:addTodo`), and its payload. The store's own events are always allowed.createStoretakes 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-rendererrenderX(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?)
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?)
import { convertSlice } from "@inglorious/store/migration/rtk"
const todoListType = convertSlice(todosSlice, {
asyncThunks: {
fetchTodos: fetchTodosAsync,
},
})
const types = {
todoList: todoListType,
}createRTKCompatDispatch(api, entityId)
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.
