[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-matchmedia-watch-media-queries-from-js":44,"search-suggestions":60,"quiz-article-matchmedia-watch-media-queries-from-js":105},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a09525-1113-77f7-a139-80e1f6ec8596","matchmedia-watch-media-queries-from-js","PRACTICE_QUIZ","matchMedia: watching media queries from JavaScript","Check what actually stuck about window.matchMedia — what it returns, how its change event differs from resize, and the preferences it can see that resize never will.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,72,76,80,84,88,91,95,98,102],{"slug":62,"name":63,"articles":64},"webdev","Webdev",99,{"slug":66,"name":67,"articles":68},"javascript","Javascript",83,{"slug":70,"name":71,"articles":53},"frontend","Frontend",{"slug":73,"name":74,"articles":75},"css","Css",33,{"slug":77,"name":78,"articles":79},"tutorial","Tutorial",27,{"slug":81,"name":82,"articles":83},"typescript","Typescript",14,{"slug":85,"name":86,"articles":87},"performance","Performance",12,{"slug":89,"name":90,"articles":87},"react","React",{"slug":92,"name":93,"articles":94},"browser","Browser",9,{"slug":96,"name":97,"articles":51},"node","Node",{"slug":99,"name":100,"articles":101},"grammar","Grammar",6,{"slug":103,"name":104,"articles":101},"programming","Programming",{"id":106,"slug":46,"title":107,"subtitle":52,"excerpt":108,"coverUrl":109,"locale":13,"readingMinutes":101,"publishedAt":110,"viewCount":111,"likeCount":19,"commentCount":19,"author":112,"vertical":117,"topic":118,"tags":120,"_count":125,"playground":127,"body":129,"bodyMd":261,"seo":262,"translationGroupId":265,"series":52,"podcastUrl":52,"verticalId":5,"thread":266,"assessments":268,"translations":271,"quiz":273},"01a09525-10c5-714d-9021-2e375a15a5d3","Stop Debouncing Resize. The Browser Already Watches It.","A debounced resize handler comparing window.innerWidth to a magic number can't ever see dark mode or reduced motion — and it fires dozens of times for one thing matchMedia fires once for.","\u002Fmedia\u002Fcovers\u002Fmatchmedia-watch-media-queries-from-js.png","2026-09-17T11:19:53.454Z",127,{"id":113,"name":114,"username":115,"avatarUrl":52,"headline":116},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":119},"JavaScript",[121,122,123,124],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":73,"name":74,"color":52},{"slug":92,"name":93,"color":52},{"assessments":126},1,{"slug":46,"title":128},"matchMedia — watch queries change live",{"blocks":130,"version":126},[131,135,138,141,144,149,152,158,161,164,167,170,174,177,180,183,186,189,192,196,199,203,206,209,212,215,218,221,224,227,230,233,236,239,242,245,248,251,254],{"id":132,"html":133,"type":134},"b1","\u003Cp>Your dashboard has a sidebar that collapses into icons under 900px. Someone on the team drags the browser window slowly from a wide monitor to a narrower one, testing the responsive layout, and watches the sidebar flicker — collapse, expand, collapse, expand — three or four times before it settles.\u003C\u002Fp>","paragraph",{"id":136,"html":137,"type":134},"b2","\u003Cp>You go looking. The bug isn&#39;t in the CSS. The CSS breakpoint is clean, one \u003Ccode>@media\u003C\u002Fcode> rule, no flicker there at all. The flicker is coming from a \u003Ccode>resize\u003C\u002Fcode> listener in JavaScript, checking \u003Ccode>window.innerWidth\u003C\u002Fcode> to decide whether to also swap out a heavier chart component for a simplified mobile one. And that check is running on \u003Cem>every single \u003Ccode>resize\u003C\u002Fcode> event\u003C\u002Fem> — which, mid-drag, can fire more than sixty times a second.\u003C\u002Fp>",{"id":139,"html":140,"type":134},"b3","\u003Cp>You add a debounce. The flicker slows down but doesn&#39;t fully go away, because debouncing delays the check, it doesn&#39;t fix what the check is asking. You&#39;re still comparing a raw pixel number against \u003Ccode>900\u003C\u002Fcode> on every settle, and a scrollbar appearing or disappearing shifts \u003Ccode>innerWidth\u003C\u002Fcode> by 15px and trips the comparison right at the boundary — a false flip your CSS breakpoint, evaluated by the browser&#39;s own layout engine, never has.\u003C\u002Fp>",{"id":142,"html":143,"type":134},"b4","\u003Cp>The bug isn&#39;t the debounce delay. It&#39;s that you rebuilt a media query in JavaScript, badly, when the browser already had a native way to ask it the same question.\u003C\u002Fp>",{"id":145,"html":146,"text":147,"type":148,"level":31},"b5","The obvious fix, and why it&#39;s still wrong","The obvious fix, and why it's still wrong","heading",{"id":150,"html":151,"type":134},"b6","\u003Cp>Here&#39;s roughly the code that got you here:\u003C\u002Fp>",{"id":153,"code":154,"type":155,"language":156,"highlight":157},"b7","let isMobile = window.innerWidth \u003C 900;\n\nwindow.addEventListener(\"resize\", debounce(() => {\n  const nowMobile = window.innerWidth \u003C 900;\n  if (nowMobile !== isMobile) {\n    isMobile = nowMobile;\n    renderChart(isMobile);\n  }\n}, 150));","code","js",[],{"id":159,"html":160,"type":134},"b8","\u003Cp>It reads fine. It even mostly works. But look at what it&#39;s actually doing: subscribing to \u003Cem>every\u003C\u002Fem> resize event on the page, then, inside every one of those callbacks, doing the comparison the CSS engine already did somewhere else, with a magic number (\u003Ccode>900\u003C\u002Fcode>) that has to be kept in sync with a breakpoint value that lives in a stylesheet you don&#39;t touch from this file. Change the CSS breakpoint to \u003Ccode>960\u003C\u002Fcode> for a redesign, forget this line exists, and the chart swap now happens at the wrong width for months.\u003C\u002Fp>",{"id":162,"html":163,"type":134},"b9","\u003Cp>Debouncing hides the symptom. It doesn&#39;t touch the actual defect, which is that \u003Ccode>resize\u003C\u002Fcode> fires on \u003Cem>movement\u003C\u002Fem>, and your logic only cares about \u003Cem>state\u003C\u002Fem> — whether you&#39;re above or below one line. Everything between crossings is wasted work.\u003C\u002Fp>",{"id":165,"html":166,"text":166,"type":148,"level":31},"b10","What you actually want to watch",{"id":168,"html":169,"type":134},"b11","\u003Cp>The browser has been able to answer &quot;does this media query currently match, and tell me the moment that changes&quot; since long before this bug shipped. It&#39;s \u003Ccode>window.matchMedia()\u003C\u002Fcode>, and it returns a live object, not a one-off boolean:\u003C\u002Fp>",{"id":171,"code":172,"type":155,"language":156,"highlight":173},"b12","const mobileQuery = window.matchMedia(\"(max-width: 899px)\");\n\nconsole.log(mobileQuery.matches); \u002F\u002F true or false, right now\n\nmobileQuery.addEventListener(\"change\", (event) => {\n  renderChart(event.matches);\n});",[],{"id":175,"html":176,"type":134},"b13","\u003Cp>That&#39;s the whole fix. No debounce, no \u003Ccode>innerWidth\u003C\u002Fcode>, no magic number duplicated from your stylesheet — the number lives in exactly one place, the query string, and you can literally copy it out of your CSS. The \u003Ccode>change\u003C\u002Fcode> event doesn&#39;t fire on every pixel of a drag. It fires exactly once, at the instant the query&#39;s truth value flips from \u003Ccode>false\u003C\u002Fcode> to \u003Ccode>true\u003C\u002Fcode> or back. Drag the window across the boundary ten times and you get ten events — not six hundred.\u003C\u002Fp>",{"id":178,"html":179,"type":134},"b14","\u003C!-- playground:start -->",{"id":181,"html":182,"text":182,"type":148,"level":31},"b15","🎮 Try it yourself",{"id":184,"html":185,"type":134},"b16","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fmatchmedia-watch-media-queries-from-js\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":187,"html":188,"type":134},"b17","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":190,"html":191,"type":134},"b18","\u003C!-- playground:end -->",{"id":193,"html":194,"text":195,"type":148,"level":31},"b19","The part \u003Ccode>resize\u003C\u002Fcode> was never going to give you","The part resize was never going to give you",{"id":197,"html":198,"type":134},"b20","\u003Cp>Here&#39;s the detail that makes this more than a tidiness upgrade: a media query isn&#39;t only about width. \u003Ccode>prefers-color-scheme\u003C\u002Fcode>, \u003Ccode>prefers-reduced-motion\u003C\u002Fcode>, \u003Ccode>prefers-contrast\u003C\u002Fcode>, \u003Ccode>hover\u003C\u002Fcode>, \u003Ccode>pointer\u003C\u002Fcode> — these are all valid media features, and none of them have anything to do with the size of the window.\u003C\u002Fp>",{"id":200,"code":201,"type":155,"language":156,"highlight":202},"b21","const wantsReducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\nif (wantsReducedMotion.matches) {\n  skipEntranceAnimation();\n}\n\nwantsReducedMotion.addEventListener(\"change\", (event) => {\n  \u002F\u002F fires the moment the reader flips the OS setting — no reload\n  animationsEnabled = !event.matches;\n});",[],{"id":204,"html":205,"type":134},"b22","\u003Cp>There is no \u003Ccode>resize\u003C\u002Fcode>-shaped event for &quot;the user just turned on reduced motion in their OS settings while your tab was open.&quot; There&#39;s no window dimension that encodes it. If your JavaScript-driven animation logic — the kind CSS \u003Ccode>@media (prefers-reduced-motion: reduce)\u003C\u002Fcode> can&#39;t reach because it&#39;s timed with \u003Ccode>requestAnimationFrame\u003C\u002Fcode>, not a transition — never checks this, you&#39;ve built an accessibility feature that only works for people who happened to have the setting on \u003Cem>before\u003C\u002Fem> your page loaded. \u003Ccode>matchMedia\u003C\u002Fcode> is the only way to know the setting changed while they were already looking at your site.\u003C\u002Fp>",{"id":207,"html":208,"text":208,"type":148,"level":31},"b23","Try the difference, live",{"id":210,"html":211,"type":134},"b24","\u003Cp>Talk is one thing; watching the event counts diverge in your own browser is another. The playground above wires up both a naive \u003Ccode>resize\u003C\u002Fcode> counter and a \u003Ccode>matchMedia\u003C\u002Fcode> change counter side by side — drag your actual browser window and watch one number climb dozens of times faster than the other for the exact same physical motion. It also has a live dashboard of \u003Ccode>prefers-color-scheme\u003C\u002Fcode> and \u003Ccode>prefers-reduced-motion\u003C\u002Fcode> that updates the second you flip them in your OS settings, no reload, and a box where you can type any query of your own and watch it live.\u003C\u002Fp>",{"id":213,"html":214,"text":214,"type":148,"level":31},"b25","The one gotcha worth knowing",{"id":216,"html":217,"type":134},"b26","\u003Cp>\u003Ccode>MediaQueryList\u003C\u002Fcode> objects used to only support \u003Ccode>addListener()\u003C\u002Fcode> \u002F \u003Ccode>removeListener()\u003C\u002Fcode> — an older, non-standard pair of methods that predate \u003Ccode>MediaQueryList\u003C\u002Fcode> implementing the standard \u003Ccode>EventTarget\u003C\u002Fcode> interface. They&#39;re deprecated now, but you&#39;ll still see them in code from a few years back. Use \u003Ccode>addEventListener(&quot;change&quot;, …)\u003C\u002Fcode> \u002F \u003Ccode>removeEventListener(&quot;change&quot;, …)\u003C\u002Fcode> going forward — same object, the standard event methods every other DOM node already gives you, nothing extra to learn.\u003C\u002Fp>",{"id":219,"html":220,"type":134},"b27","\u003Cp>And remember to clean up: if you create a \u003Ccode>matchMedia\u003C\u002Fcode> listener inside a component that unmounts, remove it the same way you&#39;d remove any other event listener, or you&#39;ll leak a callback that keeps firing against a DOM tree that&#39;s already gone.\u003C\u002Fp>",{"id":222,"html":223,"text":223,"type":148,"level":31},"b28","The lesson underneath the API",{"id":225,"html":226,"type":134},"b29","\u003Cp>The pattern here isn&#39;t specific to breakpoints. It&#39;s a habit worth checking for anywhere: whenever you catch yourself polling a raw value on every tick of some noisy event — \u003Ccode>scroll\u003C\u002Fcode>, \u003Ccode>resize\u003C\u002Fcode>, \u003Ccode>mousemove\u003C\u002Fcode> — and then hand-comparing it against a threshold to derive a state that only has two or three values, stop and ask whether the platform already has a named event for the state itself. Threshold-crossing is a narrower, cheaper thing to subscribe to than &quot;everything moved,&quot; and browsers have been quietly shipping these narrower events for longer than most of us have been checking for them.\u003C\u002Fp>",{"id":228,"html":229,"type":134},"b30","\u003Cp>Next time you write a debounce around a \u003Ccode>resize\u003C\u002Fcode> or \u003Ccode>scroll\u003C\u002Fcode> handler, that debounce is a tell — it&#39;s evidence you&#39;re computing a state change from motion instead of listening for the state change directly. What&#39;s the last place in your own code you debounced something the platform could have told you about for free?\u003C\u002Fp>",{"id":231,"html":232,"type":134},"b31","\u003C!-- quiz:start -->",{"id":234,"html":235,"text":235,"type":148,"level":31},"b32","🧠 Test yourself",{"id":237,"html":238,"type":134},"b33","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fmatchmedia-watch-media-queries-from-js\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":240,"html":241,"type":134},"b34","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":243,"html":244,"type":134},"b35","\u003C!-- quiz:end -->",{"id":246,"type":247},"b36","divider",{"id":249,"html":250,"type":134},"b37","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":252,"html":253,"type":134},"b38","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":255,"type":256,"items":257,"ordered":18},"b39","list",[258,259,260],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","Your dashboard has a sidebar that collapses into icons under 900px. Someone on the team drags the browser window slowly from a wide monitor to a narrower one, testing the responsive layout, and watches the sidebar flicker — collapse, expand, collapse, expand — three or four times before it settles.\n\nYou go looking. The bug isn't in the CSS. The CSS breakpoint is clean, one `@media` rule, no flicker there at all. The flicker is coming from a `resize` listener in JavaScript, checking `window.innerWidth` to decide whether to also swap out a heavier chart component for a simplified mobile one. And that check is running on *every single `resize` event* — which, mid-drag, can fire more than sixty times a second.\n\nYou add a debounce. The flicker slows down but doesn't fully go away, because debouncing delays the check, it doesn't fix what the check is asking. You're still comparing a raw pixel number against `900` on every settle, and a scrollbar appearing or disappearing shifts `innerWidth` by 15px and trips the comparison right at the boundary — a false flip your CSS breakpoint, evaluated by the browser's own layout engine, never has.\n\nThe bug isn't the debounce delay. It's that you rebuilt a media query in JavaScript, badly, when the browser already had a native way to ask it the same question.\n\n## The obvious fix, and why it's still wrong\n\nHere's roughly the code that got you here:\n\n```js\nlet isMobile = window.innerWidth \u003C 900;\n\nwindow.addEventListener(\"resize\", debounce(() => {\n  const nowMobile = window.innerWidth \u003C 900;\n  if (nowMobile !== isMobile) {\n    isMobile = nowMobile;\n    renderChart(isMobile);\n  }\n}, 150));\n```\n\nIt reads fine. It even mostly works. But look at what it's actually doing: subscribing to *every* resize event on the page, then, inside every one of those callbacks, doing the comparison the CSS engine already did somewhere else, with a magic number (`900`) that has to be kept in sync with a breakpoint value that lives in a stylesheet you don't touch from this file. Change the CSS breakpoint to `960` for a redesign, forget this line exists, and the chart swap now happens at the wrong width for months.\n\nDebouncing hides the symptom. It doesn't touch the actual defect, which is that `resize` fires on *movement*, and your logic only cares about *state* — whether you're above or below one line. Everything between crossings is wasted work.\n\n## What you actually want to watch\n\nThe browser has been able to answer \"does this media query currently match, and tell me the moment that changes\" since long before this bug shipped. It's `window.matchMedia()`, and it returns a live object, not a one-off boolean:\n\n```js\nconst mobileQuery = window.matchMedia(\"(max-width: 899px)\");\n\nconsole.log(mobileQuery.matches); \u002F\u002F true or false, right now\n\nmobileQuery.addEventListener(\"change\", (event) => {\n  renderChart(event.matches);\n});\n```\n\nThat's the whole fix. No debounce, no `innerWidth`, no magic number duplicated from your stylesheet — the number lives in exactly one place, the query string, and you can literally copy it out of your CSS. The `change` event doesn't fire on every pixel of a drag. It fires exactly once, at the instant the query's truth value flips from `false` to `true` or back. Drag the window across the boundary ten times and you get ten events — not six hundred.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fmatchmedia-watch-media-queries-from-js\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The part `resize` was never going to give you\n\nHere's the detail that makes this more than a tidiness upgrade: a media query isn't only about width. `prefers-color-scheme`, `prefers-reduced-motion`, `prefers-contrast`, `hover`, `pointer` — these are all valid media features, and none of them have anything to do with the size of the window.\n\n```js\nconst wantsReducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\nif (wantsReducedMotion.matches) {\n  skipEntranceAnimation();\n}\n\nwantsReducedMotion.addEventListener(\"change\", (event) => {\n  \u002F\u002F fires the moment the reader flips the OS setting — no reload\n  animationsEnabled = !event.matches;\n});\n```\n\nThere is no `resize`-shaped event for \"the user just turned on reduced motion in their OS settings while your tab was open.\" There's no window dimension that encodes it. If your JavaScript-driven animation logic — the kind CSS `@media (prefers-reduced-motion: reduce)` can't reach because it's timed with `requestAnimationFrame`, not a transition — never checks this, you've built an accessibility feature that only works for people who happened to have the setting on *before* your page loaded. `matchMedia` is the only way to know the setting changed while they were already looking at your site.\n\n## Try the difference, live\n\nTalk is one thing; watching the event counts diverge in your own browser is another. The playground above wires up both a naive `resize` counter and a `matchMedia` change counter side by side — drag your actual browser window and watch one number climb dozens of times faster than the other for the exact same physical motion. It also has a live dashboard of `prefers-color-scheme` and `prefers-reduced-motion` that updates the second you flip them in your OS settings, no reload, and a box where you can type any query of your own and watch it live.\n\n## The one gotcha worth knowing\n\n`MediaQueryList` objects used to only support `addListener()` \u002F `removeListener()` — an older, non-standard pair of methods that predate `MediaQueryList` implementing the standard `EventTarget` interface. They're deprecated now, but you'll still see them in code from a few years back. Use `addEventListener(\"change\", …)` \u002F `removeEventListener(\"change\", …)` going forward — same object, the standard event methods every other DOM node already gives you, nothing extra to learn.\n\nAnd remember to clean up: if you create a `matchMedia` listener inside a component that unmounts, remove it the same way you'd remove any other event listener, or you'll leak a callback that keeps firing against a DOM tree that's already gone.\n\n## The lesson underneath the API\n\nThe pattern here isn't specific to breakpoints. It's a habit worth checking for anywhere: whenever you catch yourself polling a raw value on every tick of some noisy event — `scroll`, `resize`, `mousemove` — and then hand-comparing it against a threshold to derive a state that only has two or three values, stop and ask whether the platform already has a named event for the state itself. Threshold-crossing is a narrower, cheaper thing to subscribe to than \"everything moved,\" and browsers have been quietly shipping these narrower events for longer than most of us have been checking for them.\n\nNext time you write a debounce around a `resize` or `scroll` handler, that debounce is a tell — it's evidence you're computing a state change from motion instead of listening for the state change directly. What's the last place in your own code you debounced something the platform could have told you about for free?\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fmatchmedia-watch-media-queries-from-js\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":107,"canonical":263,"description":264},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fmatchmedia-watch-media-queries-from-js","A debounced resize handler comparing window.innerWidth to a magic number can't ever see dark mode or reduced motion — and it fires dozens of times for one thing matchMedia fires on","01a09525-10c5-714d-9021-3007ff669d84",{"id":267,"locked":18},"01a09525-10f6-76ec-a9e2-cf97c4ee9d63",[269],{"id":45,"slug":46,"title":48,"_count":270},{"questions":51},[272],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":274,"questionCount":51},{"questions":51}]