VOOZH about

URL: https://dev.to/linou518/making-your-spa-remember-state-with-localstorage-3-patterns-and-their-pitfalls-30jo

⇱ Making Your SPA Remember State with localStorage — 3 Patterns and Their Pitfalls - DEV Community


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.