The Permissions API lets you inspect whether access to a browser capability is granted, denied, or still waiting for a prompt. Implement a React hook that queries one permission name, exposes a predictable loading and failure model, and stays synchronized with the returned PermissionStatus. Your hook must remain correct when the permission name changes while an earlier query is still pending.
function usePermission(name: string): {
state: 'loading' | 'granted' | 'denied' | 'prompt' | 'unsupported';
error: Error | null;
};
const { result } = renderHook(() => usePermission('geolocation'));
// Before navigator.permissions.query() resolves:
result.current; // { state: 'loading', error: null }
// After it resolves to a PermissionStatus whose state is "granted":
result.current; // { state: 'granted', error: null }
// In a browser without navigator.permissions.query:
const { result } = renderHook(() => usePermission('camera'));
result.current; // { state: 'unsupported', error: null }
{ name } to navigator.permissions.query().change event and freshly read its state each time.name changes.You can turn the browser's asynchronous permission snapshot into React state without taking control of the permission itself.
A camera settings screen may need to show whether access is already granted before it enables a capture button. Permissions.query() returns a promise for a PermissionStatus, and that object can later emit change events. The hook must connect both parts while preventing an old asynchronous result from overwriting the current permission.
Treat the resolved PermissionStatus as a live browser-owned record. React stores a copy for rendering, but every update is read from that record rather than guessed locally.
function usePermission(name) {
const [state, setState] = useState('loading');
useEffect(() => {
navigator.permissions.query({ name }).then((status) => {
setState(status.state);
status.addEventListener('change', () => setState(status.state));
});
}, [name]);
return { state, error: null };
}
This works only when every query resolves in order and the API always exists. It never removes its anonymous listener, cannot describe a rejected query, and lets a slow query for an old name replace newer state.
const { useEffect, useState } = require('react');
function normalizeError(reason) {
return reason instanceof Error ? reason : new Error(String(reason));
}
function usePermission(name) {
const [result, setResult] = useState({ state: 'loading', error: null });
useEffect(() => {
let cancelled = false;
let status = null;
let handleChange = null;
setResult({ state: 'loading', error: null });
const query =
typeof navigator !== 'undefined' && navigator.permissions?.query;
if (typeof query !== 'function') {
setResult({ state: 'unsupported', error: null });
return () => {
cancelled = true;
};
}
let pending;
try {
// Calling with the Permissions object preserves its browser method receiver.
pending = query.call(navigator.permissions, { name });
} catch (reason) {
setResult({ state: 'unsupported', error: normalizeError(reason) });
return () => {
cancelled = true;
};
}
Promise.resolve(pending).then(
(nextStatus) => {
if (cancelled) return;
status = nextStatus;
handleChange = () => {
if (!cancelled) {
setResult({ state: status.state, error: null });
}
};
status.addEventListener('change', handleChange);
setResult({ state: status.state, error: null });
},
(reason) => {
if (!cancelled) {
setResult({ state: 'unsupported', error: normalizeError(reason) });
}
}
);
return () => {
cancelled = true;
if (status && handleChange) {
status.removeEventListener('change', handleChange);
}
};
}, [name]);
return result;
}
module.exports = { usePermission };
The effect resets the visible snapshot whenever name changes. Its local cancelled flag belongs to that particular query, while status and handleChange retain the exact resource pair needed for cleanup. Rejections are normalized because JavaScript promises may reject with values that are not Error objects.
You render usePermission('camera'), so the hook first returns { state: 'loading', error: null }. The effect queries { name: 'camera' }; the promise resolves with a status whose state is prompt, so the hook subscribes and renders prompt. The user later grants camera access, the same status object emits change, and the handler freshly reads granted. If the component switches to microphone, cleanup marks the camera effect cancelled and removes its listener before a new loading cycle begins.
PermissionStatus subscribed.unsupported and preserve the error.query with navigator.permissions as this.PermissionDescriptor for APIs such as push or MIDI that need fields beyond name.refresh function for environments whose permission status does not emit reliable changes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
The Permissions API lets you inspect whether access to a browser capability is granted, denied, or still waiting for a prompt. Implement a React hook that queries one permission name, exposes a predictable loading and failure model, and stays synchronized with the returned PermissionStatus. Your hook must remain correct when the permission name changes while an earlier query is still pending.
function usePermission(name: string): {
state: 'loading' | 'granted' | 'denied' | 'prompt' | 'unsupported';
error: Error | null;
};
const { result } = renderHook(() => usePermission('geolocation'));
// Before navigator.permissions.query() resolves:
result.current; // { state: 'loading', error: null }
// After it resolves to a PermissionStatus whose state is "granted":
result.current; // { state: 'granted', error: null }
// In a browser without navigator.permissions.query:
const { result } = renderHook(() => usePermission('camera'));
result.current; // { state: 'unsupported', error: null }
{ name } to navigator.permissions.query().change event and freshly read its state each time.name changes.You can turn the browser's asynchronous permission snapshot into React state without taking control of the permission itself.
A camera settings screen may need to show whether access is already granted before it enables a capture button. Permissions.query() returns a promise for a PermissionStatus, and that object can later emit change events. The hook must connect both parts while preventing an old asynchronous result from overwriting the current permission.
Treat the resolved PermissionStatus as a live browser-owned record. React stores a copy for rendering, but every update is read from that record rather than guessed locally.
function usePermission(name) {
const [state, setState] = useState('loading');
useEffect(() => {
navigator.permissions.query({ name }).then((status) => {
setState(status.state);
status.addEventListener('change', () => setState(status.state));
});
}, [name]);
return { state, error: null };
}
This works only when every query resolves in order and the API always exists. It never removes its anonymous listener, cannot describe a rejected query, and lets a slow query for an old name replace newer state.
const { useEffect, useState } = require('react');
function normalizeError(reason) {
return reason instanceof Error ? reason : new Error(String(reason));
}
function usePermission(name) {
const [result, setResult] = useState({ state: 'loading', error: null });
useEffect(() => {
let cancelled = false;
let status = null;
let handleChange = null;
setResult({ state: 'loading', error: null });
const query =
typeof navigator !== 'undefined' && navigator.permissions?.query;
if (typeof query !== 'function') {
setResult({ state: 'unsupported', error: null });
return () => {
cancelled = true;
};
}
let pending;
try {
// Calling with the Permissions object preserves its browser method receiver.
pending = query.call(navigator.permissions, { name });
} catch (reason) {
setResult({ state: 'unsupported', error: normalizeError(reason) });
return () => {
cancelled = true;
};
}
Promise.resolve(pending).then(
(nextStatus) => {
if (cancelled) return;
status = nextStatus;
handleChange = () => {
if (!cancelled) {
setResult({ state: status.state, error: null });
}
};
status.addEventListener('change', handleChange);
setResult({ state: status.state, error: null });
},
(reason) => {
if (!cancelled) {
setResult({ state: 'unsupported', error: normalizeError(reason) });
}
}
);
return () => {
cancelled = true;
if (status && handleChange) {
status.removeEventListener('change', handleChange);
}
};
}, [name]);
return result;
}
module.exports = { usePermission };
The effect resets the visible snapshot whenever name changes. Its local cancelled flag belongs to that particular query, while status and handleChange retain the exact resource pair needed for cleanup. Rejections are normalized because JavaScript promises may reject with values that are not Error objects.
You render usePermission('camera'), so the hook first returns { state: 'loading', error: null }. The effect queries { name: 'camera' }; the promise resolves with a status whose state is prompt, so the hook subscribes and renders prompt. The user later grants camera access, the same status object emits change, and the handler freshly reads granted. If the component switches to microphone, cleanup marks the camera effect cancelled and removes its listener before a new loading cycle begins.
PermissionStatus subscribed.unsupported and preserve the error.query with navigator.permissions as this.PermissionDescriptor for APIs such as push or MIDI that need fields beyond name.refresh function for environments whose permission status does not emit reliable changes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.