Quick Start
Let’s build a counter. It is the simplest possible example, but it goes through the full Hurum pipeline — the same pipeline a complex async checkout flow would use.
The counter, step by step
Section titled “The counter, step by step”-
Define Events — what can happen
Events record things that already happened — think past tense. Not “increment the counter” but “the counter was incremented.” This distinction matters: events are facts, not commands. A counter can be incremented, decremented, or reset.
counter/events.ts import { Events, Event } from '@hurum/core'export const CounterEvent = Events('Counter', {incremented: Event<{ amount: number }>(),decremented: Event<{ amount: number }>(),reset: Event<{}>(),})Events('Counter', { ... })creates a namespaced group. Each event has a typed payload —incrementedcarries{ amount: number }, whileresetcarries nothing. -
Define CommandExecutors — how things happen
A CommandExecutor is where side effects live — API calls, localStorage, timers, anything that talks to the outside world. Each
CommandExecutorcall returns a[Command, Executor]tuple: the Command is a reference token used by Intents, and the Executor is the function that does the work. For a counter there are no real side effects, but the executor still shows you exactly where they would go.counter/executors.ts import { CommandExecutor } from '@hurum/core'import { CounterEvent } from './events'export const [IncrementCommand, IncrementExecutor] = CommandExecutor<{ amount: number }>((command, { emit }) => {emit(CounterEvent.incremented(command))})export const [DecrementCommand, DecrementExecutor] = CommandExecutor<{ amount: number }>((command, { emit }) => {emit(CounterEvent.decremented(command))})export const [ResetCommand, ResetExecutor] = CommandExecutor<{}>((command, { emit }) => {emit(CounterEvent.reset(command))})Each
CommandExecutorcall returns a[Command, Executor]tuple. The executor receives the command payload and a context object — here we only useemit, but real executors also getdeps,getState,signal, andscope. -
Define Intents — what the user wants to do
An Intent is a declarative mapping: “when the user does X, run commands Y and Z.” Intents describe what the user did (clicked a button, submitted a form), not what the system should do. This naming convention makes your code self-documenting.
counter/intents.ts import { Intents, Intent } from '@hurum/core'import { IncrementCommand, DecrementCommand, ResetCommand } from './executors'export const CounterIntents = Intents('Counter', {plusButtonClicked: Intent(IncrementCommand),minusButtonClicked: Intent(DecrementCommand),resetButtonClicked: Intent(ResetCommand),})Notice the naming:
plusButtonClicked, notincrement. Intents describe what the user did, not what the system should do. This distinction matters when one user action triggers multiple commands. -
Define the Store — state + transitions + everything
The Store is the central unit. It wires together initial state,
.on()handlers (pure functions that react to events),.computed()values (derived state), and the intents + executors you defined above.counter/store.ts import { Store } from '@hurum/core'import { CounterEvent } from './events'import { CounterIntents } from './intents'import { IncrementExecutor, DecrementExecutor, ResetExecutor } from './executors'export const CounterStore = Store({ state: { count: 0, multiplier: 2 } }).on(CounterEvent, {incremented: (state, { amount }) => ({...state,count: state.count + amount,}),decremented: (state, { amount }) => ({...state,count: state.count - amount,}),reset: (state) => ({...state,count: 0,}),}).computed({doubled: (state) => state.count * 2,product: (state) => state.count * state.multiplier,}).intents(CounterIntents).executors(IncrementExecutor, DecrementExecutor, ResetExecutor).on()handlers are pure functions:(state, payload) => newState. No side effects..computed()values are derived from state and recalculate eagerly when their dependencies change. -
Use in React — read state and send intents
Import
useStorefrom@hurum/react, then usestore.use.*hooks in your components.counter/Counter.tsx import { useStore } from '@hurum/react'import { CounterStore } from './store'import { CounterIntents } from './intents'function Counter() {const store = useStore(CounterStore)const count = store.use.count()const doubled = store.use.doubled()const product = store.use.product()return (<div><p>Count: {count}</p><p>Doubled: {doubled}</p><p>Product: {product}</p><button onClick={() => store.send.plusButtonClicked({ amount: 1 })}>+1</button><button onClick={() => store.send.minusButtonClicked({ amount: 1 })}>-1</button><button onClick={() => store.send.resetButtonClicked({})}>Reset</button></div>)}store.use.count()subscribes to just thecountfield — ifmultiplierchanges butcountdoesn’t, this component won’t re-render. Eachuse.*hook is granular by default.store.send.plusButtonClicked({ amount: 1 })is a shorthand forstore.send(CounterIntents.plusButtonClicked({ amount: 1 })). Both work — use whichever you prefer. -
Provider for scoped instances (optional)
By default,
useStore(CounterStore)outside a provider operates on a global singleton. If you need multiple independent instances (e.g., two counters on the same page), use aStoreProvider:counter/ScopedCounter.tsx import { useStore, StoreProvider } from '@hurum/react'import { CounterStore } from './store'function ScopedCounter() {const store = useStore(CounterStore)const count = store.use.count()return (<div><p>Count: {count}</p><button onClick={() => store.send.plusButtonClicked({ amount: 1 })}>+1</button></div>)}// Each StoreProvider gets its own independent store instancefunction App() {const storeA = CounterStore.create()const storeB = CounterStore.create()return (<><StoreProvider of={CounterStore} store={storeA}><ScopedCounter /></StoreProvider><StoreProvider of={CounterStore} store={storeB}><ScopedCounter /></StoreProvider></>)}Inside a
StoreProvider,useStore(CounterStore)returns the scoped instance. Outside a provider, it falls back to the global singleton.
What just happened?
Section titled “What just happened?”When you clicked “+1”, here is the exact sequence that ran:
1. onClick → store.send.plusButtonClicked({ amount: 1 })2. Intent maps to IncrementCommand → Store matches IncrementExecutor3. IncrementExecutor runs → emit(CounterEvent.incremented({ amount: 1 }))4. Store.on[CounterEvent.incremented.type] runs → state becomes { count: 1, multiplier: 2 }5. Computed recalculates: doubled = 2, product = 26. React subscribers notified → component re-rendersThis is the same path every state change takes. For async operations, the only difference is what happens inside the executor — the rest of the pipeline is identical.
Next steps
Section titled “Next steps”Now that you have seen the pipeline in action, learn how each piece works in detail:
- Data Flow — Deep dive into every step of the pipeline
- Event — How events work and why they are facts
- CommandExecutor — The side-effect boundary
- Store — State, transitions, computed, and the builder API