Build a hook that reports which responsive breakpoint the viewport is currently in. Layout code often needs to branch on screen size — render a sidebar at desktop widths, a hamburger menu at phone widths — and hard-coding window.innerWidth < 768 in a dozen places is brittle. useBreakpoint() reads the current window width, maps it to a named band, and re-reports whenever the window resizes, so a component can simply read a name like 'lg' and re-render when the viewport crosses a threshold.
function useBreakpoint(): 'sm' | 'md' | 'lg' | 'xl';
The hook takes no arguments and returns the active breakpoint name. The thresholds are fixed (Tailwind-like):
< 640 → 'sm'>= 640 and < 768 → 'md'>= 768 and < 1024 → 'lg'>= 1024 → 'xl'function Layout() {
const breakpoint = useBreakpoint();
// At 'sm'/'md' show a compact menu; at 'lg'/'xl' show the full sidebar.
return breakpoint === 'sm' || breakpoint === 'md' ? <MobileNav /> : <Sidebar />;
}
// width 500 -> 'sm'
// width 640 -> 'md' (the lower edge of md is inclusive)
// width 767 -> 'md'
// width 768 -> 'lg'
// width 1024 -> 'xl'
// Resizing from 500 to 900 must change the returned name from 'sm' to 'lg'.
640 is 'md', 768 is 'lg', and 1024 is 'xl'. 639 is still 'sm'.resize event.resize listener when the component unmounts, or it leaks and keeps firing into a component that no longer exists.window.innerWidth at mount, not a placeholder you correct after the first resize.'sm', 'md', 'lg', 'xl' — never undefined or an in-between value.You'll turn the current window width into a named band with a small pure helper, seed state from it, and re-run that helper on every resize event so the name stays in sync.
Your layout wants to know "are we on a phone, a tablet, or a desktop?" instead of caring about exact pixels. You pick a few cut points — 640, 768, 1024 — and give each range a name: 'sm', 'md', 'lg', 'xl'. The hook's job is to report which range the window is in right now, and to keep reporting the right one as the user drags the window across a cut point. The hard part isn't the math; it's that the width changes over time, so a value you read once goes stale.
Think of width as a number line chopped into four labelled segments. A pure function getBreakpoint(width) is just "which segment is this number in?" The lower edge of each segment is inclusive — exactly 640 is already 'md', not the tail end of 'sm'.
The width line itself shifts as the window resizes. So the hook needs two pieces: the pure lookup, plus a subscription that re-runs the lookup whenever the width changes.
The obvious version reads the width once into initial state and stops there:
const { useState } = require('react');
function getBreakpoint(width) {
if (width < 640) return 'sm';
if (width < 768) return 'md';
if (width < 1024) return 'lg';
return 'xl';
}
function useBreakpoint() {
const [name] = useState(() => getBreakpoint(window.innerWidth));
return name; // never updates
}
It returns the correct name at mount, so it looks done. But useState's initializer runs only on the first render — there's no subscription to the resize event, so when the user widens the window from 500px to 900px the hook keeps returning the old 'sm'. The value is frozen at whatever the width was when the component mounted.
const { useState, useEffect } = require('react');
// Pure lookup: a width in, a band name out. Lower edges are inclusive,
// so 640 -> 'md', 768 -> 'lg', 1024 -> 'xl'.
function getBreakpoint(width) {
if (width < 640) return 'sm';
if (width < 768) return 'md';
if (width < 1024) return 'lg';
return 'xl';
}
function useBreakpoint() {
// Seed from the current width so the first render is already correct.
const [name, setName] = useState(() => getBreakpoint(window.innerWidth));
useEffect(() => {
const handleResize = () => {
const next = getBreakpoint(window.innerWidth);
// Refinement: only re-render when the band actually changed. Resizing
// within the same band (760 -> 765) recomputes 'md' both times, so we
// skip the setState and avoid a needless render.
setName((prev) => (prev === next ? prev : next));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // subscribe once; window is a stable target
return name;
}
module.exports = { useBreakpoint };
Two shifts from the naive version. First, state is now writable (setName) instead of read-only, so the name can change after mount. Second, an effect subscribes to resize: every event recomputes the band from the current width and updates state, and the effect returns a cleanup that removes the listener on unmount. The prev === next guard inside the updater is the one extra touch — it keeps a render from happening when a resize stays inside the same band.
Start with window.innerWidth = 500 and mount the hook:
useState initializer runs getBreakpoint(500) → 'sm', so the first render returns 'sm'. The effect runs and calls window.addEventListener('resize', handleResize).resize event fires. handleResize reads window.innerWidth (now 900) and computes getBreakpoint(900) → 'lg'. Since 'sm' !== 'lg', the updater returns 'lg', state changes, and the component re-renders returning 'lg'.getBreakpoint(950) is still 'lg'. The updater sees prev === next ('lg' === 'lg') and returns prev, so React bails out — no re-render.getBreakpoint(1024) → 'xl' (the lower edge is inclusive). 'lg' !== 'xl', so state updates and the hook now returns 'xl'.window.removeEventListener('resize', handleResize). No further resize touches the gone component.window.innerWidth once gives a correct first value, but the hook then freezes — dragging the window across 768 leaves it reporting the old band. Fix: subscribe to resize in an effect and recompute on every event.<= instead of < (or testing > 640 instead of >= 640) puts exactly 640 in the wrong band. Fix: compare with < against each upper cut point so the lower edge stays inclusive — width < 640 is 'sm', everything from 640 up is at least 'md'.return () => removeEventListener(...), the listener outlives the component and keeps firing into something unmounted; remounts stack listeners. Fix: always remove the listener in the effect's cleanup.prev === next guard, a setName on each resize event re-renders even when the band didn't change. Fix: compare the recomputed name to the previous one and bail out when equal.{ sm, md, lg } thresholds object (or an ordered list of [name, minWidth] pairs) so the same hook serves a design system whose cut points differ from Tailwind's.window.matchMedia('(min-width: 768px)') listeners rather than a single resize handler; the browser fires a change only when a query actually flips, which is the event you care about.typeof window === 'undefined') and return a sensible default band on the server, then reconcile to the real width after hydration so it doesn't throw during server render.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a hook that reports which responsive breakpoint the viewport is currently in. Layout code often needs to branch on screen size — render a sidebar at desktop widths, a hamburger menu at phone widths — and hard-coding window.innerWidth < 768 in a dozen places is brittle. useBreakpoint() reads the current window width, maps it to a named band, and re-reports whenever the window resizes, so a component can simply read a name like 'lg' and re-render when the viewport crosses a threshold.
function useBreakpoint(): 'sm' | 'md' | 'lg' | 'xl';
The hook takes no arguments and returns the active breakpoint name. The thresholds are fixed (Tailwind-like):
< 640 → 'sm'>= 640 and < 768 → 'md'>= 768 and < 1024 → 'lg'>= 1024 → 'xl'function Layout() {
const breakpoint = useBreakpoint();
// At 'sm'/'md' show a compact menu; at 'lg'/'xl' show the full sidebar.
return breakpoint === 'sm' || breakpoint === 'md' ? <MobileNav /> : <Sidebar />;
}
// width 500 -> 'sm'
// width 640 -> 'md' (the lower edge of md is inclusive)
// width 767 -> 'md'
// width 768 -> 'lg'
// width 1024 -> 'xl'
// Resizing from 500 to 900 must change the returned name from 'sm' to 'lg'.
640 is 'md', 768 is 'lg', and 1024 is 'xl'. 639 is still 'sm'.resize event.resize listener when the component unmounts, or it leaks and keeps firing into a component that no longer exists.window.innerWidth at mount, not a placeholder you correct after the first resize.'sm', 'md', 'lg', 'xl' — never undefined or an in-between value.You'll turn the current window width into a named band with a small pure helper, seed state from it, and re-run that helper on every resize event so the name stays in sync.
Your layout wants to know "are we on a phone, a tablet, or a desktop?" instead of caring about exact pixels. You pick a few cut points — 640, 768, 1024 — and give each range a name: 'sm', 'md', 'lg', 'xl'. The hook's job is to report which range the window is in right now, and to keep reporting the right one as the user drags the window across a cut point. The hard part isn't the math; it's that the width changes over time, so a value you read once goes stale.
Think of width as a number line chopped into four labelled segments. A pure function getBreakpoint(width) is just "which segment is this number in?" The lower edge of each segment is inclusive — exactly 640 is already 'md', not the tail end of 'sm'.
The width line itself shifts as the window resizes. So the hook needs two pieces: the pure lookup, plus a subscription that re-runs the lookup whenever the width changes.
The obvious version reads the width once into initial state and stops there:
const { useState } = require('react');
function getBreakpoint(width) {
if (width < 640) return 'sm';
if (width < 768) return 'md';
if (width < 1024) return 'lg';
return 'xl';
}
function useBreakpoint() {
const [name] = useState(() => getBreakpoint(window.innerWidth));
return name; // never updates
}
It returns the correct name at mount, so it looks done. But useState's initializer runs only on the first render — there's no subscription to the resize event, so when the user widens the window from 500px to 900px the hook keeps returning the old 'sm'. The value is frozen at whatever the width was when the component mounted.
const { useState, useEffect } = require('react');
// Pure lookup: a width in, a band name out. Lower edges are inclusive,
// so 640 -> 'md', 768 -> 'lg', 1024 -> 'xl'.
function getBreakpoint(width) {
if (width < 640) return 'sm';
if (width < 768) return 'md';
if (width < 1024) return 'lg';
return 'xl';
}
function useBreakpoint() {
// Seed from the current width so the first render is already correct.
const [name, setName] = useState(() => getBreakpoint(window.innerWidth));
useEffect(() => {
const handleResize = () => {
const next = getBreakpoint(window.innerWidth);
// Refinement: only re-render when the band actually changed. Resizing
// within the same band (760 -> 765) recomputes 'md' both times, so we
// skip the setState and avoid a needless render.
setName((prev) => (prev === next ? prev : next));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // subscribe once; window is a stable target
return name;
}
module.exports = { useBreakpoint };
Two shifts from the naive version. First, state is now writable (setName) instead of read-only, so the name can change after mount. Second, an effect subscribes to resize: every event recomputes the band from the current width and updates state, and the effect returns a cleanup that removes the listener on unmount. The prev === next guard inside the updater is the one extra touch — it keeps a render from happening when a resize stays inside the same band.
Start with window.innerWidth = 500 and mount the hook:
useState initializer runs getBreakpoint(500) → 'sm', so the first render returns 'sm'. The effect runs and calls window.addEventListener('resize', handleResize).resize event fires. handleResize reads window.innerWidth (now 900) and computes getBreakpoint(900) → 'lg'. Since 'sm' !== 'lg', the updater returns 'lg', state changes, and the component re-renders returning 'lg'.getBreakpoint(950) is still 'lg'. The updater sees prev === next ('lg' === 'lg') and returns prev, so React bails out — no re-render.getBreakpoint(1024) → 'xl' (the lower edge is inclusive). 'lg' !== 'xl', so state updates and the hook now returns 'xl'.window.removeEventListener('resize', handleResize). No further resize touches the gone component.window.innerWidth once gives a correct first value, but the hook then freezes — dragging the window across 768 leaves it reporting the old band. Fix: subscribe to resize in an effect and recompute on every event.<= instead of < (or testing > 640 instead of >= 640) puts exactly 640 in the wrong band. Fix: compare with < against each upper cut point so the lower edge stays inclusive — width < 640 is 'sm', everything from 640 up is at least 'md'.return () => removeEventListener(...), the listener outlives the component and keeps firing into something unmounted; remounts stack listeners. Fix: always remove the listener in the effect's cleanup.prev === next guard, a setName on each resize event re-renders even when the band didn't change. Fix: compare the recomputed name to the previous one and bail out when equal.{ sm, md, lg } thresholds object (or an ordered list of [name, minWidth] pairs) so the same hook serves a design system whose cut points differ from Tailwind's.window.matchMedia('(min-width: 768px)') listeners rather than a single resize handler; the browser fires a change only when a query actually flips, which is the event you care about.typeof window === 'undefined') and return a sensible default band on the server, then reconcile to the real width after hydration so it doesn't throw during server render.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.