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.

localStoragesessionStorage
LifetimePermanent (until you clear it or the user does)Until the tab closes
SharingAll tabs of the same originWithin the same tab only
CapacityTypically 5~10MBTypically 5~10MB

Basic usage #

localStorage basics
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.

String conversion
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.

Storing an object
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.

storage wrapper
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 #

Quota pitfall
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.

storage event
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.

IndexedDB characteristics
- Capacity: typically hundreds of MB ~ GB
- API: asynchronous
- Structure: key-value, can have indexes
- Supports transactions

The 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.

localStorageCookie
Auto-sendNoAutomatic on every request
Size5~10MB4KB
JavaScript accessAlwaysNot for HttpOnly cookies
SecurityExposed to XSSRecommend 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 #

Simple store
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 #

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.

Debounced persistence
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.

Multi-tab sync
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.

BroadcastChannel
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 #

Anti-pattern
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.

X