A pending-plugin-count badge on the 🔌 button — reusing the dashboard cache instead of doubling state 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. 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. The 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. The 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 . Its shape: updatesDashState = { sites: { site id: "abc...", plugins: {...}, {...}, {...} }, { site id: "def...", plugins: ... }, , total pending count: 12, loadedAt: 1748600000000, } Look up by site id , take plugins.length , 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. The win of not adding state is quiet but real: updatesDashState , 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. Both the list view and grid view need the same badge on the 🔌 button, so the logic lives in helpers. function getPendingPluginCountForSite siteId { // null = unchecked badge not shown ; a number = the actual pending count const entry = updatesDashState.sites.find s = s.site id === siteId ; return entry ? entry.plugins.length : null; } function attachPendingPluginCountBadge pluginsBtn, siteId { const count = getPendingPluginCountForSite siteId ; if count === null || count === 0 return; // reduce noise const display = count 99 ? '99+' : String count ; const badge = document.createElement 'span' ; badge.className = 'plugin-count-badge'; badge.textContent = display; badge.title = formatPendingPluginCountTooltip count ; pluginsBtn.appendChild badge ; } A small but easy-to-miss detail: the button itself needs position: relative; so the absolutely-positioned badge doesn't fly off the parent. Without it, the badge ends up in a corner of the screen. If 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: null , 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+ , not 100+ — it keeps the badge width consistent across rows, and it's the convention readers already know from GitHub-style notification counters. Pulling 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. function updatePendingPluginCacheForSite site, plugins { // From /api/site plugins, keep only "update available" — exclude must-use / dropin const pending = plugins.filter p = p.update === 'available' && p.status == 'must-use' && p.status == 'dropin' ; const sites = updatesDashState.sites; const idx = sites.findIndex s = s.site id === site. id ; if pending.length === 0 && idx = 0 { sites.splice idx, 1 ; // drop the entry entirely when count hits zero } else if pending.length 0 { const entry = { site id: site. id, plugins: pending }; if idx = 0 sites idx = entry; else sites.push entry ; } updatesDashState.total pending count = sites.reduce sum, s = sum + s.plugins.length, 0 ; saveUpdatesDashStateToLocalStorage ; filterSites ; // immediately re-render the site list } The must-use and dropin exclusions 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. The trailing filterSites re-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. Three principles to take away: A 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? One 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/ .