State Management Patterns

Lesson 17

Every UI has state — the current filter, the active tab, the search query. Where you store that state determines how shareable, bookmarkable, and maintainable your interface is. This lesson covers three layers: an in-memory state object, URL params for sharing, and localStorage for persistence across sessions.

The state object pattern

The most important pattern Claude Code generates reliably is the single source of truth + render function. Instead of scattering classList.toggle calls everywhere, you maintain one object and call a single render() that reads it.

Prompt: “Build a filterable list. Maintain a single state object with { category, sort, query }. Write a render() function that filters and displays items based on state. Every user interaction should update state then call render(). Never mutate the DOM directly outside of render().”

const state = { category: 'all', sort: 'newest', query: '' };

function render() {
  const filtered = items
    .filter(i => state.category === 'all' || i.category === state.category)
    .filter(i => i.title.toLowerCase().includes(state.query.toLowerCase()))
    .sort(sortFns[state.sort]);

  list.innerHTML = filtered.map(renderCard).join('');
  countEl.textContent = `${filtered.length} courses`;
}

When you give Claude this shape upfront, it wires every button, input, and dropdown to state mutations rather than ad-hoc DOM operations. The result is consistent and easy to extend.

URL params for shareable state

URLSearchParams is the native way to encode state in the URL. Combined with history.replaceState, you can keep the URL in sync without triggering a page reload.

Prompt: “After every state change, call syncURL() which uses URLSearchParams to write category, sort, and query into the URL. On page load, read those params with URLSearchParams and hydrate the state object before the first render().”

function syncURL() {
  const params = new URLSearchParams({ ...state });
  history.replaceState(null, '', '?' + params.toString());
}

// On load:
const p = new URLSearchParams(location.search);
if (p.get('category')) state.category = p.get('category');
if (p.get('sort'))     state.sort     = p.get('sort');
if (p.get('query'))    state.query    = p.get('query');

This makes every filter combination a shareable URL. Paste it in a new tab and the exact same view appears.

localStorage for persistence

localStorage is ideal for preferences that should survive a page reload but don’t need to be in the URL — last selected category, theme, sidebar state.

Prompt: “After updating state.category, save it to localStorage with key lastCategory. On init, read that key and fall back to 'all' if absent.”

localStorage.setItem('lastCategory', state.category);
// On load:
state.category = localStorage.getItem('lastCategory') || 'all';

Keep localStorage for sticky preferences and URL params for shareable filters — they serve different purposes and work well together.

What the demo shows

The demo is a filterable course list with all three layers working simultaneously. Category pill buttons, a sort dropdown, and a live search input all update a single state object, which calls render(). Every change syncs to the URL via history.replaceState — you can copy the URL and paste it in a new tab to restore the exact view. The last selected category is also saved to localStorage and restored on revisit. A share button copies the current URL to the clipboard.

Source Code script.js
/* ── Data ─────────────────────────────────────────────────────── */
const COURSES = [
  { id: 1,  title: 'GSAP ScrollTrigger Mastery',   category: 'gsap',   lessons: 12, popularity: 98 },
  { id: 2,  title: 'GSAP Stagger & Timeline',       category: 'gsap',   lessons: 8,  popularity: 85 },
  { id: 3,  title: 'GSAP SVG Animation',            category: 'gsap',   lessons: 10, popularity: 72 },
  { id: 4,  title: 'CSS Grid Layout Patterns',      category: 'css',    lessons: 9,  popularity: 91 },
  { id: 5,  title: 'CSS Scroll-Driven Animations',  category: 'css',    lessons: 7,  popularity: 88 },
  { id: 6,  title: 'CSS Blend Modes & Filters',     category: 'css',    lessons: 6,  popularity: 65 },
  { id: 7,  title: 'D3 Interactive Bar Charts',     category: 'd3',     lessons: 11, popularity: 79 },
  { id: 8,  title: 'D3 World Map & Globe',          category: 'd3',     lessons: 8,  popularity: 70 },
  { id: 9,  title: 'D3 Force-Directed Graphs',      category: 'd3',     lessons: 9,  popularity: 61 },
  { id: 10, title: 'Canvas 2D Particle Systems',    category: 'canvas', lessons: 7,  popularity: 83 },
  { id: 11, title: 'Canvas Physics Simulations',    category: 'canvas', lessons: 10, popularity: 77 },
  { id: 12, title: 'Canvas Generative Art',         category: 'canvas', lessons: 8,  popularity: 69 },
];

/* ── State ────────────────────────────────────────────────────── */
const state = {
  category: 'all',
  sort: 'newest',
  query: '',
};

