Skip to content

If you are coming from Redux, Zustand, MobX, or a custom Flux architecture, this guide maps the concepts you already know to Hurum’s equivalents and walks through a practical migration strategy.

Your current architectureHurum equivalentNotes
Action / ActionTypeIntentIntents declare what the user wants to do, not what should happen.
Action CreatorIntent + CommandIntent(ValidateCommand, SaveCommand) bundles the commands to run.
ReducerStore.onPure state transitions keyed by event type.
Thunk / Saga / EpicCommandExecutorAll async logic and side effects live here.
Selector / Derived stateComputed.computed({ key: (state) => value }). Recalculated eagerly on state change.
MiddlewareMiddlewareonEvent, onStateChange, onIntentStart, onIntentEnd, onError hooks.
Store (Redux)StoreContains state, reducers, computed, intents, executors, and deps. Everything in one definition.
dispatch()store.send()store.send.intentName(payload) or store.send(PreparedIntent).
useSelector()store.use.key()Per-field hook. Only re-renders when that field changes.
Provider (Redux)Store.ProviderUsed for SSR and scoped instances. Global singleton does not need a Provider.

In Redux-style architectures, an action might trigger a reducer, a middleware, a saga, and a selector all at once. Side effects can happen in thunks, middleware, or sagas — there are multiple paths.

In Hurum, there is one path: Intent -> Command -> Executor -> Event -> Store.on -> Computed. Every state change, sync or async, follows this exact pipeline.

Do not rewrite everything at once. Migrate one domain process at a time.

Start by wrapping your existing UseCase, service, or thunk logic inside a CommandExecutor. This lets you adopt Hurum’s architecture without rewriting your business logic.

import { CommandExecutor, Events, Event } from '@hurum/core'
// Your existing UseCase class stays the same
interface SavePurchaseUseCase {
execute(purchase: Purchase): Promise<Result<Purchase, SaveError>>
}
const PurchaseEvent = Events('Purchase', {
saveRequested: Event<{ id: string }>(),
saved: Event<{ purchase: Purchase }>(),
saveFailed: Event<{ error: SaveError }>(),
})
// Phase 1: Executor wraps the existing UseCase
const [SaveCommand, SaveExecutor] = CommandExecutor<
{ purchase: Purchase },
{ saveUseCase: SavePurchaseUseCase }
>(async (command, { deps, emit, signal }) => {
emit(PurchaseEvent.saveRequested({ id: command.purchase.id }))
if (signal.aborted) return
const result = await deps.saveUseCase.execute(command.purchase)
if (signal.aborted) return
result.match(
(saved) => emit(PurchaseEvent.saved({ purchase: saved })),
(error) => emit(PurchaseEvent.saveFailed({ error })),
)
})

Your UseCase still does the actual work. The executor just bridges it into Hurum’s event-driven flow.

Once the store is working and tested, replace the UseCase wrapper with direct dependency calls:

// Phase 2: Executor calls the repository directly
const [SaveCommand, SaveExecutor] = CommandExecutor<
{ purchase: Purchase },
{ purchaseRepository: PurchaseRepository }
>(async (command, { deps, emit, signal }) => {
emit(PurchaseEvent.saveRequested({ id: command.purchase.id }))
if (signal.aborted) return
const result = await deps.purchaseRepository.save(command.purchase)
if (signal.aborted) return
result.match(
(saved) => emit(PurchaseEvent.saved({ purchase: saved })),
(error) => emit(PurchaseEvent.saveFailed({ error })),
)
})

The executor function is the same shape. You just changed what goes into deps. The store definition, intents, events, and React components stay untouched.

Let’s walk through migrating a “save purchase” flow from a Redux-style architecture.

Pick one user action that triggers state changes and side effects. For example: “User clicks Save on a purchase form.”

In your current code, this might involve:

  • A SAVE_PURCHASE_REQUEST action
  • A thunk or saga that calls the API
  • A SAVE_PURCHASE_SUCCESS / SAVE_PURCHASE_FAILURE action
  • Reducer cases for each action
  • Selectors for isSaving and saveError

