[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"playground-object-groupby-replace-reduce":32},[4,20],{"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","Build. Learn. Ship.","Practical courses, engineering-grade articles and open-source tools for people who ship.","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.","Structured paths, listening drills and spaced repetition that actually sticks.","amber-400","⌘",[13,15,29],"es",{"courses":17,"paths":17,"articles":18,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":17},2,{"slug":33,"title":34,"summary":35,"html":36,"css":37,"js":38,"config":39,"doc":41},"object-groupby-replace-reduce","Object.groupBy — group an array in one line",null,"\u003Cdiv class=\"wrap\">\n    \u003Ch1>🎮 Group an array in one line\u003C\u002Fh1>\n    \u003Cp class=\"sub\">\n      Pick a key below and watch these tasks snap into buckets — that's\n      \u003Ccode>Object.groupBy(tasks, t =&gt; t[key])\u003C\u002Fcode>, the native replacement for the\n      hand-written \u003Ccode>reduce\u003C\u002Fcode> everyone writes. Click any task to change it live.\n    \u003C\u002Fp>\n\n    \u003Cdiv class=\"note\" id=\"polyNote\" hidden>\n      ⚠️ Your browser lacks native \u003Ccode>Object.groupBy\u003C\u002Fcode> \u002F \u003Ccode>Map.groupBy\u003C\u002Fcode>, so a tiny\n      inline polyfill is running the demo. In Chrome 117+, Firefox 119+, Safari 17.4+ or Node 21+ this is native.\n    \u003C\u002Fdiv>\n\n    \u003C!-- ============ MAIN: pick a key, see the buckets ============ -->\n    \u003Cdiv class=\"panel\">\n      \u003Ch2>Group by…\u003C\u002Fh2>\n      \u003Cdiv class=\"seg\" role=\"group\" aria-label=\"Grouping key\">\n        \u003Cbutton data-key=\"status\"   aria-pressed=\"true\">status\u003C\u002Fbutton>\n        \u003Cbutton data-key=\"priority\" aria-pressed=\"false\">priority\u003C\u002Fbutton>\n        \u003Cbutton data-key=\"assignee\" aria-pressed=\"false\">assignee\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"caption\" id=\"caption\" aria-live=\"polite\">\u003C\u002Fp>\n      \u003Cdiv class=\"buckets\" id=\"buckets\">\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003C!-- ============ The exact call, next to the reduce it replaces ============ -->\n    \u003Cdiv class=\"panel\">\n      \u003Ch2>The one line that replaces the reduce\u003C\u002Fh2>\n      \u003Cdiv class=\"code2\">\n        \u003Cpre class=\"old\" id=\"codeOld\">\u003C\u002Fpre>\n        \u003Cpre class=\"new\" id=\"codeNew\">\u003C\u002Fpre>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003C!-- ============ The data (mutable) ============ -->\n    \u003Cdiv class=\"panel\">\n      \u003Ch2>The tasks\u003C\u002Fh2>\n      \u003Cdiv class=\"toolbar\">\n        \u003Cbutton id=\"add\">+ Add random task\u003C\u002Fbutton>\n        \u003Cbutton id=\"reset\" class=\"ghost\">Reset\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"hint\">Tip: \u003Cb>click any row\u003C\u002Fb> to cycle its \u003Cspan id=\"hintKey\">status\u003C\u002Fspan> — it jumps to a different bucket instantly.\u003C\u002Fp>\n      \u003Cdiv class=\"tablewrap\">\n        \u003Ctable>\n          \u003Cthead>\u003Ctr>\u003Cth scope=\"col\">Task\u003C\u002Fth>\u003Cth scope=\"col\">Status\u003C\u002Fth>\u003Cth scope=\"col\">Priority\u003C\u002Fth>\u003Cth scope=\"col\">Assignee\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\n          \u003Ctbody id=\"tbody\">\u003C\u002Ftbody>\n        \u003C\u002Ftable>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003C!-- ============ BONUS: Map.groupBy, for non-string keys ============ -->\n    \u003Cdiv class=\"panel\">\n      \u003Ch2>Bonus · Map.groupBy — when keys aren't strings\u003C\u002Fh2>\n      \u003Cdiv class=\"tabs\" role=\"tablist\" aria-label=\"groupBy variant\">\n        \u003Cbutton role=\"tab\" data-view=\"map\" aria-selected=\"true\">Map.groupBy ✅\u003C\u002Fbutton>\n        \u003Cbutton role=\"tab\" data-view=\"obj\" aria-selected=\"false\">Object.groupBy ⚠️\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"caption\" id=\"mapCaption\" aria-live=\"polite\">\u003C\u002Fp>\n      \u003Cdiv class=\"buckets\" id=\"mapBuckets\">\u003C\u002Fdiv>\n      \u003Cdiv class=\"mapcode\" id=\"mapCode\">\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>",":root {\n    --bg: #0d1117; --panel: #161b22; --ink: #e8ebf2; --muted: #9aa4b2;\n    --accent: #7c9cff; --good: #3fb950; --bad: #f0883e; --line: #232a35;\n    font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n  }\n  * { box-sizing: border-box; }\n  body { margin: 0; background: var(--bg); color: var(--ink); min-height: 100vh;\n    display: flex; align-items: flex-start; justify-content: center; padding: 18px; }\n  .wrap { width: 100%; max-width: 720px; }\n  h1 { font-size: 1.2rem; margin: 0 0 3px; letter-spacing: -0.01em; }\n  .sub { color: var(--muted); font-size: .9rem; margin: 0 0 14px; line-height: 1.45; }\n  code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n  .sub code { color: var(--accent); background: #ffffff10; padding: 1px 5px; border-radius: 5px; }\n\n  .panel { background: var(--panel); border: 1px solid var(--line);\n    border-radius: 14px; padding: 16px; margin-bottom: 14px; }\n  .panel h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .06em;\n    color: var(--muted); margin: 0 0 12px; font-weight: 700; }\n\n  \u002F* controls *\u002F\n  .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }\n  button { font: inherit; font-weight: 600; color: #0b0d10; background: var(--accent);\n    border: 0; border-radius: 10px; padding: 8px 14px; cursor: pointer; }\n  button.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }\n  button:focus-visible, .row:focus-visible, [role=tab]:focus-visible {\n    outline: 2px solid var(--accent); outline-offset: 2px; }\n\n  \u002F* segmented control *\u002F\n  .seg { display: inline-flex; gap: 4px; background: #0b0e14; border: 1px solid var(--line);\n    border-radius: 12px; padding: 4px; flex-wrap: wrap; }\n  .seg button { background: transparent; color: var(--muted); border-radius: 8px; padding: 7px 14px; }\n  .seg button[aria-pressed=\"true\"] { background: var(--accent); color: #0b0d10; }\n\n  .caption { font-size: .9rem; color: var(--ink); margin: 13px 0 2px; line-height: 1.4; }\n  .caption b { color: var(--accent); }\n  .caption code { color: var(--good); background: #ffffff10; padding: 1px 5px; border-radius: 5px; }\n\n  \u002F* buckets *\u002F\n  .buckets { display: grid; gap: 10px; margin-top: 12px;\n    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }\n  .bucket { background: #0b0e14; border: 1px solid var(--line); border-radius: 11px;\n    padding: 10px 11px; border-left: 3px solid hsl(var(--h,220) 65% 60%); }\n  .bhead { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }\n  .chip { font-size: .74rem; font-weight: 700; color: hsl(var(--h,220) 75% 72%);\n    background: hsl(var(--h,220) 65% 60% \u002F .14); padding: 3px 8px; border-radius: 999px;\n    text-transform: none; }\n  .count { font-size: .72rem; color: var(--muted); font-variant-numeric: tabular-nums; }\n  .items { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }\n  .items li { font-size: .82rem; line-height: 1.25; }\n  .items .meta { display: block; color: var(--muted); font-size: .68rem; margin-top: 1px; }\n\n  \u002F* data table *\u002F\n  .hint { font-size: .78rem; color: var(--muted); margin: 0 0 10px; }\n  .tablewrap { overflow-x: auto; }\n  table { width: 100%; border-collapse: collapse; font-size: .82rem; }\n  th, td { text-align: left; padding: 7px 9px; border-bottom: 1px solid var(--line); white-space: nowrap; }\n  th { color: var(--muted); font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }\n  .row { cursor: pointer; }\n  .row:hover { background: #ffffff08; }\n  .row td:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }\n  .pri { font-weight: 600; }\n  .pri.high { color: var(--bad); } .pri.medium { color: var(--accent); } .pri.low { color: var(--good); }\n  .toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }\n\n  \u002F* code panels *\u002F\n  .code2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n  pre { margin: 0; overflow-x: auto; background: #0b0e14; border: 1px solid var(--line);\n    border-radius: 10px; padding: 12px; font-size: .76rem; line-height: 1.55; }\n  pre .lbl { display: block; font-size: .68rem; text-transform: uppercase; letter-spacing: .05em;\n    margin-bottom: 8px; font-weight: 700; }\n  pre.old .lbl { color: var(--bad); } pre.new .lbl { color: var(--good); }\n  .c { color: #6b7688; } .fn { color: var(--accent); } .kw { color: #ff7b9c; } .k { color: var(--good); }\n\n  \u002F* tabs (Map section) *\u002F\n  .tabs { display: inline-flex; gap: 4px; background: #0b0e14; border: 1px solid var(--line);\n    border-radius: 12px; padding: 4px; margin-bottom: 12px; }\n  [role=tab] { background: transparent; color: var(--muted); border: 0; border-radius: 8px;\n    padding: 7px 13px; font: inherit; font-weight: 600; cursor: pointer; }\n  [role=tab][aria-selected=\"true\"] { background: #ffffff14; color: var(--ink); }\n\n  .note { font-size: .82rem; color: var(--muted); background: #f0883e14;\n    border: 1px solid #f0883e40; border-radius: 10px; padding: 10px 12px; margin-bottom: 14px; }\n  .note[hidden] { display: none; }\n  .mapcode { font-size: .76rem; color: var(--ink); background: #0b0e14; border: 1px solid var(--line);\n    border-radius: 8px; padding: 8px 11px; margin-top: 12px; overflow-x: auto; }\n  .mapcode code { color: var(--good); }\n\n  @media (max-width: 520px) {\n    .code2 { grid-template-columns: 1fr; }\n  }","\"use strict\";\n  const $ = (id) => document.getElementById(id);\n\n  \u002F* ---- Polyfill: only if the runtime lacks the natives (older browser) ---- *\u002F\n  let polyfilled = false;\n  if (typeof Object.groupBy !== \"function\") {\n    Object.groupBy = (items, cb) => {\n      const out = Object.create(null); let i = 0;\n      for (const item of items) { const k = cb(item, i++); (out[k] ??= []).push(item); }\n      return out;\n    };\n    polyfilled = true;\n  }\n  if (typeof Map.groupBy !== \"function\") {\n    Map.groupBy = (items, cb) => {\n      const out = new Map(); let i = 0;\n      for (const item of items) {\n        const k = cb(item, i++);\n        const arr = out.get(k);\n        if (arr) arr.push(item); else out.set(k, [item]);\n      }\n      return out;\n    };\n    polyfilled = true;\n  }\n  if (polyfilled) $(\"polyNote\").hidden = false;\n\n  \u002F* ---- Data ---- *\u002F\n  const SEED = [\n    { name: \"Ship onboarding\",    status: \"done\",        priority: \"high\",   assignee: \"Ada\" },\n    { name: \"Write unit tests\",   status: \"todo\",        priority: \"medium\", assignee: \"Ben\" },\n    { name: \"Fix login bug\",      status: \"in-progress\", priority: \"high\",   assignee: \"Ada\" },\n    { name: \"Review PR #204\",     status: \"review\",      priority: \"low\",    assignee: \"Cid\" },\n    { name: \"Update the docs\",    status: \"todo\",        priority: \"low\",    assignee: \"Ben\" },\n    { name: \"Refactor cache\",     status: \"in-progress\", priority: \"medium\", assignee: \"Ada\" },\n    { name: \"Design empty state\", status: \"done\",        priority: \"medium\", assignee: \"Cid\" },\n    { name: \"Triage inbox\",       status: \"todo\",        priority: \"high\",   assignee: \"Ben\" },\n    { name: \"Deploy hotfix\",      status: \"review\",      priority: \"high\",   assignee: \"Cid\" },\n  ];\n  const VALUES = {\n    status:   [\"todo\", \"in-progress\", \"review\", \"done\"],\n    priority: [\"low\", \"medium\", \"high\"],\n    assignee: [\"Ada\", \"Ben\", \"Cid\", \"Dee\"],\n  };\n  const NAMES = [\"Patch the CI\", \"Rename the module\", \"Add a11y labels\", \"Bump dependencies\",\n                 \"Wire the webhook\", \"Split the bundle\", \"Cache the query\", \"Fix the flake\"];\n\n  let seq = 0;\n  const withId = (t) => ({ _id: ++seq, ...t });\n  let tasks = SEED.map(withId);\n  let key = \"status\";\n  let mapView = \"map\";\n  let flashId = null; \u002F\u002F task to pulse after a render\n\n  \u002F* ---- Small helpers ---- *\u002F\n  \u002F\u002F Stable colour per group name, so a bucket keeps its hue as data changes.\n  const hueOf = (s) => { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 360; };\n  const esc = (s) => String(s).replace(\u002F[&\u003C>]\u002Fg, (c) => ({ \"&\": \"&amp;\", \"\u003C\": \"&lt;\", \">\": \"&gt;\" }[c]));\n  const rand = (a) => a[Math.floor(Math.random() * a.length)];\n\n  function orderedKeys(grouped) {\n    const known = VALUES[key] || [];\n    const present = Object.keys(grouped);\n    const extras = present.filter((k) => !known.includes(k));\n    return [...known.filter((k) => present.includes(k)), ...extras];\n  }\n\n  function bucketHTML(label, items) {\n    const h = hueOf(label);\n    const rows = items.map((t) => {\n      const others = [\"status\", \"priority\", \"assignee\"].filter((f) => f !== key).map((f) => t[f]).join(\" · \");\n      return `\u003Cli data-id=\"${t._id}\">${esc(t.name)}\u003Cspan class=\"meta\">${esc(others)}\u003C\u002Fspan>\u003C\u002Fli>`;\n    }).join(\"\");\n    return `\u003Cdiv class=\"bucket\" style=\"--h:${h}\">\n      \u003Cdiv class=\"bhead\">\u003Cspan class=\"chip\">${esc(label)}\u003C\u002Fspan>\u003Cspan class=\"count\">${items.length}\u003C\u002Fspan>\u003C\u002Fdiv>\n      \u003Cul class=\"items\">${rows}\u003C\u002Ful>\u003C\u002Fdiv>`;\n  }\n\n  function flash() {\n    if (flashId == null) return;\n    const el = document.querySelector(`#buckets [data-id=\"${flashId}\"], #mapBuckets [data-id=\"${flashId}\"]`);\n    flashId = null;\n    if (!el || !el.animate) return;\n    el.animate(\n      [{ background: \"hsl(210 80% 60% \u002F .35)\" }, { background: \"transparent\" }],\n      { duration: 700, easing: \"ease-out\" }\n    );\n  }\n\n  \u002F* ---- Render: main buckets + caption ---- *\u002F\n  function renderBuckets() {\n    \u002F\u002F THE line the whole post is about:\n    const grouped = Object.groupBy(tasks, (t) => t[key]);\n    const order = orderedKeys(grouped);\n    $(\"buckets\").innerHTML = order.map((k) => bucketHTML(k, grouped[k])).join(\"\");\n    $(\"caption\").innerHTML =\n      `Grouped \u003Cb>${tasks.length}\u003C\u002Fb> tasks into \u003Cb>${order.length}\u003C\u002Fb> buckets by ` +\n      `\u003Ccode>${key}\u003C\u002Fcode> — one line, no \u003Ccode>reduce\u003C\u002Fcode>.`;\n  }\n\n  \u002F* ---- Render: the two code panels, with the live key substituted ---- *\u002F\n  function renderCode() {\n    $(\"codeOld\").innerHTML =\n`\u003Cspan class=\"lbl\">The old way — reduce\u003C\u002Fspan>\u003Cspan class=\"c\">\u002F\u002F bookkeeping every dev re-writes\u003C\u002Fspan>\nconst grouped = tasks.\u003Cspan class=\"fn\">reduce\u003C\u002Fspan>((acc, t) =&gt; {\n  (acc[t.\u003Cspan class=\"k\">${key}\u003C\u002Fspan>] ??= []).\u003Cspan class=\"fn\">push\u003C\u002Fspan>(t);\n  \u003Cspan class=\"kw\">return\u003C\u002Fspan> acc;\n}, {});`;\n    $(\"codeNew\").innerHTML =\n`\u003Cspan class=\"lbl\">Now — one line\u003C\u002Fspan>\u003Cspan class=\"c\">\u002F\u002F same result, native, self-documenting\u003C\u002Fspan>\nconst grouped = \u003Cspan class=\"fn\">Object.groupBy\u003C\u002Fspan>(tasks, t =&gt; t.\u003Cspan class=\"k\">${key}\u003C\u002Fspan>);`;\n  }\n\n  \u002F* ---- Render: the data table ---- *\u002F\n  function renderTable() {\n    $(\"tbody\").innerHTML = tasks.map((t) =>\n      `\u003Ctr class=\"row\" tabindex=\"0\" role=\"button\" data-id=\"${t._id}\" aria-label=\"${esc(t.name)}, click to change ${key}\">\n        \u003Ctd>${esc(t.name)}\u003C\u002Ftd>\n        \u003Ctd>${esc(t.status)}\u003C\u002Ftd>\n        \u003Ctd class=\"pri ${t.priority}\">${esc(t.priority)}\u003C\u002Ftd>\n        \u003Ctd>${esc(t.assignee)}\u003C\u002Ftd>\n      \u003C\u002Ftr>`).join(\"\");\n  }\n\n  \u002F* ---- Render: the Map.groupBy bonus (non-string keys) ---- *\u002F\n  \u002F\u002F Group by a shared BAND OBJECT keyed off priority. With real object keys,\n  \u002F\u002F Object.groupBy would stringify every one to \"[object Object]\" and collapse\n  \u002F\u002F them into a single bucket; Map.groupBy keeps the objects distinct.\n  const BANDS = { high: { label: \"🔴 Urgent\" }, medium: { label: \"🟡 Normal\" }, low: { label: \"🟢 Backlog\" } };\n  const bandOf = (t) => BANDS[t.priority];\n\n  function renderMap() {\n    if (mapView === \"map\") {\n      const m = Map.groupBy(tasks, bandOf); \u002F\u002F Map { {label:\"🔴 Urgent\"} => [...], ... }\n      const entries = [...m.entries()].sort((a, b) => b[1].length - a[1].length);\n      $(\"mapCaption\").innerHTML =\n        `A real \u003Cb>Map\u003C\u002Fb> with \u003Cb>${entries.length}\u003C\u002Fb> object keys — reach them with ` +\n        `\u003Ccode>.get(band)\u003C\u002Fcode>. The objects stay distinct.`;\n      $(\"mapBuckets\").innerHTML = entries.map(([band, items]) => bucketHTML(band.label, items)).join(\"\");\n      $(\"mapCode\").innerHTML = `\u003Ccode>Map.groupBy(tasks, t =&gt; BANDS[t.priority])\u003C\u002Fcode>`;\n    } else {\n      const bad = Object.groupBy(tasks, bandOf); \u002F\u002F { \"[object Object]\": [...everything...] }\n      const only = Object.keys(bad);\n      $(\"mapCaption\").innerHTML =\n        `\u003Cb>Everything collapsed into 1 bucket.\u003C\u002Fb> Object keys get stringified to the same ` +\n        `\u003Ccode>\"[object Object]\"\u003C\u002Fcode> — identity is lost. This is why \u003Cb>Map.groupBy\u003C\u002Fb> exists.`;\n      $(\"mapBuckets\").innerHTML = only.map((k) => bucketHTML(k, bad[k])).join(\"\");\n      $(\"mapCode\").innerHTML = `\u003Ccode>Object.groupBy(tasks, t =&gt; BANDS[t.priority])\u003C\u002Fcode>`;\n    }\n  }\n\n  function renderAll() {\n    renderBuckets();\n    renderCode();\n    renderTable();\n    renderMap();\n    $(\"hintKey\").textContent = key;\n    flash();\n  }\n\n  \u002F* ---- Interactions ---- *\u002F\n  \u002F\u002F Segmented control → pick the grouping key.\n  document.querySelectorAll(\".seg button\").forEach((b) => {\n    b.addEventListener(\"click\", () => {\n      key = b.dataset.key;\n      document.querySelectorAll(\".seg button\").forEach((x) =>\n        x.setAttribute(\"aria-pressed\", String(x === b)));\n      renderAll();\n    });\n  });\n\n  \u002F\u002F Click a task row → cycle its value for the active key; it hops buckets.\n  function cycleRow(id) {\n    const t = tasks.find((x) => x._id === Number(id));\n    if (!t) return;\n    const opts = VALUES[key];\n    t[key] = opts[(opts.indexOf(t[key]) + 1) % opts.length];\n    flashId = t._id;\n    renderAll();\n  }\n  $(\"tbody\").addEventListener(\"click\", (e) => {\n    const row = e.target.closest(\".row\"); if (row) cycleRow(row.dataset.id);\n  });\n  $(\"tbody\").addEventListener(\"keydown\", (e) => {\n    if (e.key !== \"Enter\" && e.key !== \" \") return;\n    const row = e.target.closest(\".row\"); if (row) { e.preventDefault(); cycleRow(row.dataset.id); }\n  });\n\n  \u002F\u002F Add a random task → groups recompute live.\n  $(\"add\").addEventListener(\"click\", () => {\n    const t = withId({ name: rand(NAMES), status: rand(VALUES.status),\n      priority: rand(VALUES.priority), assignee: rand(VALUES.assignee) });\n    tasks.push(t);\n    flashId = t._id;\n    renderAll();\n  });\n\n  \u002F\u002F Reset back to the seed data.\n  $(\"reset\").addEventListener(\"click\", () => { tasks = SEED.map(withId); renderAll(); });\n\n  \u002F\u002F Bonus tabs → Map vs Object.\n  document.querySelectorAll(\"[role=tab]\").forEach((tab) => {\n    tab.addEventListener(\"click\", () => {\n      mapView = tab.dataset.view;\n      document.querySelectorAll(\"[role=tab]\").forEach((x) =>\n        x.setAttribute(\"aria-selected\", String(x === tab)));\n      renderMap();\n    });\n  });\n\n  renderAll();",{"height":40,"autorun":17},520,{"slug":33,"title":42,"excerpt":43,"coverUrl":44},"You've been writing this `reduce` a hundred times. `Object.groupBy` does it in one.","Grouping an array by a key is one of the most common data transforms in frontend code. You've been wiring it by hand with `reduce` for years. `Object.groupBy` is the native version — no helper, no library, no ceremony.","\u002Fmedia\u002Fcovers\u002Fobject-groupby-replace-reduce.png"]