Making a React Native list swipe like Apple Mail
You build swipe-to-archive into a React Native email app. Swipe one message and it slides off, the rows below glide up, it feels great. Then you archive five as fast as you can flick, and it falls apart: the row leaves, the list freezes for a beat, then jumps. The faster you go, the worse it stacks.
The last post explained the why in general: motion janks when it has to wait on a busy thread, and in React Native the list's layout is computed on that busy thread. This post is the fix, on one real feature. Here is the freeze, and what it looks like once it is gone:
The whole freeze is that middle block: work that piled onto the JavaScript thread in the exact gap between the row leaving and the rows below moving up. None of it is needed to make the row disappear. It just happened to run there. The job is to get all of it out of that gap.
But before any of that, one bigger decision has to be right, and it is the one most "make my list swipe like Mail" attempts get wrong.
Step 1: pick the glide mechanism (most choices are dead ends)
The signature motion is the rows below sliding up to close the gap. You would think there is a standard tool for this. There are several, and on React Native's New Architecture (the current rendering engine, called Fabric) most of them are quietly broken. We use FlashList v2 for the list, and here is the whole map of what we tried:
| Approach | Smooth under load? | Actually runs on Fabric + FlashList? |
|---|---|---|
FlashList cell + Reanimated layout (chosen) | Yes, on the UI thread | Yes |
RN's built-in LayoutAnimation | No, main thread | No, Reanimated disables it |
Reanimated list-level itemLayoutAnimation | Would be | No, not on FlashList |
| A different list library with built-in animations | No, slower for us | Yes |
| Hand-rolled "FLIP" transform per row | No, fights the list | Sort of |
| Animate the removed row's height to zero | No, measures everything | Yes |
Walking the dead ends quickly, because knowing why they fail is the useful part:
RN's built-in LayoutAnimation is the textbook answer, and it is dead the moment Reanimated is installed. Reanimated takes over the animation delegate on the New Architecture, so the built-in call silently does nothing (issue #6751). It also runs on the main thread, which is the thread we are trying to protect.
Reanimated's list-level itemLayoutAnimation looks perfect: a prop that animates rows when they reflow. It only works on a plain single-column animated FlatList, not on a custom list like FlashList (flash-list #1284). Wire it up, get nothing, and there is no error to tell you why. This is the single most common trap.
Reaching for a different list library because it has animations built in. We tried one. On a real inbox of a few thousand threads it was measurably slower than FlashList v2 for our workload, and janked under the same bursts that started this whole thing. The lesson is worth keeping: a built-in animation API is not the same as a smooth animation on a large list. Smoothness comes from where the animation runs and how few views it touches, not from the API being convenient.
A hand-rolled FLIP (measure the old position, let the layout jump, apply a reverse transform, animate it to zero) is the classic way to animate a reorder by hand. FlashList v2 constantly re-measures its variable-height rows, so a hand-rolled version ends up animating those measurement corrections too, and you get gaps and a flash. You are fighting the list's own layout engine instead of riding it.
Animating the removed row's height to zero forces a layout pass that measures every row. That is the exact O(N) work on the busy thread we are trying to avoid.
So one door is open. FlashList positions each visible cell with a real top offset, and Reanimated's per-cell layout transition (not the list-level one) still works. You inject an animated wrapper as the cell renderer and give it a layout worklet:
function cellLayoutTransition(v) {
'worklet';
const dy = v.targetOriginY - v.currentOriginY;
const dist = dy < 0 ? -dy : dy;
// A recycled cell jumps a long way. Never animate that: place it instantly.
if (dist === 0 || dist > SNAP_THRESHOLD) {
return { initialValues: place(v.target), animations: {} };
}
// A small shift (a row above was removed): glide the position, snap the size.
return {
initialValues: place(v.current),
animations: { originY: withSpring(v.targetOriginY, GLIDE_SPRING) },
};
}Two choices in there matter. It uses a spring, not a fixed-duration tween, because if you archive a second row while the first glide is still moving, a spring re-aims from where it is and carries its speed, so the two merge into one motion. A tween would restart from zero and stutter between them. And it snaps large jumps (line 8): when FlashList recycles a cell for a far-away row, its position jumps across the screen, and animating that would look like the list is flying around, so anything past a threshold is placed instantly and only small one-row shifts glide.
There is one more subtlety. We fire the actual removal (dropping the row from the data) from the fly-off animation's completion callback, not the instant the swipe commits. That lets the row fly fully off screen before it leaves the list, so the now-empty cell recycles out of view. It sidesteps a known Reanimated bug where a removed view can get stuck on screen under load (#9170). The catch is that Reanimated's completion callback reports finished: false if the animation is interrupted, so you also need a fallback timer, or an interrupted swipe leaves the row stranded. Every shortcut has a bill.
Step 2: the closest thing to "patching React" (three compiler flags)
The glide is smooth, but it is not free. Here is a fact about the New Architecture that explains the rest of this post: the view nodes are immutable. To change one animated value, Reanimated cannot edit a node in place. It has to clone the node, and every parent up to the root, on every frame it participates in. With a list full of animated cells, that cloning adds up to real time on the JavaScript thread, right when we are swiping.
You attack this directly with three of Reanimated's static feature flags, set in package.json:
"reanimated": {
"staticFeatureFlags": {
"USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS": true,
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": true,
"USE_SYNCHRONIZABLE_FOR_MUTABLES": true
}
}In plain terms: the first makes Reanimated skip its expensive cloning on updates that did not come from React (like scroll position changing in native code). The second, once an animation has come to rest, hands the final value back to React and drops that view from the "please clone me every frame" list. The third makes reading animated values from JavaScript cheaper. Together they cut how much of that per-frame cloning happens at all.
The word "static" is the whole trick, and the thing that trips people up. These flags are compiled into the native binary. Editing the JSON does nothing until you rebuild the native app. Change the file, run the old build, see no difference, conclude it did not work: that is the pothole. You have to do a real native rebuild for the flags to take effect. (They also default to on in newer Reanimated versions, so check yours before copying this.)
After a native rebuild, the freeze that grew worse with each fast swipe was gone. That was the single biggest qualitative jump, and it was three lines of JSON plus a rebuild.
Step 3: get the rest of the work out of the gap
Back to that timeline. The flags handle the animation's own cost. Everything else in the freeze block is ordinary app work that has no business running during the glide. Toggle the visual above to "after" to see where each piece went. Four moves did it.
Make the list rebuild cheap. Turning the raw data into the on-screen list involves sorting and filtering a few thousand threads. Two helper functions dominated it, and both are pure functions of a thread that never changes once loaded. So you cache their results keyed by the thread object itself (a WeakMap, which lets the entry disappear when the thread does). A changed thread is a new object, so it misses the cache automatically and can never go stale. That one change took the filter-and-sort from about 101ms to about 10ms, measured on-device.
Stop redrawing when nothing visible changed. An archive removes the row twice: once instantly (the optimistic hide that drives the glide) and again a moment later when the real data catches up. That second removal produces a list that is identical to what is already on screen, but a fresh array with a new identity, which makes the list re-render and fire a second, pointless glide. The fix is to compare the new list to the old one and, if they match item for item, return the old array so nothing downstream notices. No second glide.
Turn off a redundant safety check. The data library we use deep-compares every query result to preserve object identity. Normally that is a nice default. Our own cache updates already preserve identity, so the deep compare was pure waste, up to a few hundred milliseconds per swipe across every list. We turned it off for those lists on mobile only, so the web app keeps the default.
Defer everything that can wait. When you archive, the visible list is already correct instantly. Reconciling the other views (starred, search, other folders) and adjusting sidebar counts can happen a moment later. So those go into two small queues that wait for a quiet moment and batch a whole burst of swipes into one pass. Undo cancels the pending work, so an undone archive never runs its side effects at all. The data still converges, just off the animation's path.
Here is the shape of the whole flow, deferral and all:
The single idea under all four moves: the swipe's visible result happens now, on the critical path, and everything else is cheaper, rarer, or later.
Step 4: the edge cases nobody sees in the demo
Deferring work is where the bugs live. A smooth demo and a trustworthy inbox are different products, and the difference is a dozen small correctness fixes. My favorite one is a lesson in not patching symptoms.
A swiped row is hidden by adding its id to a "removing" set until the real change lands. Simple. Version one dropped the id as soon as the thread left the current view. Bug: switch tabs right after a swipe, and the id gets dropped while the thread is still mid-flight, so coming back to the inbox flashes the row back.
Version two patched it: keep the id while the thread is still anywhere in the data. That fixed the flash, and broke something worse. An archived thread does not leave the data, it moves to the Done folder. So its id was kept forever, and it stayed hidden in the very folder it moved into.
Two patches to the same spot is a smell. The real problem was the model. A removal is not global, it is relative to a view. Archiving removes a thread from the Inbox but it belongs in Done. So the set becomes a map of id to the view the swipe happened in, and a row is only hidden in that view:
const [removing, setRemoving] = useState<Map<string, string>>(new Map());
function hideRow(key: string) {
const originView = currentViewRef.current;
setRemoving((m) => new Map(m).set(key, originView)); // key -> where it happened
}Now an archived thread is hidden in the Inbox and shows up normally in Done, and the tab-switch flash is gone too, because the id is kept until the thread reconciles out of its own view. One data-model change, both bugs fixed at the root. When you find yourself writing the second patch in the same place, the first one was in the wrong spot.
What it comes down to
Every fast, native-feeling React Native list is doing the same three things, because there is no fourth:
- Move the animation off the busy thread. The glide is a UI-thread worklet, so it does not wait on JavaScript.
- Make the thread's work cheaper. Cached rebuilds, no redundant deep compares, no pointless re-renders.
- Do the work less often, and later. Batch and defer everything that does not have to happen during the swipe.
And the thing you cannot do: fix "too much work in the animation window" by nudging that work a few milliseconds earlier or later. We tried; it just lands in the next swipe's window instead. You have to make it cheaper, rarer, or move the animation away from it. Do all three and a React Native list will swipe like Mail, freeze and all, gone.
Further reading: