DEV Community

linou518
linou518

Posted on

Making Your SPA Remember State with localStorage — 3 Patterns and Their Pitfalls

Making Your SPA Remember State with localStorage — 3 Patterns and Their Pitfalls

In a vanilla JavaScript SPA without build tools, you want the page to return to "where you were" after a reload. React has zustand + persist, Vue has pinia-plugin-persistedstate, but without a framework you're writing raw localStorage calls.

Running a home lab dashboard (single-file SPA, ~3000 lines), I use localStorage for three distinct purposes. Here are the patterns and the pitfalls I only discovered after implementing them.


Pattern 1: Persisting View State

The most common SPA annoyance — pressing F5 dumps you back to the home page.

function showView(viewName) {
  document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
  document.getElementById(viewName + "View").classList.add("active");

  // Save the current view
  localStorage.setItem('dashboardCurrentView', viewName);
}

document.addEventListener("DOMContentLoaded", function() {
  const savedView = localStorage.getItem('dashboardCurrentView');
  if (savedView) {
    const el = document.getElementById(savedView + "View");
    if (el) {
      showView(savedView);
    } else {
      showView('simple-tasks'); // fallback
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Pitfall: A saved view ID that no longer exists. If you delete or rename a view, getElementById returns null and blows up. Never trust saved values — always verify the element exists in the DOM. Keep a hardcoded fallback too.


Pattern 2: Theme Persistence

There are 5 theme colors with a toggle button. If localStorage has no saved value, fall back to a date-based random selection.

const themes = ["", "theme-blue", "theme-green", "theme-pink", "theme-purple"];
let currentTheme = 0;

function switchTheme() {
  document.body.className = "";
  currentTheme = (currentTheme + 1) % themes.length;
  if (themes[currentTheme]) document.body.classList.add(themes[currentTheme]);
  localStorage.setItem("dashboardTheme", currentTheme);
}

function loadTheme() {
  const saved = localStorage.getItem("dashboardTheme");
  currentTheme = saved !== null
    ? parseInt(saved)
    : new Date().getDate() % themes.length;
  if (themes[currentTheme]) document.body.classList.add(themes[currentTheme]);
}

loadTheme(); // Execute immediately, don't wait for DOMContentLoaded
Enter fullscreen mode Exit fullscreen mode

Pitfall: FOUC (Flash of Unstyled Content). If you load the theme inside DOMContentLoaded, the default theme flashes briefly before switching. Run loadTheme() at script load time and place the <script> tag as close to the top of <body> as possible. You could also add style="visibility:hidden" to <html> and remove it via JS, but that feels like overkill.


Pattern 3: API Response Caching (Stale-While-Revalidate)

An API that fetches server status across multiple nodes runs SSH internally, taking several seconds. The user stares at a blank screen in the meantime.

Solution: Stale-While-Revalidate pattern. Stuff the previous response into localStorage. On next access, render the cache first → fetch the API in the background → swap in fresh data.

const CACHE_KEY = 'nodes_cache';

async function fetchNodes() {
  // Step 1: Render cache if available (avoid blank screen)
  const cached = localStorage.getItem(CACHE_KEY);
  if (cached) {
    try {
      nodes = JSON.parse(cached);
      renderAll();  // Stale data is better than nothing
    } catch(e) {}
  }

  // Step 2: Fetch latest data in the background
  try {
    const r = await fetch("/api/nodes-status");
    if (r.ok) {
      const data = await r.json();
      nodes = data.nodes;
      localStorage.setItem(CACHE_KEY, JSON.stringify(data.nodes));
      renderAll();  // Re-render with fresh data
    }
  } catch(e) {
    console.error("API error", e);
  }

  // Step 3: No cache, no API — fall back to hardcoded data
  if (!cached) {
    nodes = getLocalNodeData();
    renderAll();
  }
}
Enter fullscreen mode Exit fullscreen mode

Pitfall: localStorage's 5MB limit. Each node status JSON is only a few KB, but combined with other uses (task data, project info, etc.) you approach the ceiling faster than expected. Without a try/catch for QuotaExceededError, a failed cache write can crash the entire app.

Another issue: no cache freshness management. Leave the browser open for a week and the first render shows week-old data. Saving a timestamp alongside the cache and skipping it when too stale is on the todo list.


Design Decision: Why localStorage Over sessionStorage

sessionStorage clears when the tab closes. This dashboard is used as "open in the morning, leave it running all day, occasionally reload" — so localStorage keeping settings across tab closures felt more natural.

On the flip side, security-sensitive data (API tokens, etc.) should never go in localStorage. This dashboard is LAN-only so the tradeoff is acceptable, but for a public service you'd want httpOnly cookies or server-side sessions.


Summary

Pattern What's Stored Watch Out For
View state String (view name) DOM existence check required
Theme Number (index) FOUC — execute immediately
API cache JSON string 5MB limit + freshness management

This is essentially doing by hand what framework persist plugins do automatically. But with vanilla JS, you can see exactly what gets persisted and how — that's a feature, not a bug. Three lines of getItem/setItem are easier to debug than a black-box middleware layer.

Top comments (0)