This article is published in English.
Unstable useEffect Dependencies: Diagnosing Battery Drain in React Native
See how an object dependency and a missing dependency array caused battery drain and map stutter in React Native, how to profile it, and the three fixes that worked.
A real-time screen that looks perfect in the simulator can still ship with a bug that heats up phones and makes animations stutter, all without logging a single error. The usual culprit is a useEffect whose dependencies change far more often than the data it cares about. This case study walks through such a bug on a live delivery-tracking screen: why useEffect exists, when it is the right tool, how two small dependency mistakes combined into a render loop, how the problem was traced with profiling tools on both the JavaScript and native sides, and the three changes that fixed it.
Why useEffect exists
Before React 16.8, class components spread side effects such as API calls, subscriptions and timers across three separate lifecycle methods: componentDidMount, componentDidUpdate and componentWillUnmount. One logical concern, for example "listen to this socket while the screen is showing", was typically split across all three, which scattered related code and made it easy to forget a step.
Hooks arrived in React 16.8, and useEffect was designed to unify that logic. Rather than reasoning about lifecycle stages, you describe how the component stays in sync with an external system, whether that is a request, a native listener, a subscription, a timer or an animation frame. The effect runs after render, may return a cleanup function, and re-runs whenever a value in its dependency array changes:
useEffect(() => {
// side effect code
return () => {
// cleanup code
};
}, [dependencies]);
In React Native the hook shows up constantly, since nearly all work outside rendering counts as a side effect: AppState and NetInfo listeners, Keyboard events, location watchers, WebSocket connections, and native SDKs for things like the camera or Bluetooth.
Why it is worth the discipline
Side effects need a place to run once rendering is done, and a place to tear down before the component unmounts or before the effect runs again. Without that structure you end up with leaked listeners, duplicated subscriptions and stale closures, and those problems cost far more than useEffect does when it is used correctly.
When to reach for useEffect, and when not to
Good uses in a React Native app include:
- listening to native event sources such as
AppState,NetInfo,KeyboardorDimensions - running timers, intervals or animation loops only while a screen is visible
- bringing local state in line with a prop or global store once rendering is done
- loading data on mount, or again when a key input such as the current user's ID changes
- driving a native module imperatively, for example toggling location tracking, BLE scans or a camera stream
Situations where an effect is the wrong tool:
- Deriving a value from props or state. Compute it during render instead.
- Reacting to a user action such as a button press. Handle it in the event handler, not in an effect that watches for a state change.
- "Waiting" for a state update to land. This usually means two separate state values ought to be merged.
- Omitting the dependency array, or depending on an object or array recreated on every render. This is exactly the trap described in the rest of this article.
For a broader treatment of the syncing anti-pattern, see our article on why syncing state with useEffect is risky.
The bug: a tracking screen that drained the battery
Picture a live delivery-tracking screen: a map with the driver's position updating in real time, much like a food delivery app. It worked in the simulator, got through QA on two or three devices, and was released. About two weeks later, support tickets started arriving:
- On Android, users said the phone got hot and the battery fell by roughly 15% within 20 minutes of keeping the tracking screen up.
- On iOS, users said the map stuttered: the driver marker jumped between positions instead of moving smoothly, and scrolling felt sluggish.
Two different symptoms turned out to share one root cause.
The component, simplified
Here is a reduced version of the screen. It holds the driver's location and the order in state, creates a socket connection, subscribes to location updates in one effect, and recalculates the ETA in another. The two flagged lines are where things went wrong:
function TrackingScreen({ orderId }) {
const [driverLocation, setDriverLocation] = useState(null);
const [order, setOrder] = useState(fetchOrderSync(orderId)); // returns a new object reference
const socket = useMemo(() => connectSocket(), []); // looked memoized, wasn't the issue
useEffect(() => {
const subscription = LocationSocket.on('update', (loc) => {
setDriverLocation(loc);
});
return () => subscription.remove();
}, [order]); // 🚩 the bug
useEffect(() => {
console.log('Recalculating ETA...');
calculateETA(order, driverLocation);
}); // 🚩 no dependency array at all
return <Map driverLocation={driverLocation} order={order} />;
}
Two independent problems were stacked on top of each other.
orderreceived a fresh object identity whenever the parent rendered. In the real app it came from a hook higher up the tree that spread props into a fresh object each time. (The simplified snippet shows it coming fromuseState, which would actually keep a stable reference; treat that line as a stand-in for the upstream hook.) Because the subscription effect listedorderas a dependency, React ran the cleanup and subscribed to the location socket again after each render, not only when the order genuinely changed.- The ETA effect had no dependency array at all. An effect without one runs after every render, including the renders triggered by
setDriverLocationinside the first effect. In this app the ETA calculation also caused a state update elsewhere, which closed the loop: location update, re-render, ETA effect, another state update, another re-render, and so on.
The same code produced different symptoms per platform. On Android, the socket was disconnecting and reconnecting in rapid succession, which kept the radio and CPU busy almost continuously; that was the real source of the battery drain. On iOS, radio activity was throttled differently, but the constant re-subscription and render cycle still hammered the JavaScript thread and made the map remount its marker layer far more often than necessary, which users experienced as stutter.
A side note on the snippet: useState(fetchOrderSync(orderId)) calls fetchOrderSync on every render even though React only uses the result the first time. If the initial value is expensive, pass a function instead, as in useState(() => fetchOrderSync(orderId)), so it runs only once.
How the problem was diagnosed
Step 1: Confirm it is re-renders, not the map library
The natural first suspect was the map SDK. The React DevTools Profiler, which connects to a React Native app over the same Metro connection used for development, ruled that out quickly. The team captured a 10-second profile while the tracking screen was displayed with no interaction at all.
The recording showed the component tree committing dozens of renders per second, while the backend only sent a new driver location roughly every 3 to 5 seconds. That mismatch was the first real clue. As a rule of thumb, render frequency should track meaningful data changes; when renders vastly outnumber updates, something is triggering them artificially.
Step 2: Find out why it re-renders
The Profiler's ranked view listed TrackingScreen and Map committing one right after the other, over and over. To see the exact trigger, the small debugging library why-did-you-render was added temporarily. It logs which prop or state change caused each render, and it reported this:
TrackingScreen re-rendered because of changed props: order
order: Object !== Object (deep equal: true)
The "deep equal: true" part was the decisive evidence. The contents of order had not changed in any meaningful way; only its reference had, because the object was being rebuilt upstream on every pass. React compares dependencies with Object.is, so a structurally identical but newly created object always counts as a change.
Step 3: Watch the native side
JavaScript profiling explains renders, but not what the device's network hardware is doing. On the native side, Flipper with its Network plugin and a custom logging plugin was used to observe the WebSocket lifecycle. The log showed repeated connect and close events only seconds apart, rather than one steady connection for as long as the screen was open. That confirmed the socket was being torn down every time the effect re-ran.
Flipper's Hermes debugger added one more confirmation: a breakpoint placed in the subscription effect's cleanup fired far more often than a real unmount or order change could explain.
A time-sensitive caveat: newer React Native releases have moved away from Flipper as the default debugging tool in favour of React Native DevTools, so check the current React Native documentation for the recommended setup on your version. The approach, observing connection lifecycle and breaking inside cleanup functions, carries over to whichever tools you use.
Step 4: Measure the real battery and CPU impact
Finally, Android Studio's Profiler, using its CPU and Energy views alongside Flipper, quantified the damage:
- Before the fix: sustained CPU usage of about 35 to 40% with the tracking screen sitting idle, and the Energy profiler classified the app as a high battery consumer because of constant radio activity.
- After the fix: idle CPU usage dropped to roughly 4 to 6%, and the Energy profiler no longer reported sustained radio use. It showed short, periodic bursts that lined up with the real update interval.
The fix: three targeted changes
Each change addresses one link in the chain.
1. Depend on a primitive instead of an object
The subscription only needs to restart when the order itself changes, and the orderId string identifies that precisely. Because primitives compare by value, it stays equal across renders:
useEffect(() => {
const subscription = LocationSocket.on('update', (loc) => {
setDriverLocation(loc);
});
return () => subscription.remove();
}, [orderId]); // orderId is a primitive string — stable across re-renders
2. Declare dependencies on every effect
Leave the array off only when you truly want the effect to run after every render, which is rare. Here the ETA should be recalculated when the driver's location changes:
useEffect(() => {
calculateETA(order, driverLocation);
}, [driverLocation]); // only recalculate when location actually changes
Strictly speaking, the effect also reads order, so the react-hooks/exhaustive-deps lint rule will ask for it in the array. Once order is memoized (the next fix), adding it is safe and keeps the effect honest, because it will only re-run when the order really changes. Omitting a value the effect reads risks computing the ETA against stale data.
3. Memoize the order object upstream
Finally, stabilize the object where it is created, so its reference only changes when the fields that matter change:
const order = useMemo(() => buildOrder(rawOrderData), [rawOrderData.id, rawOrderData.status]);
Be deliberate about that dependency list: with only id and status in it, a change to any other field of rawOrderData, such as the delivery address, will not produce a new order. That is correct only if nothing downstream depends on those other fields.
With all three changes in place, the socket connected once per visit to the screen, the ETA was recalculated only when the location actually moved, and the render rate fell from dozens per second to about one every few seconds, matching the real flow of data.
Lessons for React Native teams
- Assume object and array dependencies are unstable. Unless you created them with
useMemooruseCallback, expect a new reference on every render. Prefer primitive dependencies such as IDs where they express the intent. - Always write the dependency array, and do not silence the lint rule.
react-hooks/exhaustive-depsexists to catch exactly this class of bug. Disabling it without understanding the warning is how these problems reach production; fix the instability instead. - Profile idle screens, not just interactions. This bug appeared only while nobody touched the screen, which is exactly the state manual testing tends to overlook.
- Battery drain and stutter can be the same bug. Android's radio and CPU behaviour made this a battery problem, while iOS's rendering pipeline turned the same root cause into jank.
- Cover both halves of the app. A JavaScript profiler shows renders and their causes; native tooling shows connections, radio use and energy. A bug like this usually needs both to be diagnosed with confidence.
If you want to go further on the render side, our overview of common patterns that trigger unnecessary React re-renders covers related traps.
Wrapping up
Few hooks are as quick to write as useEffect, and few are as easy to get quietly wrong. On real-time screens, a dependency mistake does not just produce an extra log line: it can keep the radio awake, overload the JavaScript thread and make an app feel broken with no visible error. Stable dependencies, explicit arrays and profiling of idle states are cheap habits that prevent the most expensive version of this bug.