Skip to content

Intent Cancellation

Long-running operations need a way to stop. A user might click “Cancel”, navigate away, or submit a new request that makes the previous one irrelevant. Hurum provides three cancellation mechanisms, all built on AbortSignal.

Every executor receives a signal in its context. This is a standard AbortSignal that gets aborted when the intent is cancelled:

const [SearchCommand, SearchExecutor] = CommandExecutor<
{ query: string },
{ searchApi: SearchApi }
>(async (command, { deps, emit, signal }) => {
emit(SearchEvent.searchStarted({ query: command.query }))
if (signal.aborted) return
const results = await deps.searchApi.search(command.query, { signal })
if (signal.aborted) return
emit(SearchEvent.searchCompleted({ results }))
})

Check signal.aborted before and after every await. You can also pass signal directly to APIs that support AbortSignal (like fetch), so the underlying network request gets cancelled too.

1. cancel(ref) — Cancel a specific intent

Section titled “1. cancel(ref) — Cancel a specific intent”

store.send() returns an IntentRef. Pass it to store.cancel() to abort that specific execution:

// Start the operation
const ref = store.send.searchStarted({ query: 'shoes' })
// Later, cancel just this one
store.cancel(ref)

This is useful when you have multiple concurrent operations and want to cancel one without affecting others.

store.cancelAll() aborts every running executor:

store.cancelAll()

Use this when the user navigates away or when you need to reset the entire store’s async state.

When you send a new intent, any previous execution of that same intent is not automatically cancelled. Each send() creates an independent execution. If you want “only the latest request wins” behavior, cancel the previous one explicitly:

let lastSearchRef: IntentRef | null = null
function handleSearchInput(query: string) {
if (lastSearchRef) store.cancel(lastSearchRef)
lastSearchRef = store.send.searchStarted({ query })
}

Here is a complete example: a form with a save button and a cancel button.

import { Events, Event, CommandExecutor, Intents, Intent, Store } from '@hurum/core'
const PurchaseEvent = Events('Purchase', {
saveRequested: Event<{ id: string }>(),
saved: Event<{ purchase: Purchase }>(),
saveFailed: Event<{ error: SaveError }>(),
cancelled: Event(),
})
const [SavePurchaseCommand, SavePurchaseExecutor] = 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 })),
)
})
const [CancelCommand, CancelExecutor] = CommandExecutor<
{}
>((command, { emit }) => {
emit(PurchaseEvent.cancelled(command))
})
const PurchaseIntents = Intents('Purchase', {
submitButtonClicked: Intent(SavePurchaseCommand),
cancelClicked: Intent(CancelCommand),
})
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,
}),
cancelled: (state) => ({
...state,
saving: false,
error: null,
}),
})
.intents(PurchaseIntents)
.executors(SavePurchaseExecutor, CancelExecutor)
.deps<{ purchaseRepository: PurchaseRepository }>()
import { useStore } from '@hurum/react'
function PurchaseForm({ purchase }: { purchase: Purchase }) {
const store = useStore(PurchaseStore)
const saving = store.use.saving()
const handleSubmit = () => {
store.send.submitButtonClicked({ purchase })
}
const handleCancel = () => {
store.cancelAll()
store.send.cancelClicked({})
}
return (
<div>
<button onClick={handleSubmit} disabled={saving}>
{saving ? 'Saving...' : 'Submit'}
</button>
{saving && (
<button onClick={handleCancel}>Cancel</button>
)}
</div>
)
}

The cancel handler does two things:

  1. store.cancelAll() aborts the running save executor’s signal
  2. store.send.cancelClicked({}) sends a cancel intent that resets the state

This two-step approach is intentional. cancelAll() only aborts signals — it does not change state. You need to send a separate intent to update the state, because state changes must always flow through events.

If your dependency uses fetch or any API that accepts AbortSignal, pass it through:

const [FetchCommand, FetchExecutor] = CommandExecutor<
{ url: string },
{ httpClient: { get(url: string, options: { signal: AbortSignal }): Promise<Response> } }
>(async (command, { deps, emit, signal }) => {
emit(FetchEvent.started({ url: command.url }))
try {
const response = await deps.httpClient.get(command.url, { signal })
if (signal.aborted) return
const data = await response.json()
emit(FetchEvent.completed({ data }))
} catch (error) {
if (signal.aborted) return
emit(FetchEvent.failed({ error: (error as Error).message }))
}
})

When the signal is aborted, fetch throws an AbortError. The catch block checks signal.aborted and returns silently, so no failure event is emitted for cancellations.

  • Always check signal.aborted in async executors. Check before and after every await point.
  • cancelAll() aborts signals but does not change state. Send a separate cancel intent to reset your state.
  • cancel(ref) targets a specific execution. Use the IntentRef returned by store.send().
  • Pass signal to underlying APIs. This cancels the actual network request, not just the handler.
  • Cancellation is silent. After signal.aborted is true, the executor should return without emitting any more events. The store state remains at whatever point it was before the cancellation.