"Find stores near me," a live-tracking map, a weather widget that defaults to your city — all need the device's location, which the browser exposes through the Geolocation API. It's permission-gated and asynchronous: the user might grant or deny it, a fix takes time, and watchPosition streams updates as they move. useGeolocation wraps that into a clean { loading, error, latitude, longitude, … } state.
Implement useGeolocation(options). Start in a loading state, subscribe with watchPosition, store each fix (coords + timestamp), capture errors (including "permission denied" and "unsupported"), and clear the watch on unmount.
function useGeolocation(options) {
// returns { loading, error, latitude, longitude, accuracy, timestamp }
}
const { loading, error, latitude, longitude } = useGeolocation();
if (loading) return <Spinner />;
if (error) return <p>{error.message}</p>;
return <Map lat={latitude} lng={longitude} />;
// Higher accuracy (uses GPS on mobile), with a timeout.
const pos = useGeolocation({ enableHighAccuracy: true, timeout: 5000 });
navigator.geolocation.watchPosition(onSuccess, onError, options) streams updates and returns a watch id; clearWatch(id) stops it.loading until the first fix or error; success fills coords and clears error; error sets error and stops loading.navigator.geolocation is undefined in insecure contexts / SSR; surface that as an error rather than crashing.clearWatch on unmount, and ignore any late callback that fires after the component is gone.You'll hold a { loading, error, …coords } object in state, subscribe with watchPosition in an effect, funnel its success/error callbacks into that state, and clearWatch on cleanup — guarding against an unavailable API and post-unmount callbacks.
Geolocation is a permissioned, asynchronous stream. When your component mounts you don't have a location yet (loading), the user may approve or deny the prompt, and once approved the browser can keep sending updated fixes as the device moves. So the hook models a small state machine — loading until the first result, then either a position (that can update repeatedly) or an error — and it has to manage a subscription: start it on mount, stop it on unmount, and not touch React state after the component is gone.
watchPosition is a subscription, like addEventListener: you give it a success and an error callback, it returns a watch id, and it calls your callbacks over time. The effect owns that subscription. onSuccess writes the coords into state and marks loading: false; onError writes the error and marks loading: false. The cleanup calls clearWatch(id) to unsubscribe. A mounted flag (or the effect's own teardown) ensures a fix arriving right as you unmount doesn't call setState on a dead component.
The naive version grabs the location once and ignores failure:
function useGeolocationNaive() {
const [coords, setCoords] = useState(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition((pos) => {
setCoords(pos.coords); // no error handling, no loading, no updates
});
}, []);
return coords;
}
Three gaps. getCurrentPosition fires once, so a moving device never updates — you need watchPosition for a live position. There's no error branch, so a denied permission or a timeout leaves the UI stuck on null forever with no way to tell "still loading" from "failed". And touching navigator.geolocation without a guard throws in an insecure context (non-HTTPS) or on the server. The real hook needs the full state machine, a watch, and guards.
const { useState, useEffect } = require('react');
function useGeolocation(options = {}) {
const [state, setState] = useState({
loading: true,
error: null,
latitude: null,
longitude: null,
accuracy: null,
timestamp: null,
});
useEffect(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setState((s) => ({ ...s, loading: false, error: new Error('Geolocation unsupported') }));
return;
}
let mounted = true;
const onSuccess = (position) => {
if (!mounted) return;
setState({
loading: false,
error: null,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp,
});
};
const onError = (error) => {
if (mounted) setState((s) => ({ ...s, loading: false, error }));
};
const watchId = navigator.geolocation.watchPosition(onSuccess, onError, options);
return () => {
mounted = false;
navigator.geolocation.clearWatch(watchId);
};
}, []);
return state;
}
module.exports = { useGeolocation };
The effect first guards: if there's no navigator.geolocation, it records an error and bails without subscribing. Otherwise it calls watchPosition, passing options straight through, and keeps the returned watchId. onSuccess replaces the whole state with the new fix (clearing any prior error — a recovered signal shouldn't keep showing "denied"); onError merges in the error and stops loading. The mounted flag, flipped in the cleanup alongside clearWatch(watchId), means a callback that resolves after unmount is dropped instead of updating a dead component. One state object keeps loading/error/coords always consistent.
Mount the hook; the user grants permission; a fix arrives; they move and a second fix arrives; then the component unmounts:
{ loading: true, error: null, coords: null }. The effect subscribes: watchId = watchPosition(onSuccess, onError, options). UI shows a spinner.onSuccess(pos). mounted is true → state becomes { loading: false, latitude, longitude, accuracy, timestamp, error: null }. UI shows the map.onSuccess fires again with new coords; state updates in place. Still loading: false.mounted = false, clearWatch(watchId). If a fix were already queued, onSuccess would see mounted === false and do nothing.Had the user denied the prompt instead, onError would have set { loading: false, error }, and the UI would show the message.
getCurrentPosition for live data — it fires once; a moving device won't update. Use watchPosition and clearWatch.onError and expose error.navigator.geolocation is undefined over HTTP / on the server; check before using and surface it as an error.setState on a dead component; guard with a mounted flag and always clearWatch in cleanup.navigator.permissions.query({ name: 'geolocation' }) lets you show "location blocked — enable it in settings" instead of a generic error.getCurrentPosition — exposing a request() that does a one-shot fix lets you defer the permission prompt to a user gesture (better UX than prompting on mount).Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
"Find stores near me," a live-tracking map, a weather widget that defaults to your city — all need the device's location, which the browser exposes through the Geolocation API. It's permission-gated and asynchronous: the user might grant or deny it, a fix takes time, and watchPosition streams updates as they move. useGeolocation wraps that into a clean { loading, error, latitude, longitude, … } state.
Implement useGeolocation(options). Start in a loading state, subscribe with watchPosition, store each fix (coords + timestamp), capture errors (including "permission denied" and "unsupported"), and clear the watch on unmount.
function useGeolocation(options) {
// returns { loading, error, latitude, longitude, accuracy, timestamp }
}
const { loading, error, latitude, longitude } = useGeolocation();
if (loading) return <Spinner />;
if (error) return <p>{error.message}</p>;
return <Map lat={latitude} lng={longitude} />;
// Higher accuracy (uses GPS on mobile), with a timeout.
const pos = useGeolocation({ enableHighAccuracy: true, timeout: 5000 });
navigator.geolocation.watchPosition(onSuccess, onError, options) streams updates and returns a watch id; clearWatch(id) stops it.loading until the first fix or error; success fills coords and clears error; error sets error and stops loading.navigator.geolocation is undefined in insecure contexts / SSR; surface that as an error rather than crashing.clearWatch on unmount, and ignore any late callback that fires after the component is gone.You'll hold a { loading, error, …coords } object in state, subscribe with watchPosition in an effect, funnel its success/error callbacks into that state, and clearWatch on cleanup — guarding against an unavailable API and post-unmount callbacks.
Geolocation is a permissioned, asynchronous stream. When your component mounts you don't have a location yet (loading), the user may approve or deny the prompt, and once approved the browser can keep sending updated fixes as the device moves. So the hook models a small state machine — loading until the first result, then either a position (that can update repeatedly) or an error — and it has to manage a subscription: start it on mount, stop it on unmount, and not touch React state after the component is gone.
watchPosition is a subscription, like addEventListener: you give it a success and an error callback, it returns a watch id, and it calls your callbacks over time. The effect owns that subscription. onSuccess writes the coords into state and marks loading: false; onError writes the error and marks loading: false. The cleanup calls clearWatch(id) to unsubscribe. A mounted flag (or the effect's own teardown) ensures a fix arriving right as you unmount doesn't call setState on a dead component.
The naive version grabs the location once and ignores failure:
function useGeolocationNaive() {
const [coords, setCoords] = useState(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition((pos) => {
setCoords(pos.coords); // no error handling, no loading, no updates
});
}, []);
return coords;
}
Three gaps. getCurrentPosition fires once, so a moving device never updates — you need watchPosition for a live position. There's no error branch, so a denied permission or a timeout leaves the UI stuck on null forever with no way to tell "still loading" from "failed". And touching navigator.geolocation without a guard throws in an insecure context (non-HTTPS) or on the server. The real hook needs the full state machine, a watch, and guards.
const { useState, useEffect } = require('react');
function useGeolocation(options = {}) {
const [state, setState] = useState({
loading: true,
error: null,
latitude: null,
longitude: null,
accuracy: null,
timestamp: null,
});
useEffect(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setState((s) => ({ ...s, loading: false, error: new Error('Geolocation unsupported') }));
return;
}
let mounted = true;
const onSuccess = (position) => {
if (!mounted) return;
setState({
loading: false,
error: null,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp,
});
};
const onError = (error) => {
if (mounted) setState((s) => ({ ...s, loading: false, error }));
};
const watchId = navigator.geolocation.watchPosition(onSuccess, onError, options);
return () => {
mounted = false;
navigator.geolocation.clearWatch(watchId);
};
}, []);
return state;
}
module.exports = { useGeolocation };
The effect first guards: if there's no navigator.geolocation, it records an error and bails without subscribing. Otherwise it calls watchPosition, passing options straight through, and keeps the returned watchId. onSuccess replaces the whole state with the new fix (clearing any prior error — a recovered signal shouldn't keep showing "denied"); onError merges in the error and stops loading. The mounted flag, flipped in the cleanup alongside clearWatch(watchId), means a callback that resolves after unmount is dropped instead of updating a dead component. One state object keeps loading/error/coords always consistent.
Mount the hook; the user grants permission; a fix arrives; they move and a second fix arrives; then the component unmounts:
{ loading: true, error: null, coords: null }. The effect subscribes: watchId = watchPosition(onSuccess, onError, options). UI shows a spinner.onSuccess(pos). mounted is true → state becomes { loading: false, latitude, longitude, accuracy, timestamp, error: null }. UI shows the map.onSuccess fires again with new coords; state updates in place. Still loading: false.mounted = false, clearWatch(watchId). If a fix were already queued, onSuccess would see mounted === false and do nothing.Had the user denied the prompt instead, onError would have set { loading: false, error }, and the UI would show the message.
getCurrentPosition for live data — it fires once; a moving device won't update. Use watchPosition and clearWatch.onError and expose error.navigator.geolocation is undefined over HTTP / on the server; check before using and surface it as an error.setState on a dead component; guard with a mounted flag and always clearWatch in cleanup.navigator.permissions.query({ name: 'geolocation' }) lets you show "location blocked — enable it in settings" instead of a generic error.getCurrentPosition — exposing a request() that does a one-shot fix lets you defer the permission prompt to a user gesture (better UX than prompting on mount).Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.