— The stack

Expo Speech cancellation: keep old callbacks from changing a new utterance

Guard speech callbacks with utterance identity so stopping cue A cannot clear cue B, with an executed synthetic callback-race trace.

4 min readUpdated 2026-11-05By AptixLabs studio
Start A: Generation 1; Replace with B: Generation 2 owns state; Late A callback: Ignore obsolete generation; B completes: Only current callback finishesAptixLabs · 2026-11-05

Original explanatory diagram using synthetic or proposed states; not a product screenshot. View full-size diagram

Associate every speech callback with the utterance generation that created it. When replacing a cue, invalidate the old generation before stopping playback, then allow only the current generation to update visible state. A delayed completion or stop callback from cue A must not mark cue B finished.

Prerequisites are one owner for the current cue, a cancellation policy and an unmount cleanup path. This guide addresses callback ownership. Choosing audio mixing and proving voice availability are separate integration tasks, even though they share the speech module.

Model one cue from request to terminal state

Expo documents stop as interrupting current speech and clearing the queue, with callbacks for completion, errors and stopping. That API surface still leaves the application responsible for deciding which cue a callback may affect. A boolean called isSpeaking contains no identity by itself.

Expo: speech stop and lifecycle callbacks

The reviewed UltraFit360 hook stops speech before starting a new cue and sets speaking false in several callbacks. This is useful source context for replacement behavior, not proof that a delayed callback cannot race a newer cue. Review ordering at the application boundary rather than assuming stop makes old callbacks disappear synchronously.

Give the callback a generation guard

const myGeneration = ++generation;
await stopPreviousSpeech();
if (myGeneration !== generation || unmounted) return;
startSpeech(text, {
  onDone: () => finishIfCurrent(myGeneration),
  onStopped: () => finishIfCurrent(myGeneration),
  onError: () => finishIfCurrent(myGeneration),
});

This is proposed pseudocode, not a drop-in Expo wrapper. Serialize replacements or guard after each awaited step so two rapid start requests cannot both begin after the stop promise settles. Mark the old generation obsolete before invoking stop, because its callback can otherwise still own the visible state during replacement.

Executed isolated callback ownership fixture
EventCurrent visible cueState
Start A, then start BBspeaking
Delayed done callback from ABspeaking
Done callback from BBdone

The local fixture deliberately invokes callbacks in the troublesome order and produces the states shown. It tests generation comparison in a pure function model. It does not invoke Expo Speech, simulate native event timing or establish that a device delivered those callbacks in this sequence.

Separate intent from terminal cleanup

Keep stopped-by-user, replaced, failed and naturally-completed outcomes if the interface needs to explain them. All can clean up resources, but only the appropriate ones should offer replay or advance a workflow. An old cue should never trigger a next-exercise action merely because its terminal callback arrived late.

On unmount, invalidate the generation before stopping speech and avoid updating disposed screen state. If a global speech owner outlives the screen, transfer ownership explicitly rather than creating several local hooks that all call the same global stop function. Account changes should also clear personalized queued text under the intended policy.

Test rapid replacement and real native callbacks

Acceptance should include A replaced by B, B replaced by C while stop is pending, unmount during preparation, error after replacement and repeated stop taps. Record current generation, callback generation and terminal reason in synthetic diagnostics. Do not log private spoken content to prove the ordering.

After the pure fixture passes, run the same user journeys on supported physical devices and the installed SDK version. Confirm both audible output and visible state. A race fix is complete only when an obsolete cue cannot speak, finish a new cue or trigger a workflow side effect. The article’s evidence remains the isolated guard trace until those native tests are retained.

Guard intermediate events as well as terminal callbacks. An obsolete onStart event can turn a stopped screen back into a speaking state, and an old boundary event can highlight text belonging to the next cue. Apply the same ownership check wherever callbacks update visible progress. A useful fixture sends stale start, boundary and done events after replacement, then verifies that only the current generation can affect playback labels, highlighted text or any next-step control.

Elevenlabs voice coaching

React native 60fps

Bring the supported devices, installed SDK versions and a reproducible lifecycle or UI failure to scope a mobile engineering assessment.

Explore react native development

How can stale Expo Speech callbacks be kept from finishing a new utterance?

Associate callbacks with a playback generation, invalidate the old generation before stopping it and ignore callbacks that no longer own the current cue.

Is calling stop before speak sufficient to prevent callback races?

Not by itself. Preserve utterance ownership and guard asynchronous callbacks and awaited replacement steps.

Need help with react native app development?

Tell us about your product, existing stack and the problem you need to solve. We can discuss integration scope, implementation tradeoffs and a practical first milestone.

Start a conversation