Performance
The rules every component follows, and how to measure whether they held.
Performance is the reason this library exists in the form it does. Three rules decide almost everything about how a component here is written.
1. Animation runs on the UI thread, always
React Native has two threads that matter. JavaScript runs your components, effects and event handlers. The UI thread draws. An animation driven from JavaScript stalls the moment JavaScript is busy — and JavaScript is busiest exactly when something interesting is happening: a list rendering, a response streaming, a screen transitioning.
Every animation in PanelUI is a Reanimated worklet: the values live in shared
values and the styles are computed in useAnimatedStyle, so they keep their
frame rate no matter what React is doing.
// This is the shape everything here uses.
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withSpring(open ? 1 : 0);
}, [open]);
const style = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ translateY: (1 - progress.value) * 12 }],
}));Never use React Native's core Animated in code that runs alongside this
library. Even with useNativeDriver, its API surface encourages patterns —
listeners, setValue from JS, non-transform properties — that put the
animation back on the JavaScript thread.
2. Nothing re-renders per frame
A component that calls setState from a scroll handler, a gesture or a timer
re-renders its whole subtree on every frame of the interaction. That is the
most common performance bug in a React Native app, and it is invisible on a
fast phone in development.
ScrollFade is the worked example. It tracks scroll offset, content size and
viewport size in shared values written from useAnimatedScrollHandler, and its
edges read them in useAnimatedStyle. Scrolling a list wrapped in it triggers
zero React renders.
The same applies to your own code:
- Scroll position that only drives a style belongs in a shared value, not state.
- A value that only drives an animation never needs to be state at all.
- If you do need it in React, throttle it — 20 updates a second is plenty for anything a human reads.
3. Transform and opacity, not layout
Animating width, height, margin or top forces a layout pass. Animating
transform and opacity does not — they are composited. Where an effect can
be expressed either way, this library picks the composited one:
| Effect | How it is done here |
|---|---|
| Sheet slide-in | translateY, not height |
| Press feedback | scale and opacity, not padding |
| Shimmer sweep | translateX behind a mask, not a moving gradient stop |
| Accordion expand | Layout transition, measured once, not an animated height |
Measure in a release build
This is the one that catches people, and it is worth stating on its own.
Debug builds inflate Reanimated's per-frame cost by roughly two to three times. An animation that appears to blow the 16.7ms frame budget in development can land comfortably inside it in release. Profiling against debug numbers means optimising something that is not actually slow — and worse, it hides the things that are.
# iOS
npx expo run:ios --configuration Release
# Android
npx expo run:android --variant releaseThen measure on a real device — ideally the slowest one you intend to support. A simulator runs on your desktop's CPU and tells you very little about a mid-range Android phone.
Expo published a benchmark of what each animation approach actually costs per frame on real iOS and Android hardware: The real cost of React Native animations. Worth reading before you decide any of this is premature.
Where the cost actually is
In rough order of how often each one is the real problem:
- Re-rendering a list on every update. Memoise the row and keep
renderItemin auseCallback, or the list re-renders every row whenever anything changes. ScrollViewwhere aFlatListbelongs. AScrollViewrenders every child, every time, with no recycling.- State written from a per-frame event. See rule 2.
- Work in a
useAnimatedStyle. It runs every frame. Anything that does not depend on a shared value should be computed outside it. - Large images without dimensions. They cause a layout pass when they land, mid-scroll.
Reduced motion
Every looping animation here — Shimmer, Progress's indeterminate state —
checks useReducedMotion() and renders a static equivalent when the OS setting
is on. It is an accessibility requirement, and it is also free performance for
the users who enable it.
If you write your own looping animation, do the same:
const reducedMotion = useReducedMotion();
useEffect(() => {
if (reducedMotion) return;
progress.value = withRepeat(withTiming(1, { duration: 1200 }), -1, true);
}, [reducedMotion]);Related
- Streaming performance — the same rules under the specific load of a token-by-token AI response.
- ScrollFade — the zero-re-render scroll
pattern, with the caveat it imposes on your own
onScroll.