The Fullscreen API lets an element occupy the entire display, while document.fullscreenElement identifies the element that is actually fullscreen. Build a React hook that controls one referenced element and stays synchronized when the browser, the user, or another part of the page changes fullscreen state.
function useFullscreen(targetRef: React.RefObject<Element | null>): {
isFullscreen: boolean;
enter: () => Promise<void>;
exit: () => Promise<void>;
toggle: () => Promise<void>;
};
function VideoPlayer() {
const playerRef = useRef(null);
const { isFullscreen, toggle } = useFullscreen(playerRef);
return (
<section ref={playerRef}>
<video src="/trailer.mp4" />
<button onClick={() => toggle()}>
{isFullscreen ? 'Leave fullscreen' : 'Enter fullscreen'}
</button>
</section>
);
}
await enter(); // resolves after targetRef.current.requestFullscreen()
await exit(); // resolves immediately if nothing is fullscreen
isFullscreen is true only when document.fullscreenElement === targetRef.current. A different fullscreen element does not count.enter, exit, and toggle are async functions that resolve with undefined after their requested work finishes.enter rejects with an Error when there is no target or it lacks requestFullscreen. While something is fullscreen, exit rejects with an Error if document.exitFullscreen is unavailable.document.fullscreenElement is null, exit() resolves without calling the browser API.document's fullscreenchange event, read document.fullscreenElement in the handler, and remove that exact listener on unmount. Do not set state optimistically just because an API promise resolves.Treat the browser document as the authority: commands request transitions, while fullscreenchange reports what actually became true.
Fullscreen is shared document state, not private component state. A request may be rejected, the user may press Escape, and another element may become fullscreen without this hook initiating it. The hook therefore needs commands for intent and one document listener for observed reality.
Think of document.fullscreenElement as the single register that every hook reads. Each instance compares that shared value with its own current target; equality means active, while null or any other element means inactive.
const enter = async () => {
await targetRef.current.requestFullscreen();
setIsFullscreen(true);
};
const exit = async () => {
await document.exitFullscreen();
setIsFullscreen(false);
};
This looks natural because it updates state after each successful promise. It still misses Escape, browser UI, and changes started by other code. More subtly, promise completion is not the Fullscreen API's state notification; fullscreenchange is, so the local boolean can drift from the document.
const { useCallback, useEffect, useState } = require('react');
function useFullscreen(targetRef) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
// Re-read both sides on every event because the active element and the
// ref's current target can each change during this mount.
const syncFromDocument = () => {
setIsFullscreen(document.fullscreenElement === targetRef.current);
};
syncFromDocument();
document.addEventListener('fullscreenchange', syncFromDocument);
return () => {
document.removeEventListener('fullscreenchange', syncFromDocument);
};
}, []);
const enter = useCallback(async () => {
const target = targetRef.current;
if (!target || typeof target.requestFullscreen !== 'function') {
throw new Error('Fullscreen target or requestFullscreen is unavailable');
}
// State changes only when the browser dispatches fullscreenchange.
await target.requestFullscreen();
}, [targetRef]);
const exit = useCallback(async () => {
if (!document.fullscreenElement) return;
if (typeof document.exitFullscreen !== 'function') {
throw new Error('document.exitFullscreen is unavailable');
}
// A rejected exit leaves the event-derived state untouched.
await document.exitFullscreen();
}, []);
const toggle = useCallback(async () => {
if (document.fullscreenElement === targetRef.current) {
await exit();
} else {
await enter();
}
}, [enter, exit, targetRef]);
return { isFullscreen, enter, exit, toggle };
}
module.exports = { useFullscreen };
The effect performs an initial reconciliation and then owns one listener for the mount's full lifetime. The commands validate browser capabilities and await them, but deliberately never write isFullscreen; only a document snapshot observed at mount or on fullscreenchange can do that.
Suppose a video element is the target and a sidebar is currently fullscreen:
false because the sidebar is not the video.toggle() compares document.fullscreenElement with targetRef.current. They differ, so it selects enter(), not exit().enter() captures the current video element, verifies requestFullscreen, and awaits the browser request.fullscreenchange.syncFromDocument now compares the video with itself and sets isFullscreen to true.false.Mounting, each command, and each fullscreenchange notification take O(1) time. Each mounted hook stores O(1) state and owns one document listener.
document.fullscreenElement on every fullscreenchange.Boolean(document.fullscreenElement) answers a document-wide question. Fix: use strict equality with targetRef.current.Error objects for unsupported operations, while keeping an already-empty exit a no-op.fullscreenerror reporting so the UI can display failures initiated outside these command promises.requestFullscreen({ navigationUI: 'hide' }) where the browser supports it.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
The Fullscreen API lets an element occupy the entire display, while document.fullscreenElement identifies the element that is actually fullscreen. Build a React hook that controls one referenced element and stays synchronized when the browser, the user, or another part of the page changes fullscreen state.
function useFullscreen(targetRef: React.RefObject<Element | null>): {
isFullscreen: boolean;
enter: () => Promise<void>;
exit: () => Promise<void>;
toggle: () => Promise<void>;
};
function VideoPlayer() {
const playerRef = useRef(null);
const { isFullscreen, toggle } = useFullscreen(playerRef);
return (
<section ref={playerRef}>
<video src="/trailer.mp4" />
<button onClick={() => toggle()}>
{isFullscreen ? 'Leave fullscreen' : 'Enter fullscreen'}
</button>
</section>
);
}
await enter(); // resolves after targetRef.current.requestFullscreen()
await exit(); // resolves immediately if nothing is fullscreen
isFullscreen is true only when document.fullscreenElement === targetRef.current. A different fullscreen element does not count.enter, exit, and toggle are async functions that resolve with undefined after their requested work finishes.enter rejects with an Error when there is no target or it lacks requestFullscreen. While something is fullscreen, exit rejects with an Error if document.exitFullscreen is unavailable.document.fullscreenElement is null, exit() resolves without calling the browser API.document's fullscreenchange event, read document.fullscreenElement in the handler, and remove that exact listener on unmount. Do not set state optimistically just because an API promise resolves.Treat the browser document as the authority: commands request transitions, while fullscreenchange reports what actually became true.
Fullscreen is shared document state, not private component state. A request may be rejected, the user may press Escape, and another element may become fullscreen without this hook initiating it. The hook therefore needs commands for intent and one document listener for observed reality.
Think of document.fullscreenElement as the single register that every hook reads. Each instance compares that shared value with its own current target; equality means active, while null or any other element means inactive.
const enter = async () => {
await targetRef.current.requestFullscreen();
setIsFullscreen(true);
};
const exit = async () => {
await document.exitFullscreen();
setIsFullscreen(false);
};
This looks natural because it updates state after each successful promise. It still misses Escape, browser UI, and changes started by other code. More subtly, promise completion is not the Fullscreen API's state notification; fullscreenchange is, so the local boolean can drift from the document.
const { useCallback, useEffect, useState } = require('react');
function useFullscreen(targetRef) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
// Re-read both sides on every event because the active element and the
// ref's current target can each change during this mount.
const syncFromDocument = () => {
setIsFullscreen(document.fullscreenElement === targetRef.current);
};
syncFromDocument();
document.addEventListener('fullscreenchange', syncFromDocument);
return () => {
document.removeEventListener('fullscreenchange', syncFromDocument);
};
}, []);
const enter = useCallback(async () => {
const target = targetRef.current;
if (!target || typeof target.requestFullscreen !== 'function') {
throw new Error('Fullscreen target or requestFullscreen is unavailable');
}
// State changes only when the browser dispatches fullscreenchange.
await target.requestFullscreen();
}, [targetRef]);
const exit = useCallback(async () => {
if (!document.fullscreenElement) return;
if (typeof document.exitFullscreen !== 'function') {
throw new Error('document.exitFullscreen is unavailable');
}
// A rejected exit leaves the event-derived state untouched.
await document.exitFullscreen();
}, []);
const toggle = useCallback(async () => {
if (document.fullscreenElement === targetRef.current) {
await exit();
} else {
await enter();
}
}, [enter, exit, targetRef]);
return { isFullscreen, enter, exit, toggle };
}
module.exports = { useFullscreen };
The effect performs an initial reconciliation and then owns one listener for the mount's full lifetime. The commands validate browser capabilities and await them, but deliberately never write isFullscreen; only a document snapshot observed at mount or on fullscreenchange can do that.
Suppose a video element is the target and a sidebar is currently fullscreen:
false because the sidebar is not the video.toggle() compares document.fullscreenElement with targetRef.current. They differ, so it selects enter(), not exit().enter() captures the current video element, verifies requestFullscreen, and awaits the browser request.fullscreenchange.syncFromDocument now compares the video with itself and sets isFullscreen to true.false.Mounting, each command, and each fullscreenchange notification take O(1) time. Each mounted hook stores O(1) state and owns one document listener.
document.fullscreenElement on every fullscreenchange.Boolean(document.fullscreenElement) answers a document-wide question. Fix: use strict equality with targetRef.current.Error objects for unsupported operations, while keeping an already-empty exit a no-op.fullscreenerror reporting so the UI can display failures initiated outside these command promises.requestFullscreen({ navigationUI: 'hide' }) where the browser supports it.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.