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
}
}
});
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
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();
}
}
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)