JavaScript Practice #5 Local Storage and Lightweight State Management
In #4 Working with Forms you saw how to receive input and send it. This post covers where to store data and how to share it across multiple places — patterns that take you as far as you can go without a library.
Web Storage — localStorage / sessionStorage #
The standard tools for storing small amounts of data in the browser. There are two kinds.
| localStorage | sessionStorage | |
|---|---|---|
| Lifetime | Permanent (until you clear it or the user does) | Until the tab closes |
| Sharing | All tabs of the same origin | Within the same tab only |
| Capacity | Typically 5~10MB | Typically 5~10MB |
Basic usage #
localStorage.setItem('username', 'Curtis');
localStorage.getItem('username'); // 'Curtis'
localStorage.removeItem('username');
localStorage.clear(); // remove all
// Number of keys / key name
localStorage.length;
localStorage.key(0);sessionStorage has exactly the same API. The only difference is lifetime.
Always strings #
The biggest pitfall — values are always stored as strings.
localStorage.setItem('count', 42);
localStorage.getItem('count'); // '42' (string!)
typeof localStorage.getItem('count'); // 'string'
To store objects or arrays, you need to go through JSON serialization, as you saw in Intermediate #7 JSON.
const user = { id: 'u1', name: 'Curtis' };
localStorage.setItem('user', JSON.stringify(user));
const loaded = JSON.parse(localStorage.getItem('user'));
console.log(loaded.name); // 'Curtis'
A safe wrapper function #
It’s tedious to write JSON.parse/stringify every time. A small helper keeps things tidy.
const storage = {
get(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback;
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.warn('storage set failed:', err);
}
},
remove(key) {
localStorage.removeItem(key);
},
};
// Call site
storage.set('user', { id: 'u1', name: 'Curtis' });
const user = storage.get('user', { name: 'Anonymous' });
storage.remove('user');Even this much lets you handle the JSON.parse pitfall and quota errors in one place.
Pitfalls #
1) Quota overruns throw #
try {
localStorage.setItem('big', new Array(10_000_000).join('x'));
} catch (err) {
if (err.name === 'QuotaExceededError') {
console.warn('Storage is full');
}
}Exceeding the storage quota (typically 5~10MB) throws. You almost never hit this with small data, but in places where things accumulate like a cache, you have to be aware.
2) Private/incognito mode #
Some browsers block localStorage in incognito mode or set the quota to 0, causing setItem itself to throw. A wrapper that absorbs those errors is a good idea.
3) Values that don’t survive JSON #
The JSON pitfalls you saw in Intermediate #7 apply directly — undefined, functions, Symbol, BigInt, circular references. Convert before saving, or handle only serializable values.
4) Changes from another tab — the storage event
#
When storage changes in another tab of the same origin, an event fires.
window.addEventListener('storage', (e) => {
console.log(e.key, e.oldValue, e.newValue);
});Only changes from other tabs are caught — your own tab’s setItem does not fire this event. It’s the standard tool for multi-tab sync.
Larger data — IndexedDB #
localStorage caps at 5MB and is a synchronous API, so it’s a poor fit for big data. IndexedDB is the browser’s full database API.
- Capacity: typically hundreds of MB ~ GB
- API: asynchronous
- Structure: key-value, can have indexes
- Supports transactionsThe raw IndexedDB API is somewhat complex, so direct usage gets verbose. Small wrappers like idb-keyval are a common choice, or dexie for richer features. We won’t go deep here, but remember: when localStorage isn’t enough, IndexedDB is the next step.
Cookies — the old tool #
Cookies also store data, but the key feature is that they are automatically sent with every request.
| localStorage | Cookie | |
|---|---|---|
| Auto-send | No | Automatic on every request |
| Size | 5~10MB | 4KB |
| JavaScript access | Always | Not for HttpOnly cookies |
| Security | Exposed to XSS | Recommend HttpOnly + Secure |
For sensitive values like auth tokens, HttpOnly cookies are almost always the right answer. JavaScript can’t read them, so XSS attacks can’t steal the token.
JavaScript can also read cookies directly via document.cookie, but the API is awkward, so people typically reach for a library like js-cookie.
Lightweight state management — without a library #
How do you sync when many places read and modify the same data?
1) A single object + event publish #
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
get() {
return state;
},
set(updater) {
state = typeof updater === 'function' ? updater(state) : updater;
listeners.forEach((fn) => fn(state));
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
// Call site
const cart = createStore({ items: [] });
cart.subscribe((s) => {
cartCount.textContent = s.items.length;
});
addBtn.addEventListener('click', () => {
cart.set((prev) => ({ ...prev, items: [...prev.items, item] }));
});This is the pattern from Advanced #1 Closures. The state variable persists via closure, and the listeners set broadcasts change notifications. Libraries like Zustand are essentially a more polished version of this same idea.
2) Combined with localStorage — a persisted store #
function createPersistedStore(key, initialState) {
const stored = storage.get(key, initialState);
const store = createStore(stored);
store.subscribe((state) => {
storage.set(key, state);
});
return store;
}
const settings = createPersistedStore('settings', {
theme: 'light',
lang: 'ko',
});Whenever state changes, it’s saved to localStorage automatically. Restored on the next visit.
Debounce + persistence — handling frequent changes #
If state changes often, the cost of writing to localStorage every time adds up. Debouncing to coalesce them is better.
function createPersistedStore(key, initialState) {
const stored = storage.get(key, initialState);
const store = createStore(stored);
const persist = debounce((state) => {
storage.set(key, state);
}, 200);
store.subscribe(persist);
return store;
}It only writes once changes have been quiet for 200ms. Good for places where state changes frequently, like form input.
Syncing with other tabs #
To share a single store across multiple tabs of the same origin — receive the storage event and update the store.
function createSyncedStore(key, initialState) {
const store = createPersistedStore(key, initialState);
window.addEventListener('storage', (e) => {
if (e.key !== key || e.newValue === null) return;
try {
const newState = JSON.parse(e.newValue);
store.set(newState);
} catch {
// Ignore invalid JSON
}
});
return store;
}Add an item to the cart in tab A and tab B reflects it instantly — a small but impressive touch.
Broadcast Channel — modern inter-tab communication #
A more explicit tool than the storage event.
const channel = new BroadcastChannel('app');
// Send
channel.postMessage({ type: 'cart-updated', items: [...] });
// Receive
channel.addEventListener('message', (e) => {
console.log(e.data);
});The modern answer for inter-tab messaging. It communicates directly without depending on localStorage.
Don’t put things directly on Window #
window.appState = { ... }; // ✗ accessible from anywhere
window.handleClick = () => { ... };Pinning things directly on the global object is an old pattern. Since the module system arrived (Basics #7), importing an exported store is the standard. Avoid globals except for ephemeral debugging.
Wrapping up #
What this post covered:
- localStorage (permanent) vs sessionStorage (per-tab)
- Values are always strings — JSON serialization required
- Wrapper functions to handle parse/stringify and errors in one place
- Pitfalls: quota overrun, incognito mode, JSON limits, cross-tab changes
- For larger data, IndexedDB
- For sensitive values like tokens, HttpOnly cookies
- A lightweight store — closure + event listeners
- Persisted store combined with localStorage, debouncing to ease the load
- Multi-tab sync — the storage event or BroadcastChannel
The next post (#6 Building a Small App), the last in the series and track, ties all these tools together to build a small Todo app from start to finish without any libraries.