{"slug": "a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead", "title": "A pending-plugin-count badge on the 🔌 button — reusing the dashboard cache instead of doubling state", "summary": "A developer reused an existing dashboard cache to show a pending-plugin-count badge on each site's plugin button, avoiding a new API endpoint and doubling state management. The badge reads from the same localStorage-backed state that powers the cross-site updates dashboard, returning the length of each site's pending plugins array. The implementation caps the badge at '99+' and omits it for zero or unknown counts to reduce visual noise.", "body_md": "A client asked: \"**After I run a cross-site update check, can each site show — right in the site list — how many plugin updates are still pending?**\" Visually the answer was obvious: a small red badge on the top-right of the 🔌 plugins button, like an unread-notification count. Easy to specify.\n\nThe harder question was **where the data comes from**. We could have added a fresh API endpoint and a new cache to hold \"pending count per site.\" But doing that would have **doubled state management**, and we already had a cache that knew this. We routed through the existing one. Here's the reasoning behind that decision.\n\nThe cross-site updates dashboard (the one we wrote about in [killing the 24.5-second silence with a cache-first design](https://en.wpmm.jp/blog/cache-first-dashboard-ux/)) already kept each site's pending plugins in a localStorage-backed state called `_updatesDashState`\n\n. Its shape:\n\n```\n_updatesDashState = {\n  sites: [\n    { site_id: \"abc...\", plugins: [ {...}, {...}, {...} ] },\n    { site_id: \"def...\", plugins: [ ... ] },\n  ],\n  total_pending_count: 12,\n  loadedAt: 1748600000000,\n}\n```\n\nLook up by `site_id`\n\n, take `plugins.length`\n\n, and you have the badge's number. **No new API, no new cache.** The data that powers the cross-site dashboard is also the data that powers the site-list badge.\n\nThe win of not adding state is quiet but real:\n\n`_updatesDashState`\n\n, the badge There's always a temptation to spin up a new endpoint for a new UI element. The rule we settled on: if the existing state answers it, don't add more.\n\nBoth the list view and grid view need the same badge on the 🔌 button, so the logic lives in helpers.\n\n```\nfunction _getPendingPluginCountForSite(siteId) {\n  // null = unchecked (badge not shown); a number = the actual pending count\n  const entry = _updatesDashState.sites.find(s => s.site_id === siteId);\n  return entry ? entry.plugins.length : null;\n}\n\nfunction _attachPendingPluginCountBadge(pluginsBtn, siteId) {\n  const count = _getPendingPluginCountForSite(siteId);\n  if (count === null || count === 0) return;        // reduce noise\n  const display = count > 99 ? '99+' : String(count);\n  const badge = document.createElement('span');\n  badge.className = 'plugin-count-badge';\n  badge.textContent = display;\n  badge.title = _formatPendingPluginCountTooltip(count);\n  pluginsBtn.appendChild(badge);\n}\n```\n\nA small but easy-to-miss detail: the button itself needs `position: relative;`\n\nso the absolutely-positioned badge doesn't fly off the parent. Without it, the badge ends up in a corner of the screen.\n\nIf we showed a badge on every site — including \"zero pending\" and \"never checked\" — the site list would turn into a sea of icons. The two cuts we made:\n\n`null`\n\n, no badge attached. \"I don't know the count\" and \"the count is zero\" mean different things, and the UI should preserve that distinctionFor three-digit counts, the layout breaks unless you cap. We use `99+`\n\n, not `100+`\n\n— it keeps the badge width consistent across rows, and it's the convention readers already know from GitHub-style notification counters.\n\nPulling data only from the cross-site dashboard misses one path: \"**I clicked the 🔌 on a single site, looked inside, and now I want the badge updated**.\" The request was \"either path should refresh the badge,\" so the per-site check writes back to the same cache.\n\n```\nfunction _updatePendingPluginCacheForSite(site, plugins) {\n  // From /api/site_plugins, keep only \"update available\" — exclude must-use / dropin\n  const pending = plugins.filter(p =>\n    p.update === 'available' &&\n    p.status !== 'must-use' && p.status !== 'dropin'\n  );\n\n  const sites = _updatesDashState.sites;\n  const idx = sites.findIndex(s => s.site_id === site._id);\n\n  if (pending.length === 0 && idx >= 0) {\n    sites.splice(idx, 1);            // drop the entry entirely when count hits zero\n  } else if (pending.length > 0) {\n    const entry = { site_id: site._id, plugins: pending };\n    if (idx >= 0) sites[idx] = entry;\n    else sites.push(entry);\n  }\n\n  _updatesDashState.total_pending_count =\n    sites.reduce((sum, s) => sum + s.plugins.length, 0);\n  _saveUpdatesDashStateToLocalStorage();\n  filterSites();                      // immediately re-render the site list\n}\n```\n\nThe `must-use`\n\nand `dropin`\n\nexclusions matter — those plugins don't go through the standard WordPress update flow, and counting them would create a \"badge says update available, but the update button does nothing\" bug.\n\nThe trailing `filterSites()`\n\nre-renders the site list right then, so the new count is visible before the user even closes the modal. That \"yep, it took\" feedback is what makes the path feel solid.\n\nThree principles to take away:\n\nA site-list badge looks like a small feature, but underneath, the lessons that pay off are about **not adding state when you don't have to**, **picking thresholds that mean something**, and **converging your update paths**. Next time something small needs adding, the cheapest move is to ask: is the answer already in something we have?\n\nOne more issue surfaced later: when maintenance ends with an error, the badge disappears even if plugins were left unupdated — because the completion handler assumed \"done = zero remaining\" and deleted the cache entry. How that assumption broke and the redesign using a backend marker line to sync real counts to the frontend is in [When maintenance ends with an error, the plugin update badge disappears](https://en.wpmm.jp/blog/plugin-badge-real-count-after-maintenance/).", "url": "https://wpnews.pro/news/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead", "canonical_source": "https://dev.to/susumun/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead-of-doubling-state-20k8", "published_at": "2026-07-08 00:40:22+00:00", "updated_at": "2026-07-08 00:58:20.981943+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead", "markdown": "https://wpnews.pro/news/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead.md", "text": "https://wpnews.pro/news/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead.txt", "jsonld": "https://wpnews.pro/news/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead.jsonld"}}