Location
Read position with expo-location: foreground and background access, accuracy against battery, live watching, and reverse geocoding.
const { coords } = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced })Every phone can estimate where it is, from GPS, wifi and cell towers. expo-location is the module that asks for that estimate. Install it with npx expo install expo-location.
Two kinds of access
Foreground location works only while your app is on screen. Request it with Location.requestForegroundPermissionsAsync(), which resolves with an object containing status. Background location keeps reporting when the app is hidden. It needs foreground permission granted first, a task defined through expo-task-manager, and a development build; Expo Go cannot deliver it on iOS.
Accuracy costs battery
Each read takes an accuracy option, one of Location.Accuracy.Lowest, Low, Balanced, High, Highest or BestForNavigation. Balanced is roughly a hundred metres and mostly avoids the GPS chip. BestForNavigation keeps the radio awake and drains the battery. Pick the weakest level the feature can live with.
One read or a stream
getCurrentPositionAsync(options) resolves once with a LocationObject. The numbers sit under coords, not on the object itself. watchPositionAsync(options, callback) resolves with a subscription instead; call remove() on it when the screen unmounts, or the sensor keeps running. subscription?.remove() is shorthand for if (subscription) subscription.remove() and means exactly that.
Coordinates to addresses
reverseGeocodeAsync({ latitude, longitude }) returns an array of address objects with fields such as city, street and postalCode. The array can be empty and any field can be null, so never index into it blind. The first fix after a cold start can take several seconds, so show a pending state.
Example
import { useState } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
import * as Location from "expo-location";
export default function WhereAmI() {
const [label, setLabel] = useState("No fix yet");
const [busy, setBusy] = useState(false);
async function findMe() {
setBusy(true);
// Ask only at the moment the user asked for the feature.
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
setLabel("Location permission denied");
setBusy(false);
return;
}
const position = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced, // about 100 m, cheap on battery
});
// The numbers live under coords, not on position itself.
const { latitude, longitude } = position.coords;
const places = await Location.reverseGeocodeAsync({ latitude, longitude });
const place = places[0]; // the array can be empty
setLabel(place ? `${place.city ?? "Unknown city"}, ${place.country ?? ""}` : `${latitude}, ${longitude}`);
setBusy(false);
}
return (
<View style={styles.screen}>
<Text style={styles.label}>{label}</Text>
<Button title={busy ? "Locating" : "Find me"} onPress={findMe} disabled={busy} />
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16 },
label: { fontSize: 18 },
});When to use it
- Sort a list of stores by distance from the user's current position.
- Prefill a delivery address field from the device's coordinates.
- Record a cycling route while the phone is in a pocket with the screen off.
- Show a map centred on the user and keep the centre as they walk.
More examples
Watching position and cleaning up
watchPositionAsync returns a promise for a subscription, so the effect starts an async function and keeps the subscription in a local variable. The cleanup function removes it, and the cancelled flag covers the case where the screen unmounts while the permission dialog is still open. speed is null on devices that cannot compute it, which is why the readout handles null rather than formatting it. On screen: a single large number that updates as the device moves, and the word waiting until the first fix arrives.
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import * as Location from "expo-location";
export default function SpeedReadout() {
const [speed, setSpeed] = useState<number | null>(null);
useEffect(() => {
let subscription: Location.LocationSubscription | null = null;
let cancelled = false;
async function start() {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted" || cancelled) return;
subscription = await Location.watchPositionAsync(
{ accuracy: Location.Accuracy.High, distanceInterval: 10 }, // report every 10 m
// ({ coords }) => ... is shorthand for (location) => location.coords
({ coords }) => setSpeed(coords.speed),
);
}
start();
return () => {
cancelled = true;
subscription?.remove(); // longhand: if (subscription) subscription.remove()
};
}, []);
return (
<View style={styles.screen}>
<Text style={styles.value}>{speed === null ? "waiting" : `${speed.toFixed(1)} m/s`}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: "center", justifyContent: "center" },
value: { fontSize: 32 },
});Background tracking with a task
This is the shape of every background location feature: one task name, one defineTask call at module scope, and start and stop helpers. Android requires the foregroundService notification text while updates run, which is why it is not optional here. Reach for this only when the app must record movement with the screen off, such as a run tracker or a delivery route; a map screen the user is looking at needs nothing more than watchPositionAsync.
// location-task.ts - imported once from the app entry file, never from inside a component
import * as Location from "expo-location";
import * as TaskManager from "expo-task-manager";
const LOCATION_TASK = "track-run";
// Module scope, so the task still exists when the OS restarts the process.
TaskManager.defineTask(LOCATION_TASK, ({ data, error }) => {
if (error) {
console.log("location task error", error.message);
return;
}
const { locations } = data as { locations: Location.LocationObject[] };
for (const location of locations) {
console.log(location.coords.latitude, location.coords.longitude);
}
});
export async function startTracking(): Promise<boolean> {
const foreground = await Location.requestForegroundPermissionsAsync();
if (foreground.status !== "granted") return false;
// Background must be requested second, in its own dialog.
const background = await Location.requestBackgroundPermissionsAsync();
if (background.status !== "granted") return false;
await Location.startLocationUpdatesAsync(LOCATION_TASK, {
accuracy: Location.Accuracy.Balanced,
distanceInterval: 25, // metres moved before the next report
foregroundService: {
notificationTitle: "Recording your run",
notificationBody: "Tap to return to the app",
},
});
return true;
}
export async function stopTracking() {
if (await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK)) {
await Location.stopLocationUpdatesAsync(LOCATION_TASK);
}
}Deciding whether to ask at all
Permission granted and location available are different things: the user can allow your app and still have location services turned off for the whole device. getForegroundPermissionsAsync reads the current state without showing a dialog, and canAskAgain tells you whether a second prompt would appear at all. When it is false, the only honest move is to explain and link to settings instead of calling request again and getting an instant denial.
import * as Location from "expo-location";
type LocationGate =
| { kind: "ready" }
| { kind: "services-off" }
| { kind: "denied" };
// Pure of UI: a screen can call this and then render one of three states.
export async function checkLocationGate(): Promise<LocationGate> {
// Services can be switched off device-wide, which no permission grant fixes.
const servicesOn = await Location.hasServicesEnabledAsync();
if (!servicesOn) return { kind: "services-off" };
const { status, canAskAgain } = await Location.getForegroundPermissionsAsync();
if (status === "granted") return { kind: "ready" };
if (!canAskAgain) return { kind: "denied" };
const asked = await Location.requestForegroundPermissionsAsync();
return asked.status === "granted" ? { kind: "ready" } : { kind: "denied" };
}
Discussion