Battery
@symbiote-native/battery wraps expo-battery
so every SymbioteNative adapter can read battery level, charging state, and low-power-mode. Unlike
the slider (a native view) or splash screen
(one imperative TurboModule), expo-battery is built on expo-modules-core — a pure async-function
EventEmittersurface, no Fabric view orViewConfiginvolved. Like sensors, battery exposes three independent subscriptions (level, state, low-power-mode), each with its own hook, plususePowerState, which merges all three into onePowerStatevalue.
| OS platform | Support |
|---|---|
| iOS | ✅ live |
| Android | ✅ live |
| Framework adapter | Support |
|---|---|
| React | ✅ live |
| Vue | ✅ live |
| Angular | ✅ live |
| Svelte | ✅ live |
| Solid | ✅ live |
Installation
Section titled “Installation”npm install @symbiote-native/batteryScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --battery (or
add --battery in an existing app) installs and wires this for you — see
@symbiote-native/cli.
expo-battery and expo-modules-core come along as regular dependencies, pinned to exact
versions — never install either yourself, and never add the expo meta-package to your project
(it bundles its own Metro/Babel pipeline, which conflicts with this project’s own).
Battery level
Section titled “Battery level”import { useBatteryLevel } from '@symbiote-native/battery/react';
export default function BatteryLevel() { const batteryLevel = useBatteryLevel(); // number, -1 until the first reading arrives
return <text>{batteryLevel}</text>;}<script setup lang="ts">import { useBatteryLevel } from '@symbiote-native/battery/vue';
const batteryLevel = useBatteryLevel(); // Ref<number>, -1 until the first reading arrives</script>
<template> <text>{{ batteryLevel }}</text></template>import { Component, inject } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { BatteryLevelService } from '@symbiote-native/battery/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: `<text>{{ batteryLevel() }}</text>`,})export class BatteryLevelDisplay { readonly batteryLevel = inject(BatteryLevelService).connect();}<script lang="ts"> import { useBatteryLevel } from '@symbiote-native/battery/svelte';
const batteryLevel = useBatteryLevel(); // { readonly current: number }, -1 until the first reading arrives</script>
<text>{batteryLevel.current}</text>import { createBatteryLevel } from '@symbiote-native/battery/solid';
export function BatteryLevel() { const batteryLevel = createBatteryLevel(); // Accessor<number>, -1 until the first reading arrives
return <text>{batteryLevel()}</text>;}Battery state
Section titled “Battery state”import { useBatteryState } from '@symbiote-native/battery/react';import { BatteryState } from '@symbiote-native/battery';
export default function ChargingIndicator() { const batteryState = useBatteryState();
return <text>{batteryState === BatteryState.CHARGING ? 'Charging' : 'Not charging'}</text>;}<script setup lang="ts">import { useBatteryState } from '@symbiote-native/battery/vue';import { BatteryState } from '@symbiote-native/battery';
const batteryState = useBatteryState();</script>
<template> <text>{{ batteryState === BatteryState.CHARGING ? 'Charging' : 'Not charging' }}</text></template>import { Component, inject } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { BatteryStateService } from '@symbiote-native/battery/angular';import { BatteryState } from '@symbiote-native/battery';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: `<text>{{ batteryState() === BatteryState.CHARGING ? 'Charging' : 'Not charging' }}</text>`,})export class ChargingIndicator { // Angular templates can't reach a module-level import directly — expose the enum as a field. readonly BatteryState = BatteryState; readonly batteryState = inject(BatteryStateService).connect();}<script lang="ts"> import { useBatteryState } from '@symbiote-native/battery/svelte'; import { BatteryState } from '@symbiote-native/battery';
const batteryState = useBatteryState(); // { readonly current: BatteryState }, seeded UNKNOWN</script>
<text>{batteryState.current === BatteryState.CHARGING ? 'Charging' : 'Not charging'}</text>import { createBatteryState } from '@symbiote-native/battery/solid';import { BatteryState } from '@symbiote-native/battery';
export function ChargingIndicator() { const batteryState = createBatteryState(); // Accessor<BatteryState>, seeded UNKNOWN
return <text>{batteryState() === BatteryState.CHARGING ? 'Charging' : 'Not charging'}</text>;}Low power mode
Section titled “Low power mode”import { useLowPowerMode } from '@symbiote-native/battery/react';
export default function LowPowerBadge() { const lowPowerMode = useLowPowerMode();
return <text>{lowPowerMode ? 'Low Power Mode is on' : null}</text>;}<script setup lang="ts">import { useLowPowerMode } from '@symbiote-native/battery/vue';
const lowPowerMode = useLowPowerMode();</script>
<template> <text>{{ lowPowerMode ? 'Low Power Mode is on' : null }}</text></template>import { Component, inject } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { LowPowerModeService } from '@symbiote-native/battery/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: `<text>{{ lowPowerMode() ? 'Low Power Mode is on' : null }}</text>`,})export class LowPowerBadge { readonly lowPowerMode = inject(LowPowerModeService).connect();}<script lang="ts"> import { useLowPowerMode } from '@symbiote-native/battery/svelte';
const lowPowerMode = useLowPowerMode(); // { readonly current: boolean }, seeded false</script>
<text>{lowPowerMode.current ? 'Low Power Mode is on' : null}</text>import { createLowPowerMode } from '@symbiote-native/battery/solid';
export function LowPowerBadge() { const lowPowerMode = createLowPowerMode(); // Accessor<boolean>, seeded false
return <text>{lowPowerMode() ? 'Low Power Mode is on' : null}</text>;}One-shot functions
Section titled “One-shot functions”The stateless functions are already framework-agnostic — import them straight from the package root, on any adapter:
import { isAvailableAsync, getPowerStateAsync, isBatteryOptimizationEnabledAsync, // Android only} from '@symbiote-native/battery';
const available = await isAvailableAsync();const { batteryLevel, batteryState, lowPowerMode } = await getPowerStateAsync();Stateless functions
Section titled “Stateless functions”| Signature | Description |
|---|---|
isAvailableAsync(): Promise<boolean> |
Whether the battery API is available on this device — false on an iOS Simulator |
getBatteryLevelAsync(): Promise<number> |
Battery level between 0 and 1, inclusive, or -1 if the device can’t report it |
getBatteryStateAsync(): Promise<BatteryState> |
Current charging state — see the BatteryState table below |
isLowPowerModeEnabledAsync(): Promise<boolean> |
Whether Low Power Mode (iOS) / Power Saver (Android) is currently on |
isBatteryOptimizationEnabledAsync(): Promise<boolean> |
Whether Android battery optimization is enabled for this app (background tasks may be affected in doze mode) — always resolves false on iOS |
getPowerStateAsync(): Promise<PowerState> |
Combined { batteryLevel, batteryState, lowPowerMode } snapshot, gathered with a single Promise.all over the three calls above |
addBatteryLevelListener(listener): EventSubscription |
Subscribes to battery level change events; call .remove() on the returned subscription to unsubscribe |
addBatteryStateListener(listener): EventSubscription |
Subscribes to battery state (charging/full/unplugged/unknown) change events |
addLowPowerModeListener(listener): EventSubscription |
Subscribes to Low Power Mode / Power Saver toggle events |
Hooks / composables / services / primitives
Section titled “Hooks / composables / services / primitives”React (/react) |
Vue (/vue) |
Angular (/angular) |
Svelte (/svelte) |
Solid (/solid) |
Signature | Returns |
|---|---|---|---|---|---|---|
useBatteryLevel |
useBatteryLevel |
BatteryLevelService.connect() |
useBatteryLevel |
createBatteryLevel |
() |
Live battery level — number (React), Ref<number> (Vue), Signal<number> (Angular), { readonly current: number } (Svelte), Accessor<number> (Solid), seeded -1 |
useBatteryState |
useBatteryState |
BatteryStateService.connect() |
useBatteryState |
createBatteryState |
() |
Live BatteryState — plain value (React/Vue Ref), Signal<BatteryState> (Angular), { readonly current: BatteryState } (Svelte), Accessor<BatteryState> (Solid), seeded UNKNOWN |
useLowPowerMode |
useLowPowerMode |
LowPowerModeService.connect() |
useLowPowerMode |
createLowPowerMode |
() |
Live boolean — plain value (React/Vue Ref), Signal<boolean> (Angular), { readonly current: boolean } (Svelte), Accessor<boolean> (Solid), seeded false |
usePowerState |
usePowerState |
PowerStateService.connect() |
usePowerState |
createPowerState |
() |
Live PowerState ({ lowPowerMode, batteryLevel, batteryState }) in each adapter’s own box, seeded { false, -1, UNKNOWN } |
Angular’s connect() returns a Signal — read it as batteryLevel() in code or in a template,
same as every other connect()-based service in this project. Solid’s create* primitives return
an Accessor for the same reason: call it as batteryLevel(), never destructure it, since a Solid
component body runs once and a destructured value would freeze at its first reading. Svelte’s
use* runes return a boxed getter, { readonly current }, read as batteryLevel.current — a
bare $state handed out of a plain function loses its reactivity once destructured, since Svelte 5
runes are lexically scoped to the file that declares them.
BatteryState
Section titled “BatteryState”| Member | Value | Description |
|---|---|---|
UNKNOWN |
0 |
Battery state is unknown or inaccessible |
UNPLUGGED |
1 |
Discharging, not connected to power (Android: BATTERY_STATUS_DISCHARGING) |
CHARGING |
2 |
Battery is charging |
FULL |
3 |
Battery level is full |
NOT_CHARGING |
4 |
Power connected (AC/USB/wireless) but not actually charging, e.g. a charge-limit or optimized-charging pause — Android only |
PowerState
Section titled “PowerState”| Field | Type | Description |
|---|---|---|
batteryLevel |
number |
0..1, inclusive, or -1 if the battery level is unknown |
batteryState |
BatteryState |
The current charging state, see BatteryState above |
lowPowerMode |
boolean |
true if Low Power Mode / Power Saver is on, false otherwise |
- Android stops delivering events while the app is backgrounded. The native module unregisters
its broadcast receivers when the activity enters the background and registers them again on
return, and emits no catch-up event — so after a return to the foreground your listener still
holds the last value it saw until the next real change. Re-seed from
getPowerStateAsync()if you need to be current at that moment. addBatteryLevelListenerfires far less often on Android. The Android receiver subscribes toACTION_BATTERY_LOWandACTION_BATTERY_OKAYonly, so it reports crossing those thresholds and nothing in between; iOS listens toUIDevice.batteryLevelDidChangeNotification, which fires on every reported percentage change. Don’t build a percentage readout on the Android event alone.- The iOS Simulator resolves sentinels instead of failing.
isSupportedis compiled tofalseundertargetEnvironment(simulator), but the level and state functions have no simulator branch at all — they readUIDevice.currentstraight through and resolve whatever it reports there. Gate onisAvailableAsync()rather than treating a-1level as a real reading.
Common questions
Section titled “Common questions”- The level reads
-1(or the UI shows “-100%”). The level is unknown, typically on a simulator or unsupported device. Check for-1before formatting and never treat it as low battery. - How do I keep it live? Read the initial level, then subscribe with the level listener and remove the subscription on unmount.
- Low Power Mode? Use the low power mode query and its listener to reduce background work.
Sources: Expo docs: Battery, Battery -100% bug report, Expo Battery guide.
How the wrapper works
Section titled “How the wrapper works”@symbiote-native/battery ships zero React/Vue/Angular/Svelte/Solid logic in expo-battery itself — that
package’s own JS hard-imports the expo meta-package (which this project never installs), so its
free functions and event-name constants are hand-ported, verbatim, into this package’s own
core/, changing only the one import line that now pulls EventSubscription from
expo-modules-core instead of expo:
packages/battery/src/|-- core/ isAvailableAsync/getBatteryLevelAsync/getBatteryStateAsync/| isLowPowerModeEnabledAsync/isBatteryOptimizationEnabledAsync/| getPowerStateAsync + addBatteryLevelListener/addBatteryStateListener/| addLowPowerModeListener; watchPowerState merges the three for usePowerState;| native-module.ts resolves the single `ExpoBattery` native module via| expo-modules-core's requireNativeModule|-- react/hooks/ @symbiote-native/battery/react - useBatteryLevel, useBatteryState, useLowPowerMode, usePowerState|-- vue/composables/ @symbiote-native/battery/vue - same four names|-- svelte/runes/ @symbiote-native/battery/svelte - same four names|-- solid/primitives/ @symbiote-native/battery/solid - createBatteryLevel, createBatteryState, createLowPowerMode, createPowerState`-- angular/services/ @symbiote-native/battery/angular - BatteryLevelService, BatteryStateService, LowPowerModeService, PowerStateServiceEach adapter’s hook/composable/rune/service is a thin lifecycle wrapper — seed from the one-shot
call, subscribe on mount, unsubscribe on unmount — over the same core functions; the subscription
and seeding logic is written once and shared by all four, the same logic/lifecycle split as every
other SymbioteNative component (see how it works). The native code itself
is never vendored or copied — expo-modules-autolinking resolves it straight out of
node_modules (see the native setup guide).