Map your existing actions to events. Events are past tense facts:

const PurchaseEvent = Events('Purchase', {
saveRequested: Event<{ id: string }>(), // was SAVE_PURCHASE_REQUEST
saved: Event<{ purchase: Purchase }>(), // was SAVE_PURCHASE_SUCCESS
saveFailed: Event<{ error: SaveError }>(), // was SAVE_PURCHASE_FAILURE
})

Take your thunk/saga and put it inside an executor. Inject the same dependencies:

const [SaveCommand, SaveExecutor] = CommandExecutor<
{ purchase: Purchase },
{ saveUseCase: SavePurchaseUseCase } // Same dependency as your thunk
>(async (command, { deps, emit, signal }) => {
emit(PurchaseEvent.saveRequested({ id: command.purchase.id }))
if (signal.aborted) return
const result = await deps.saveUseCase.execute(command.purchase)
if (signal.aborted) return
result.match(
(saved) => emit(PurchaseEvent.saved({ purchase: saved })),
(error) => emit(PurchaseEvent.saveFailed({ error })),
)
})

Translate your reducer cases into on handlers:

const PurchaseIntents = Intents('Purchase', {
saveClicked: Intent(SaveCommand),
})
const PurchaseStore = Store({
state: {
purchase: null as Purchase | null,
saving: false,
error: null as SaveError | null,
},
})
.on(PurchaseEvent, {
saveRequested: (state) => ({
...state,
saving: true,
error: null,
}),
saved: (state, { purchase }) => ({
...state,
purchase,
saving: false,
}),
saveFailed: (state, { error }) => ({
...state,
saving: false,
error,
}),
})
.intents(PurchaseIntents)
.executors(SaveExecutor)
.deps<{ saveUseCase: SavePurchaseUseCase }>()

Swap out useSelector / useDispatch for Hurum’s hooks:

// Before (Redux-style)
function SaveButton() {
const dispatch = useDispatch()
const saving = useSelector(selectIsSaving)
const error = useSelector(selectSaveError)
return (
<button onClick={() => dispatch(savePurchase(purchase))} disabled={saving}>
Save
</button>
)
}
// After (Hurum)
function SaveButton() {
const store = useStore(PurchaseStore)
const saving = store.use.saving()
const error = store.use.error()
return (
<button onClick={() => store.send.saveClicked({ purchase })} disabled={saving}>
Save
</button>
)
}

In Redux, you might have dozens of action types. In Hurum, intents represent user actions (what the user clicked, what page they opened), not internal operations. Many Redux actions map to events, not intents.

// Redux had separate actions for:
// SAVE_PURCHASE_REQUEST, SAVE_PURCHASE_SUCCESS, SAVE_PURCHASE_FAILURE
// Hurum has one intent (what the user did):
const PurchaseIntents = Intents('Purchase', {
saveClicked: Intent(SaveCommand),
})
// And three events (what happened as a result):
const PurchaseEvent = Events('Purchase', {
saveRequested: Event<{ id: string }>(),
saved: Event<{ purchase: Purchase }>(),
saveFailed: Event<{ error: SaveError }>(),
})

It might be tempting to call setState directly from an executor. Resist. Always emit events, even for simple operations. This is what makes Hurum stores predictable and testable.

on handlers should be pure state transitions. If you find yourself making API calls or computing complex derived data in an on handler, move that logic to an executor or a computed value.

For each domain process you migrate:

  • Identify the user actions and side effects
  • Define events (past tense facts: requested, succeeded, failed)
  • Create executors (wrap existing logic first, refactor later)
  • Define intents (one per user action)
  • Build the store (state + on handlers + computed + executors + deps)
  • Write tests using TestStore
  • Replace component hooks (useSelector -> store.use.key(), dispatch -> store.send)
  • Remove old Redux/Flux code for this process