A text selection is the run of characters the user has highlighted on the page — what they would copy if they pressed Ctrl+C. useTextSelection is a React hook that reports that selection as plain, live state, updating every time the selection changes. The browser exposes the current selection through window.getSelection() and announces every change with the selectionchange event; your job is to turn that imperative API into React state a component can render.
function useTextSelection(): {
text: string; // the selected text, "" when nothing is selected
isCollapsed: boolean; // true for no selection or a bare caret (no highlight)
};
The hook takes no arguments — it watches the whole document — and returns a fresh object whenever the selection changes.
// The user drags across the word "hello" on the page.
const { text, isCollapsed } = useTextSelection();
// text === "hello", isCollapsed === false
// The user clicks once (a caret, no highlight) or clicks away entirely.
// text === "", isCollapsed === true
selectionchange lives on the document. It fires on document, not on any element, and it does not bubble — so document.addEventListener('selectionchange', ...) is the only way to hear it.getSelection() returns one shared object. Every call hands back the same live Selection, which the browser mutates in place. Read the values you need out of it at event time; do not hold onto the object.isCollapsed is what tells them apart.You will listen for the browser's selectionchange event on the document and, each time it fires, copy the current selection's text into plain React state.
You want a component to know what the user has highlighted — to float a "share this quote" popover over the text, count the selected words, or show a formatting toolbar. The browser already tracks the selection and tells you when it moves, so this feels like a two-line hook: listen for selectionchange, put window.getSelection() in state. Do exactly that and the text updates once and then freezes — the highlight keeps changing on screen, but your component keeps showing the first thing it saw. The reason is a quirk of the selection API that most first attempts trip over.
window.getSelection() does not return a snapshot. It returns one long-lived Selection object that the browser owns and mutates in place as the selection moves — and every call hands you back that same object. So if you store it in state, you are storing a reference that never changes. React decides whether to re-render by comparing the new state to the old with Object.is; the same object equals itself, so React bails out and the UI stays put, even though the object's contents are now different. The fix is to stop storing the object and instead store a copy of the values you care about — the text, read at the instant the event fires.
The obvious version puts the selection object straight into state and reads its text at render time:
const { useState, useEffect } = require('react');
function useTextSelection() {
const [selection, setSelection] = useState(() => window.getSelection());
useEffect(() => {
const onChange = () => setSelection(window.getSelection());
document.addEventListener('selectionchange', onChange);
return () => document.removeEventListener('selectionchange', onChange);
}, []);
return { text: selection ? selection.toString() : '' };
}
The wiring is correct — the listener is on the document, and it is cleaned up. But setSelection(window.getSelection()) sets state to the same object it already holds. Object.is(previous, next) is true, so React skips the re-render, the render function never runs again, and selection.toString() is stuck at whatever it returned on the first render (usually ""). The selection on the page changes; your component does not.
const { useState, useEffect } = require('react');
// Copy the current selection into PLAIN values. window.getSelection() returns
// the same live Selection object on every call, so we never store the object
// itself — we read the primitives we care about at the moment the event fires.
function readSelection() {
const selection = typeof window === 'undefined' ? null : window.getSelection();
if (!selection || selection.rangeCount === 0) {
return { text: '', isCollapsed: true };
}
return { text: selection.toString(), isCollapsed: selection.isCollapsed };
}
function useTextSelection() {
const [state, setState] = useState(readSelection);
useEffect(() => {
const onSelectionChange = () => setState(readSelection());
document.addEventListener('selectionchange', onSelectionChange);
return () => document.removeEventListener('selectionchange', onSelectionChange);
}, []);
return state;
}
module.exports = { useTextSelection };
The one change that matters: readSelection() returns a brand-new object of primitives — a string and a boolean — every time it runs. Now each selectionchange hands React a new value that fails the Object.is check, so it re-renders and the reported text tracks the live selection. Everything else guards the edges: fall back to empty text when there is no window (a server render) or no range at all, and copy isCollapsed so a bare caret reads differently from a real highlight.
Two details about the event earn their keep. First, selectionchange is dispatched on the document — not on the paragraph the user highlighted, and it does not bubble up from an element — so the document is the only place you can hear it. Second, it fires constantly: every caret move, every extra character dragged over. On each firing the selection is in one of two shapes. A ranged selection has highlighted text, so toString() is non-empty and isCollapsed is false. A collapsed selection is a bare caret — the user clicked but dragged nothing — so toString() is "" and isCollapsed is true. Reporting both lets a caller tell "nothing selected" apart from "the cursor is sitting right here."
Start with nothing selected.
useState(readSelection) runs readSelection once. Nothing is selected, so rangeCount is 0 and the initial state is { text: '', isCollapsed: true }. The effect runs and subscribes to selectionchange on the document.selectionchange. Your handler runs readSelection(): rangeCount is now 1, selection.toString() is "world", and isCollapsed is false. It returns a new object { text: 'world', isCollapsed: false }; setState stores it; React re-renders; the component reads text as "world".readSelection() sees a range whose start equals its end: toString() is "" and isCollapsed is true. State becomes { text: '', isCollapsed: true }, and a popover keyed on the text can hide itself.removeEventListener, so the handler stops firing into a component that no longer exists.Selection object — it is a shared singleton the browser mutates in place, so the reference never changes and React's Object.is bail-out strands you on stale text. Copy toString() into state instead of keeping the object.selectionchange fires on the document and does not bubble from elements, so attaching it to the highlighted paragraph hears nothing. Attach it to document.removeEventListener in the effect's return, the listener outlives the component and keeps calling setState on something unmounted.rangeCount is 1 while toString() is "". Check isCollapsed, not merely whether a range exists.selectionchange is a bare Event with no selection data attached; you must read window.getSelection() yourself inside the handler.useTextSelection also returns ranges and rects (from range.getBoundingClientRect()), which is what positions a popover over the highlight. It needs a laid-out browser: jsdom has no layout engine and does not even implement Range.getBoundingClientRect, which is one reason this hook stops at text.useTextSelection instead listens to mouseup and mousedown on a target element and reports the rectangle, which suits a drag-to-highlight toolbar but misses keyboard (Shift+Arrow) selections that selectionchange catches. React's own libraries (react-use, usehooks-ts) ship no equivalent — only useCopyToClipboard.selectionchange fires on every caret move; a heavy consumer can debounce or throttle the handler so it recomputes at most once every, say, 100ms.anchorNode and focusNode, getRangeAt(0), and selection.containsNode() tell you where the selection is, not just its text — enough to answer "is the selection inside this editor?".<iframe> belongs to that frame's own document, so a top-level selectionchange listener will not see it. Each document tracks its selection separately.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A text selection is the run of characters the user has highlighted on the page — what they would copy if they pressed Ctrl+C. useTextSelection is a React hook that reports that selection as plain, live state, updating every time the selection changes. The browser exposes the current selection through window.getSelection() and announces every change with the selectionchange event; your job is to turn that imperative API into React state a component can render.
function useTextSelection(): {
text: string; // the selected text, "" when nothing is selected
isCollapsed: boolean; // true for no selection or a bare caret (no highlight)
};
The hook takes no arguments — it watches the whole document — and returns a fresh object whenever the selection changes.
// The user drags across the word "hello" on the page.
const { text, isCollapsed } = useTextSelection();
// text === "hello", isCollapsed === false
// The user clicks once (a caret, no highlight) or clicks away entirely.
// text === "", isCollapsed === true
selectionchange lives on the document. It fires on document, not on any element, and it does not bubble — so document.addEventListener('selectionchange', ...) is the only way to hear it.getSelection() returns one shared object. Every call hands back the same live Selection, which the browser mutates in place. Read the values you need out of it at event time; do not hold onto the object.isCollapsed is what tells them apart.You will listen for the browser's selectionchange event on the document and, each time it fires, copy the current selection's text into plain React state.
You want a component to know what the user has highlighted — to float a "share this quote" popover over the text, count the selected words, or show a formatting toolbar. The browser already tracks the selection and tells you when it moves, so this feels like a two-line hook: listen for selectionchange, put window.getSelection() in state. Do exactly that and the text updates once and then freezes — the highlight keeps changing on screen, but your component keeps showing the first thing it saw. The reason is a quirk of the selection API that most first attempts trip over.
window.getSelection() does not return a snapshot. It returns one long-lived Selection object that the browser owns and mutates in place as the selection moves — and every call hands you back that same object. So if you store it in state, you are storing a reference that never changes. React decides whether to re-render by comparing the new state to the old with Object.is; the same object equals itself, so React bails out and the UI stays put, even though the object's contents are now different. The fix is to stop storing the object and instead store a copy of the values you care about — the text, read at the instant the event fires.
The obvious version puts the selection object straight into state and reads its text at render time:
const { useState, useEffect } = require('react');
function useTextSelection() {
const [selection, setSelection] = useState(() => window.getSelection());
useEffect(() => {
const onChange = () => setSelection(window.getSelection());
document.addEventListener('selectionchange', onChange);
return () => document.removeEventListener('selectionchange', onChange);
}, []);
return { text: selection ? selection.toString() : '' };
}
The wiring is correct — the listener is on the document, and it is cleaned up. But setSelection(window.getSelection()) sets state to the same object it already holds. Object.is(previous, next) is true, so React skips the re-render, the render function never runs again, and selection.toString() is stuck at whatever it returned on the first render (usually ""). The selection on the page changes; your component does not.
const { useState, useEffect } = require('react');
// Copy the current selection into PLAIN values. window.getSelection() returns
// the same live Selection object on every call, so we never store the object
// itself — we read the primitives we care about at the moment the event fires.
function readSelection() {
const selection = typeof window === 'undefined' ? null : window.getSelection();
if (!selection || selection.rangeCount === 0) {
return { text: '', isCollapsed: true };
}
return { text: selection.toString(), isCollapsed: selection.isCollapsed };
}
function useTextSelection() {
const [state, setState] = useState(readSelection);
useEffect(() => {
const onSelectionChange = () => setState(readSelection());
document.addEventListener('selectionchange', onSelectionChange);
return () => document.removeEventListener('selectionchange', onSelectionChange);
}, []);
return state;
}
module.exports = { useTextSelection };
The one change that matters: readSelection() returns a brand-new object of primitives — a string and a boolean — every time it runs. Now each selectionchange hands React a new value that fails the Object.is check, so it re-renders and the reported text tracks the live selection. Everything else guards the edges: fall back to empty text when there is no window (a server render) or no range at all, and copy isCollapsed so a bare caret reads differently from a real highlight.
Two details about the event earn their keep. First, selectionchange is dispatched on the document — not on the paragraph the user highlighted, and it does not bubble up from an element — so the document is the only place you can hear it. Second, it fires constantly: every caret move, every extra character dragged over. On each firing the selection is in one of two shapes. A ranged selection has highlighted text, so toString() is non-empty and isCollapsed is false. A collapsed selection is a bare caret — the user clicked but dragged nothing — so toString() is "" and isCollapsed is true. Reporting both lets a caller tell "nothing selected" apart from "the cursor is sitting right here."
Start with nothing selected.
useState(readSelection) runs readSelection once. Nothing is selected, so rangeCount is 0 and the initial state is { text: '', isCollapsed: true }. The effect runs and subscribes to selectionchange on the document.selectionchange. Your handler runs readSelection(): rangeCount is now 1, selection.toString() is "world", and isCollapsed is false. It returns a new object { text: 'world', isCollapsed: false }; setState stores it; React re-renders; the component reads text as "world".readSelection() sees a range whose start equals its end: toString() is "" and isCollapsed is true. State becomes { text: '', isCollapsed: true }, and a popover keyed on the text can hide itself.removeEventListener, so the handler stops firing into a component that no longer exists.Selection object — it is a shared singleton the browser mutates in place, so the reference never changes and React's Object.is bail-out strands you on stale text. Copy toString() into state instead of keeping the object.selectionchange fires on the document and does not bubble from elements, so attaching it to the highlighted paragraph hears nothing. Attach it to document.removeEventListener in the effect's return, the listener outlives the component and keeps calling setState on something unmounted.rangeCount is 1 while toString() is "". Check isCollapsed, not merely whether a range exists.selectionchange is a bare Event with no selection data attached; you must read window.getSelection() yourself inside the handler.useTextSelection also returns ranges and rects (from range.getBoundingClientRect()), which is what positions a popover over the highlight. It needs a laid-out browser: jsdom has no layout engine and does not even implement Range.getBoundingClientRect, which is one reason this hook stops at text.useTextSelection instead listens to mouseup and mousedown on a target element and reports the rectangle, which suits a drag-to-highlight toolbar but misses keyboard (Shift+Arrow) selections that selectionchange catches. React's own libraries (react-use, usehooks-ts) ship no equivalent — only useCopyToClipboard.selectionchange fires on every caret move; a heavy consumer can debounce or throttle the handler so it recomputes at most once every, say, 100ms.anchorNode and focusNode, getRangeAt(0), and selection.containsNode() tell you where the selection is, not just its text — enough to answer "is the selection inside this editor?".<iframe> belongs to that frame's own document, so a top-level selectionchange listener will not see it. Each document tracks its selection separately.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.