State Management Patterns
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.
상태 관리는 UI가 커질수록 복잡해집니다. 단일 state 객체 패턴은 Claude Code가 생성하는 가장 깔끔한 구조입니다.
핵심 패턴
필터 가능한 리스트를 만들어줘.
단일 state 객체로 { category, query }를 관리해.
모든 사용자 인터랙션은 state를 업데이트하고 render()를 호출해.
DOM을 render() 외부에서 직접 변경하지 마.
이 패턴의 핵심:
state객체가 단일 진실의 원천render()함수가 state에서 DOM을 생성- 인터랙션은 state를 변경한 뒤 render() 호출
URL 동기화
현재 state를 URL params에 동기화해줘.
?category=gsap&q=scroll 처럼.
페이지 로드 시 URL에서 state 복원.
URL 동기화로 필터 상태를 공유 가능하게 만들 수 있습니다.
데모는 URL 동기화를 갖춘 카테고리, 정렬, 검색 필터가 포함된 필터 가능한 리스트를 보여줍니다.
/* ── 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();