●MAX — Rork Max generates native Swift apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It reaches AR/LiDAR scanning, Metal 3D games, widgets, Live Activities, and on-device Core ML●FUNDING — Rork raised $2.8M from a16z, with 743K monthly visits and 85% growth●PRICING — It's free to start, with paid plans beginning at $25 per month●FLOW — Describe your idea in plain English to get working code, a shareable test link, and iOS/Android builds●COMPARE — The original Rork builds cross-platform apps on Expo/React Native; choose the right tool per goal●MAX — Rork Max generates native Swift apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It reaches AR/LiDAR scanning, Metal 3D games, widgets, Live Activities, and on-device Core ML●FUNDING — Rork raised $2.8M from a16z, with 743K monthly visits and 85% growth●PRICING — It's free to start, with paid plans beginning at $25 per month●FLOW — Describe your idea in plain English to get working code, a shareable test link, and iOS/Android builds●COMPARE — The original Rork builds cross-platform apps on Expo/React Native; choose the right tool per goal
Your Animation Keeps Running After You Leave the Screen — Focus-Aware Battery Savings in a Rork (Expo) App
In a Rork-generated Expo app, a gradient or breathing animation can keep running after you push another screen or background the app, quietly draining the battery without ever surfacing as jank. The cause is that Reanimated's withRepeat lives on the UI thread and the navigation stack keeps screens mounted. This shows a lifecycle design — useIsFocused plus AppState — that reliably stops off-screen and background loops, with working code.
While building a "breathing guide" for one of my calming apps, I added a slow gradient that gently expanded and contracted. I liked the look. Then one evening, after carrying a beta build around all day, the device felt faintly warm, and the battery breakdown put this app surprisingly high under "background activity."
What puzzled me was that it never showed up as jank. Scrolling was smooth and nothing felt wrong. Yet the breathing animation kept expanding and contracting on the UI thread even after I pushed into Settings, even after I returned home and locked the phone. A silent loop running where I couldn't see it — that was what drained the battery.
After running several wallpaper and calming apps as an indie developer, the lesson I keep relearning is that when an animation stops matters more for battery than how heavy it is. Here I'll share a lifecycle design, using a Rork-generated Expo app as the example, that reliably stops off-screen and background loops.
Why animations don't stop when you leave the screen
It's counterintuitive, but pushing another screen does not stop your animation. There are two reasons.
First, a React Navigation stack does not unmount the previous screen — it keeps it mounted. When you push a detail screen, the list screen beneath it is still alive, and any withRepeat loop there keeps spinning. Tab navigators are even more pronounced: switching tabs generally leaves every tab's screen mounted.
Second, Reanimated's withRepeat runs on the UI thread (in a worklet). Even when the JavaScript thread stops rendering, the UI-thread animation advances independently. That independence is what makes it smooth — but the flip side is that it keeps running in a place JS can't see.
And critically, this never registers as jank. A dropped frame means rendering couldn't keep up; this problem is the opposite — rendering is keeping up, faithfully drawing frames for a screen nobody is looking at. It won't appear in a profiler's hitch view; it only accumulates, quietly, in the Energy Log.
Does each technique auto-stop off-screen?
Before designing the stop logic, it speeds up your decisions to know whether the animation techniques your app uses stop on their own off-screen or not.
Technique
Auto-stops off-screen
How to stop it
Reanimated withRepeat
No
Call cancelAnimation explicitly
Animated (RN core) loop
No
Hold the ref and call .stop()
Lottie (lottie-react-native)
No
Call pause() on the ref / drop autoPlay
expo-video / expo-av
No
Call the player's pause()
CSS-style infinite loop (WebView)
Depends
Stop or hide the WebView
Loop inside a FlatList cell
Partly, via virtualization
Gate per-cell on visibility
The takeaway is that most techniques don't stop unless you stop them. Only FlatList virtualization unmounts off-screen cells for you — but headers and parallax layers sit outside virtualization, so they're the exception.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand why withRepeat loops, Lottie, and video keep running off-screen — the navigation stack stays mounted and the UI thread runs independently — and stop them reliably by combining useIsFocused with AppState
✦Get the reset step that prevents cancelAnimation from freezing a value mid-way, plus the useFocusEffect cleanup pattern that avoids double-starting a loop
✦Know, per animation technique, whether it auto-stops off-screen, so you can target only the always-on loops — breathing animations and wallpaper previews — for pausing
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
There are two conditions to stop on: "is this screen in front (focus)" and "is the app itself in front (AppState)." Run the animation only when both are true, and stop the moment either turns false.
Combine React Navigation's useIsFocused and React Native's AppState into a single hook.
import { useEffect, useState } from 'react';import { AppState, AppStateStatus } from 'react-native';import { useIsFocused } from '@react-navigation/native';/** * Returns true only when the screen is in front AND the app is active. * Use it to centralize starting and stopping animations. */export function useShouldAnimate(): boolean { const isFocused = useIsFocused(); const [appActive, setAppActive] = useState( AppState.currentState === 'active' ); useEffect(() => { const sub = AppState.addEventListener('change', (state: AppStateStatus) => { setAppActive(state === 'active'); }); return () => sub.remove(); }, []); return isFocused && appActive;}
AppState's 'active' means the app is in front and interactive; 'background' is when you've gone home or locked; 'inactive' is the transition (the instant a call arrives or you pull down Control Center). Pausing briefly during a transition does no visible harm, so here I treat anything other than 'active' as a stop.
Stop a Reanimated loop reliably
Start withRepeat when useShouldAnimate is true and stop it with cancelAnimation when it goes false. There's one implementation trap here.
import { useEffect } from 'react';import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withTiming, cancelAnimation, Easing,} from 'react-native-reanimated';export function BreathingGlow() { const shouldAnimate = useShouldAnimate(); const progress = useSharedValue(0); useEffect(() => { if (shouldAnimate) { progress.value = withRepeat( withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.ease) }), -1, // infinite true // reverse ); } else { cancelAnimation(progress); // Trap: cancelAnimation freezes at "the value at that instant." // Don't let it stick at a half scale — ease back to rest. progress.value = withTiming(0, { duration: 300 }); } return () => cancelAnimation(progress); }, [shouldAnimate]); const style = useAnimatedStyle(() => ({ transform: [{ scale: 1 + progress.value * 0.06 }], opacity: 0.7 + progress.value * 0.3, })); return <Animated.View style={[styles.glow, style]} />;}
cancelAnimation isn't "stop," it's "freeze at the current value." If you leave the screen while the breathing animation is mid-expansion, it can stay frozen at an awkward size when you return. So on stop, follow cancelAnimation with a short withTiming back to rest (here, 0). Don't forget the cancelAnimation in the cleanup function, either — without it, a quick re-focus can double-start the loop and burn more battery than before.
Make cleanup reliable with useFocusEffect
Instead of watching useIsFocused inside a useEffect, React Navigation's useFocusEffect guarantees that cleanup runs the moment focus is lost, which makes double-starts easier to avoid. It suits anything controlled through a ref, like Lottie or video.
import { useRef, useCallback } from 'react';import { useFocusEffect } from '@react-navigation/native';import LottieView from 'lottie-react-native';export function AmbientLottie() { const ref = useRef<LottieView>(null); useFocusEffect( useCallback(() => { ref.current?.play(); return () => ref.current?.pause(); // always stops when focus is lost }, []) ); // No autoPlay. Keep playback control in one place: useFocusEffect. return <LottieView ref={ref} source={require('../assets/ambient.json')} loop />;}
The key is leaving off autoPlay. If you control playback from both autoPlay and useFocusEffect, they both fire on first mount and the control flow gets hard to read. Keep a single source of truth for "play."
Video follows the same idea — just pause() the expo-video player. Unless you specifically want audio to continue in the background, stopping on focus loss is the kinder choice for both battery and data.
Thin out always-on loops in a list with visibility
In a wallpaper gallery where each cell carries a small loop (a sparkle or shimmer), FlatList virtualization alone leaves cells near the edges running. Playing only the cells that are actually visible, via onViewableItemsChanged, keeps the number of concurrent loops bounded.
const visibleIds = useSharedValue<string[]>([]);const onViewableItemsChanged = useRef(({ viewableItems }) => { visibleIds.value = viewableItems.map((v) => v.item.id);}).current;const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 50, // visible once half is on screen}).current;// In each cell, toggle the loop based on whether its id is in visibleIds.
Now "only the few cells on screen animate, and they stop once scrolled out of view." In my wallpaper apps, CPU usage during scrolling dropped noticeably after adding this thinning.
How to confirm it's actually working
Once the design is in, verify it with numbers instead of assumptions. Three ways, easiest first.
First, add a dev-only log to the stop logic and watch the console confirm shouldAnimate flips to false — and the animation stops — on every navigation and lock. Establish the logic before anything else.
Second, in Xcode's Debug Navigator, check whether CPU usage falls to idle while you're away from the screen. If it doesn't, a loop is surviving somewhere.
Third, use Instruments' Energy Log, or simply unplug a real device, leave it for a while, and look at your app's "background" share in the Settings battery breakdown. Comparing before and after shows how much of the silent loop you cut.
The order to roll it out
Changing everything at once makes isolation hard. I'd add it in this order: build one useShouldAnimate hook; wire up just the most constantly running animation first (a full-screen background loop or the breathing animation) and confirm CPU drops to idle when you leave; move Lottie and video to the useFocusEffect pattern; and finally add list visibility gating. Check the battery breakdown on a real device once per stage, and you'll have a record of which fix mattered.
The finish line is simple: only what's visible while the app is open should animate, and everything else should be still. Leaving no unseen loops behind — that, I've come to think, is what "lightweight" really means.
Thank you for reading. I hope it gives a useful thread to pull on if you're wrestling with battery life too.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.