- Published on
Telling "Who Did It" Apart in an Event Stream: Why a Single Boolean Flag Is Doomed Against Multi-Frame Animation
- Authors

- Name
- Jack Qin
A lot of interaction logic has to answer the same question: "this state change — did the user do it, or did the system do it itself?" A map's "follow mode" is a clean sample: the map automatically tracks a target as it moves, but the moment the user drags manually it should drop out of follow. The hard part isn't the following, it's the discrimination — react-native-maps's onRegionChange fires identically for a user drag and a programmatic animateCamera, and the event payload carries no flag telling you "this is the user, not me."
This post isn't about how to write the following; it's about dissecting a more general modeling error: when the discrimination signal itself is missing, developers instinctively reach for a boolean flag to "mark" the programmatic operation; and that boolean is doomed, because it makes a wrong assumption about the operation's temporal structure — it assumes "one programmatic operation = one event." Understanding why that assumption inevitably breaks in the reality of multi-frame animation is more transferable than memorizing the "use a time window" fix.
The symptom: follow drops out on its own the moment it starts
After follow mode turns on, the camera moves to the target and then immediately drops out of follow on its own. It reproduces 100% on a release build but sometimes hides on a slow dev build — that "always reproduces in the fast environment, occasionally dodges in the slow one" trait is itself a clue, and below explains why it points at frame count.
The most natural thing to write: set a boolean ref before the animation, clear it in the next onRegionChange.
// Wrong —— single-event flag, only suppresses the first frame of a multi-frame animation.
const suppressNextRef = useRef(false);
function animateToDriver(coord: Coord): void {
suppressNextRef.current = true; // ← only suppresses the first frame
mapRef.current?.animateCamera({ center: coord, ... }, { duration: 300 });
}
function handleRegionChange(): void {
if (suppressNextRef.current) {
suppressNextRef.current = false; // ← cleared after the first frame
return;
}
onUserPan(); // frames 2..N are treated as a user drag → bug
}
Mechanism 1: The discrimination signal simply doesn't exist
First, recognize why this problem has no "happy path." onRegionChange / onRegionChangeComplete fire isomorphically for a user gesture and a programmatic animateCamera / animateToRegion — the event object has no origin field. onPanDrag looks like it could fill the gap, but it exists on iOS and not on Android, and even on iOS it misses continuous gestures like "keep dragging after a pinch zoom."
In other words, the platform does not hand you a reliable "is it the user" signal. Since the signal is missing, you can only manufacture one yourself: proactively record some state before the programmatic trigger, and use it as a back-reference when the event arrives. The question isn't "should I record state myself," it's "what shape should I record it in."
Mechanism 2: One animation emits multiple events — the boolean's fatal assumption
The boolean flag's implicit assumption is: one animateCamera call corresponds to one onRegionChange, so "suppress the next event" is enough. This assumption is wrong.
A single animateCamera call emits multiple onRegionChange events — one per frame on iOS, batched a few at a time on Android. A 300ms animation at 60fps is a dozen-plus events. The boolean flag is cleared only on the first event, and the remaining dozen-plus frames all slip through, getting registered by onRegionChange as a user drag — so every programmatic re-center silently breaks follow.
This also explains the opening "always reproduces in fast, occasionally dodges in slow" trait: the number of events an animation emits correlates with frame rate. A release build has a high frame rate, every animation fires a long string of events, and a slipped-through frame almost certainly hits the window after the flag is cleared; a slow dev build occasionally fires only one event for an animation, which the single flag happens to cover completely, so the bug goes invisible. The better the performance, the more reliably the bug reproduces — a direct symptom of the boolean's wrong assumption about the events' temporal structure.
The root of the error: a programmatic move is not a point (a single event) but an interval (a string of events). Using a boolean that can only cover one point to cover an interval inevitably leaks.
The fix: model the programmatic move as a time window
The right abstraction matches its real temporal structure — treat the programmatic move as covering a time window. Any internal call that drives animate* pushes the window's end timestamp forward; before onRegionChange classifies an event as "user-initiated," it first checks whether we're still inside the window.
// MapScreen.tsx (or wherever you hold the MapView ref)
const programmaticMoveUntilRef = useRef<number>(0);
// The window must be longer than the longest internal animation that may fire.
// useAnimateToSite defaults to 600ms; add margin for settle frames.
const PROGRAMMATIC_MOVE_SUPPRESS_MS = 800;
/** Call this before any internal animate*() call on the MapView. */
function markProgrammaticMove(): void {
programmaticMoveUntilRef.current = Date.now() + PROGRAMMATIC_MOVE_SUPPRESS_MS;
}
function handleRegionChange(): void {
if (Date.now() < programmaticMoveUntilRef.current) return; // programmatic
onUserPan?.(); // user gesture
}
function animateToDriver(c: Coord): void {
markProgrammaticMove();
mapRef.current?.animateCamera({ center: c, ... }, { duration: 300 });
}
The window model is naturally robust to multi-frame animation: all frames of one animation fall inside the same window, whether it emits 1 event or 50. It's also robust to overlapping internal moves — a later markProgrammaticMove() call simply extends the window's end, instead of fighting each other the way two boolean flags would.
Side-effect-triggered animations must bump the window too
Some camera moves aren't triggered by the user touching the map (switching sites, a deep-link route change, programmatic zoom from a sheet). These animations also fire a string of onRegionChange events and likewise need to bump the window, otherwise the frames they produce get misjudged as a user drag:
// Switching sites re-aims the camera via a downstream hook (useAnimateToSite).
// Bump the window so the frames it produces aren't treated as a user drag and silently break follow.
useEffect(() => {
if (siteId) markProgrammaticMove()
}, [siteId])
| Trigger | Required call before the animation |
|---|---|
mapRef.animateCamera({ ... }) for a follow re-center | markProgrammaticMove() |
mapRef.animateToRegion(...) for zoom-in/out buttons | markProgrammaticMove() |
Site-switch animation (useAnimateToSite and the like) | markProgrammaticMove() |
| User drag / pinch / two-finger rotate | None — onRegionChange must fire onUserPan |
Window length: an explicit knob priced to the slowest device
The time window turns a hidden hazard from "code structure" into "a numeric parameter" — both an advantage and its boundary. The window must be at least as long as the longest internal animation a screen can fire. At the time of writing, useAnimateToSite defaults to a 600ms animation, and 800ms gives a 200ms settle buffer.
Its failure mode is explicit and reasoned-about:
| Scenario | Expected behavior |
|---|---|
| Following, user single-finger drag | After the suppress window expires, onRegionChange fires → onUserPan() → drop out of follow |
| Following, user pinch zoom | Same as above — drop out |
App calls animateCamera for a follow tick | All frames within the 800ms window classified as programmatic → follow holds |
Site change triggers useAnimateToSite (while following) | Programmatic — follow holds (switching sites shouldn't lose follow) |
| Animation exceeds 800ms on a slow device | Last few frames treated as a user gesture → follow drops out prematurely. Mitigation: price the window to the slowest real device, or bump per-frame if you can hook the animation callback |
Note the last row — undersizing the window silently breaks follow, and adding a longer animation means you must enlarge the window accordingly. This is the time-window approach's honest boundary: it swaps "an inevitably failing boolean" for "a threshold that must be calibrated to real devices" — higher determinism, but it requires you to know your slowest target device.
Testing and the transferable layer
The test is pure logic — extract the decision into a function whose inputs are the current time + the last programmatic timestamp + an isProgrammatic boolean, which Vitest can cover without React (this project's renderHook is unavailable):
| Case | Assertion |
|---|---|
now < programmaticUntil | decideOnPan(state, true).next === state — unchanged, follow holds |
now >= programmaticUntil and isFollowing | .next.isFollowing === false — drop out of follow |
now >= programmaticUntil and not isFollowing | .next === state — no-op |
The multi-frame trap also needs one manual reproduction (do it when introducing a new map feature): on a release build, turn on follow → at zoom > 14, tap the FAB (so the animation is a pure camera move, not zoom-then-center) → confirm that after settling the FAB still reads "following." If it flips to "not following" on its own, the window is too short or markProgrammaticMove() was missed.
Set aside the map's specific API, and the truly transferable lesson is: when an operation occupies an interval in time but you mark it with a flag that covers only one instant, the rest of the events in the interval inevitably leak. Any scenario where "a programmatic trigger emits a string of events rather than one" — animations, observer callbacks fired by batched DOM mutations, multiple inputs within a debounce window — shares this trap. When modeling, first ask: the operation I want to suppress/classify — is it a point or an interval on the timeline? If it's an interval, don't use a boolean, use a window; and price the window length to your slowest real environment.