skip to content

In a React Native Animated like-button, rapid taps leave the heart at the wrong scale and completion logic misfires; what is going on, and how do you make it robust?

level: seniorimportance: should knowfreq 35%

answer

  1. a new animation stops the old one
  2. callbacks report finished: false
  3. sequences stop mid-way
  4. native values are not read per frame
  5. state first, animation second

basics

~20 s

Each new animation on a value stops the running one, whose callback gets finished: false, and an interrupted sequence stops mid-way. Keep the like state in React, stop and reset before each run, and act only when finished is true.

solid answer

~50 s

Three mechanisms collide. Starting an animation on a value that is already animating stops the running one, and its `start` callback receives `{finished: false}`. A `sequence` whose child is interrupted stops there, so a pop interrupted after its scale-up step can leave the heart at 1.3 if the new run starts from the current value. And code in completion callbacks, such as a haptic or toggling state when the animation ends, either runs for interrupted runs or never runs. Make it robust: keep the liked state in React and toggle it on press, not on completion; keep a ref to the running composite and `stop()` it, then `setValue` the start value before starting the new run, so every tap starts from the same place; run side effects only when `finished` is true. With the native driver, read the current value through `stopAnimation(value => ...)`, since JavaScript does not see per-frame values.

code

tsx · 36 lines
tsx
import {useEffect, useRef, useState} from 'react';
import {Animated, Pressable, Vibration, useAnimatedValue} from 'react-native';

export function RobustLike() {
  const [liked, setLiked] = useState(false);
  const scale = useAnimatedValue(1);
  const running = useRef<Animated.CompositeAnimation | null>(null);

  useEffect(() => () => running.current?.stop(), []);

  const onPress = () => {
    const next = !liked;
    setLiked(next);

    running.current?.stop();
    scale.setValue(0.8);
    const pop = Animated.sequence([
      Animated.timing(scale, {toValue: 1.3, duration: 120, useNativeDriver: true}),
      Animated.spring(scale, {toValue: 1, friction: 4, useNativeDriver: true}),
    ]);
    running.current = pop;
    pop.start(({finished}) => {
      if (finished && next) {
        Vibration.vibrate(10);
      }
    });
  };

  return (
    <Pressable onPress={onPress} accessibilityRole="button" accessibilityState={{selected: liked}}>
      <Animated.Text style={{fontSize: 32, transform: [{scale}]}}>
        {liked ? '\u2665' : '\u2661'}
      </Animated.Text>
    </Pressable>
  );
}

go deeper

for a junior

Recall that starting a new animation on a value stops the old one, and that the callback's finished flag says whether it completed.

for a middle

Explain how finished: false propagates through sequence and parallel, and why a reset with setValue makes every run start the same.

for a senior

Build user-triggered animations that treat interruption as normal: state on press, owned composites, guarded side effects, and correct value reads under the native driver.

for a principal

Establish conventions that keep business state out of animation callbacks across the app, and decide how interruption is tested.

## The symptom A user taps the like button three times quickly. The heart ends up slightly too large, or the "liked" colour does not match the actual state, and a haptic fires twice or not at all. The animation code looked fine for single taps. ## Mechanism 1: a new animation stops the old one `Animated.Value.animate` stops any animation currently running on the value before starting the new one. The stopped animation's `start` callback is called with **`{finished: false}`**. `setValue` also stops a running animation. So with rapid taps, every tap after the first interrupts the previous run. ## Mechanism 2: composites stop at the interruption - A **`sequence`** only advances when a child finishes. When its current child is interrupted, the sequence stops and reports `finished: false`; later steps never run. - A **`parallel`** with the default `stopTogether: true` stops every child when one ends unfinished. A pop built as `sequence([timing(scale → 1.3), spring(scale → 1)])`, interrupted during the first step, leaves the value somewhere up to 1.3. If the next tap's animation starts from the current value, the heart grows from an already enlarged size and the peak drifts upward. ## Mechanism 3: logic in completion callbacks Code that runs when an animation ends is unreliable under interruption: - a haptic in the callback fires for every run, including interrupted ones, unless it checks `finished`; - toggling `liked` state at the end of the animation loses toggles when runs are interrupted; - an unmounted component can still receive a callback. ## Mechanism 4: the native driver hides values from JavaScript With `useNativeDriver: true`, frames are computed natively and JavaScript does **not** receive the per-frame value. Reading the value in JS during the animation gives a stale number. To act on the actual value: - `value.stopAnimation(current => ...)` stops the animation and passes the current value; - `value.addListener(({value}) => ...)` subscribes to updates, at a cost; - and a value that ran natively cannot later run a JS-driven animation; that throws. ## A robust pattern 1. **State first.** Toggle `liked` in React on press. The animation is decoration and never the source of truth. 2. **Own the running animation.** Keep a ref to the current composite; on each tap call `ref.current?.stop()`. 3. **Start from a known value.** `scale.setValue(0.8)` before starting, so every tap produces the same pop regardless of where the previous one stopped. 4. **Guard side effects.** In the `start` callback, act only when `finished` is true, and never on the liked state. 5. **Clean up** on unmount by stopping the running animation. | Problem | Cause | Fix | |---|---|---| | heart stuck large | sequence stopped after the scale-up step | `stop()` then `setValue` before each run | | double haptic | callback ignores `finished` | check `finished` | | state out of sync | state toggled in the callback | toggle on press | | wrong value read in JS | native driver does not report frames | `stopAnimation` callback or listener | ## What interviewers listen for That the candidate treats interruption as the normal case for user-triggered animation, knows exactly what `finished: false` means, and keeps business state out of animation callbacks. ## The same bug elsewhere The like button is the classic example, but the pattern repeats wherever users can trigger an animation faster than it runs: - a **toast** that slides in and out: a second toast interrupts the first's exit, which never completes, and the first toast stays half visible; - a **tab indicator** that springs under the selected tab: fast tab switching stacks interrupted springs, which is harmless only because each new spring targets an absolute position; - a **card flip**: an interrupted `sequence` of rotate steps leaves the card edge-on unless each run starts from a known angle. The fix is the same each time: decide the target from state, stop what is running, start from a known value or animate to an absolute target, and never let completion callbacks own state.

  • How do you read the current value of a native-driven Animated.Value in JavaScript in React Native?
    Call `value.stopAnimation(current => ...)`, which stops the animation and hands you the value it reached, or subscribe with `addListener`, which reports updates back to JavaScript at a cost. The value's JS-side number is not updated per frame while the native driver runs, so reading it directly gives a stale result.
  • Why should the liked state not be toggled in the animation's completion callback?
    Completion is not guaranteed to mean success: an interrupted run reports `finished: false`, and a component can unmount before the callback. Tying state to the callback loses or duplicates toggles under rapid taps. Toggle state on press, which is the user's intent, and let the animation only decorate it.

saying these in an interview costs you the question

  • Starting a new animation on a value queues it after the running one
  • The start callback only runs when the animation completes normally
  • An interrupted sequence finishes its remaining steps instantly
  • JavaScript sees every frame of a native-driven animation's value
  • The heart's liked state should flip when the pop animation ends