/* ── Init from URL params ─────────────────────────────────────── */
(function hydrateFromURL() {
  const p = new URLSearchParams(location.search);
  if (p.get('category')) state.category = p.get('category');
  if (p.get('sort'))     state.sort     = p.get('sort');
  if (p.get('query'))    state.query    = p.get('query');
})();

/* ── Init from localStorage (last category fallback) ─────────── */
(function hydrateFromStorage() {
  // URL params take priority; only use localStorage if URL has no category
  const p = new URLSearchParams(location.search);
  if (!p.get('category')) {
    const saved = localStorage.getItem('lastCategory');
    if (saved) state.category = saved;
  }
})();

/* ── DOM refs ─────────────────────────────────────────────────── */
const countEl       = document.getElementById('count');
const courseList    = document.getElementById('courseList');
const emptyState    = document.getElementById('emptyState');
const categoryGroup = document.getElementById('categoryGroup');
const sortSelect    = document.getElementById('sortSelect');
const searchInput   = document.getElementById('searchInput');
const shareBtn      = document.getElementById('shareBtn');
const resetBtn      = document.getElementById('resetBtn');

/* ── Sorting functions ────────────────────────────────────────── */
const sortFns = {
  newest:  (a, b) => b.id - a.id,
  az:      (a, b) => a.title.localeCompare(b.title),
  popular: (a, b) => b.popularity - a.popularity,
};

/* ── Render ───────────────────────────────────────────────────── */
function render() {
  const q = state.query.toLowerCase();

  const filtered = COURSES
    .filter(c => state.category === 'all' || c.category === state.category)
    .filter(c => !q || c.title.toLowerCase().includes(q))
    .sort(sortFns[state.sort] || sortFns.newest);

  // Update count
  const n = filtered.length;
  countEl.textContent = `${n} course${n !== 1 ? 's' : ''}`;

  // Update course list
  if (filtered.length === 0) {
    courseList.innerHTML = '';
    emptyState.hidden = false;
  } else {
    emptyState.hidden = true;
    courseList.innerHTML = filtered.map(c => `
      <li class="course-item">
        <span class="course-tag tag-${c.category}">${c.category.toUpperCase()}</span>
        <div class="course-body">
          <div class="course-title">${c.title}</div>
          <div class="course-meta">${c.lessons} lessons</div>
        </div>
      </li>
    `).join('');
  }

  // Sync pill active state
  categoryGroup.querySelectorAll('.pill').forEach(pill => {
    pill.classList.toggle('active', pill.dataset.cat === state.category);
  });

  // Sync select & input
  sortSelect.value    = state.sort;
  searchInput.value   = state.query;

  syncURL();
  saveToStorage();
}

/* ── Sync URL ─────────────────────────────────────────────────── */
function syncURL() {
  const params = new URLSearchParams();
  if (state.category !== 'all') params.set('category', state.category);
  if (state.sort !== 'newest')  params.set('sort',     state.sort);
  if (state.query)              params.set('query',    state.query);

  const qs = params.toString();
  history.replaceState(null, '', qs ? '?' + qs : location.pathname);
}

/* ── Save to localStorage ─────────────────────────────────────── */
function saveToStorage() {
  localStorage.setItem('lastCategory', state.category);
}

/* ── Event wiring ─────────────────────────────────────────────── */
categoryGroup.addEventListener('click', e => {
  const pill = e.target.closest('.pill');
  if (!pill) return;
  state.category = pill.dataset.cat;
  render();
});

sortSelect.addEventListener('change', e => {
  state.sort = e.target.value;
  render();
});

searchInput.addEventListener('input', e => {
  state.query = e.target.value;
  render();
});

resetBtn.addEventListener('click', () => {
  state.category = 'all';
  state.sort     = 'newest';
  state.query    = '';
  render();
});

shareBtn.addEventListener('click', () => {
  navigator.clipboard.writeText(location.href).then(() => {
    shareBtn.textContent = 'Copied!';
    shareBtn.classList.add('copied');
    setTimeout(() => {
      shareBtn.textContent = '🔗 Share current filters';
      shareBtn.classList.remove('copied');
    }, 2000);
  }).catch(() => {
    // Fallback: select a temp input
    const tmp = document.createElement('input');
    tmp.value = location.href;
    document.body.appendChild(tmp);
    tmp.select();
    document.execCommand('copy');
    document.body.removeChild(tmp);
    shareBtn.textContent = 'Copied!';
    setTimeout(() => { shareBtn.textContent = '🔗 Share current filters'; }, 2000);
  });
});

/* ── Initial render ───────────────────────────────────────────── */
render();