In React Native 0.87, pushing a product-detail screen stutters because its mount effect builds a large variant index and fires analytics; with InteractionManager gone, how do you move that work out of the way?
answer
- InteractionManager removed in 0.87
- requestIdleCallback plus cancelIdleCallback
- idle priority, not 'after animations'
- slice work, check timeRemaining()
- each callback gets at most 50 ms
basics
~20 sRender a light screen first, then schedule non-urgent work with requestIdleCallback in small slices that check deadline.timeRemaining(), cancelling on unmount. It runs at idle priority, yielding to input and React updates, but does not wait for animations.
solid answer
~50 sFirst confirm the thread: with `@react-navigation/native-stack` the slide runs on the main thread, so JS work usually shows up as late content and dead taps rather than a stuttering slide. Then split the effect. Keep what the first frame needs, and move the variant indexing and the analytics call into `requestIdleCallback`, which React Native schedules at **idle priority** behind queued input and React updates. Process the array in a loop that checks `deadline.timeRemaining()` - in React Native it counts down from **50 ms** and drops to 0 as soon as higher-priority work is waiting - and re-schedule the remainder. Return `cancelIdleCallback(handle)` from the effect. `InteractionManager` was removed in 0.87 - accessing it throws in development - and by 0.82 it had already stopped honouring interaction handles. `requestIdleCallback` is not an "after the animation" hook, so work that must wait for the transition needs the navigator's own transition events.
code
tsx · 39 linesimport { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
type Variant = { sku: string; color: string; size: string; stock: number };
export function VariantColors({ variants }: { variants: Variant[] }) {
const [byColor, setByColor] = useState<Map<string, Variant[]> | null>(null);
useEffect(() => {
const index = new Map<string, Variant[]>();
let i = 0;
let handle = requestIdleCallback(function step(deadline) {
// timeRemaining() drops to 0 once higher-priority work is waiting
while (i < variants.length && deadline.timeRemaining() > 0) {
const v = variants[i++];
const list = index.get(v.color);
if (list) list.push(v);
else index.set(v.color, [v]);
}
if (i < variants.length) {
handle = requestIdleCallback(step); // yield, continue later
} else {
setByColor(index);
}
});
return () => cancelIdleCallback(handle);
}, [variants]);
if (byColor === null) {
return <Text>Loading colours...</Text>;
}
return (
<View>
{Array.from(byColor.keys()).map((color) => (
<Text key={color}>{color}</Text>
))}
</View>
);
}go deeper
Know that InteractionManager is removed in React Native 0.87 and that requestIdleCallback, with cancelIdleCallback in cleanup, is the replacement for deferring non-urgent work.
Explain idle priority and the IdleDeadline: timeRemaining() starts at 50 ms and drops to 0 when higher-priority work waits, so long jobs must be sliced and re-scheduled.
Diagnose the thread first, then split the effect into first-frame work and idle slices, cancel on unmount and verify on a release build. Know idle does not mean after the transition.
Weigh where deferred work belongs: on the device in idle slices, precomputed on the server, or fetched lazily, and how to keep the team off removed APIs during upgrades.
## The scenario and the first question A user taps a product in a grid and the app pushes a **product-detail** screen. The screen's mount effect builds an index of thousands of variants (by colour and size) and sends an analytics event. The push feels janky. Before changing code, decide **which thread** is hurting: - With `@react-navigation/native-stack`, the transition animates on the **UI (main) thread**. A busy JS thread typically shows up as a slide that completes but lands on a blank or half-rendered screen, and taps that do nothing for a moment. - If the slide itself stutters while JavaScript is idle, the problem is drawing cost on the main thread, and moving JS work will not help. In this scenario it is the JS thread: one synchronous effect holds it for hundreds of milliseconds, far beyond a 16.7 ms (60 Hz) or 8.3 ms (120 Hz) frame. ## What used to be the answer, and why it is gone For years the reflex was `InteractionManager.runAfterInteractions(...)`, which queued work until touches and registered animations had finished. Its story across recent releases: | Release | `InteractionManager` status | |---|---| | 0.80 | deprecated; the changelog says its behaviour now matches `setImmediate` | | 0.82 | no longer respects interaction handles; docs point to `requestIdleCallback` | | 0.86 (Expo SDK 57) | still exported, still deprecated | | 0.87 | **removed**; accessing it in development throws an error pointing to `requestIdleCallback` | So even before removal, `runAfterInteractions` had stopped delaying work until animations finished. Code still using it was relying on a guarantee that no longer existed. ## How `requestIdleCallback` behaves in React Native `requestIdleCallback(callback, options?)` and `cancelIdleCallback(handle)` are globals. With the New Architecture they are backed by a native C++ module on React Native's runtime scheduler: - The callback is scheduled at **idle priority**, the lowest level, so already-queued higher-priority JavaScript work - input handling, React updates - runs first. - The callback receives an `IdleDeadline` with `timeRemaining()` and `didTimeout`. - In React Native's implementation, `timeRemaining()` counts down from **50 ms** per callback, and returns **0** as soon as the scheduler reports that higher-priority work is waiting. - If you pass `{ timeout }`, `didTimeout` tells you whether the callback started after that time. The crucial difference from the old API: **idle does not mean "after the animation"**. React Native has no real idle periods tied to a native transition, so an idle callback can start while `native-stack` is still sliding. That is usually fine, because the slide runs on the main thread; what matters is that the JS work is short-sliced and yields to input. If some work truly must wait until the transition completes, use the navigator's transition-end events. ## The fix, step by step 1. **Render a light shell first**: title, price, primary image and a placeholder for the heavy parts. 2. **Move non-urgent work into idle callbacks**: the analytics call, the variant index, prefetching related products. 3. **Slice long work**: loop while `deadline.timeRemaining() > 0`, process one small item per iteration, and re-schedule the remainder with another `requestIdleCallback`. 4. **Cancel on unmount** by returning `() => cancelIdleCallback(handle)` from the effect, so a quick back gesture does not leave work running for a screen that no longer exists. 5. **Measure again** on a release build on a real device. ## Pitfalls - **One giant idle callback.** Scheduling the whole 300 ms job in a single callback just moves the freeze; the slice loop and re-scheduling are what keep the JS thread responsive. - **Heavy per-iteration work.** `timeRemaining()` is only checked between iterations, so each iteration must be small. - **Expecting animation awareness.** `requestIdleCallback` knows nothing about transitions or gestures. - **Typings.** React Native's own global typings do not declare `requestIdleCallback`; Expo's base `tsconfig` includes the DOM lib, which does, while a bare project may need a declaration. - **Forgetting `cancelIdleCallback`**, which leaks work and state updates after unmount. - **Swapping in `setTimeout(fn, 0)`**, which runs as soon as possible, not after higher-priority work.
- Why is requestIdleCallback not a drop-in replacement for runAfterInteractions?`runAfterInteractions` was designed to wait for touches and registered animations to finish. `requestIdleCallback` only waits for higher-priority JavaScript work queued on the scheduler; it knows nothing about a native transition, so it can run mid-slide. Work that must wait for the transition needs the navigator's transition events.
- What goes wrong if you do the whole indexing job in one idle callback without checking timeRemaining()?The callback still runs synchronously to completion, so the JS thread is blocked for the full job. Idle priority only decides when it starts; slicing with `timeRemaining()` and re-scheduling is what lets taps and React updates run in between.
- Why return cancelIdleCallback from the effect's cleanup?If the user backs out before the work finishes, pending slices would keep running and could call `setState` on an unmounted screen. Cancelling the current handle stops the chain, because each slice schedules the next only when it runs.
saying these in an interview costs you the question
- requestIdleCallback waits until navigation animations have finished
- Wrapping the whole job in one idle callback removes the freeze
- InteractionManager still delays work until animations end in 0.86
- setTimeout(fn, 0) defers work until the JS thread is idle
- Moving JS work fixes a stutter in the native-stack slide itself