앞선 글에서 배운 값을 모두 엮어 하나의 화면을 설계해 봅니다. 상태(state)는 화면 데이터를 뜻하고, 렌더(render)는 그 데이터를 HTML로 변환하는 과정이며, 이벤트는 사용자의 행동입니다. 본문은 핵심 루프(상태 ↔ 렌더)와 선택 확장(필터·불변성)으로 나뉘니, 우선 핵심을 확인한 후 필요한 부분을 덧붙이세요.
이번 글에서 새로 나오는 용어
- 상태(state): 지금 화면을 채우는 데이터 덩어리로, 배열·객체로 묶어 보관합니다.
- 렌더(render): 상태를 읽어 HTML 문자열이나 DOM 노드로 바꾸는 단계입니다.
- 불변성: 기존 배열을 직접 고치지 않고 복사본을 만들어 수정하는 원칙이라 디버깅이 쉬워집니다.
- crypto.randomUUID: 브라우저가 고유한 식별자를 만들어 주는 함수라 각 todo를 구분할 때 사용합니다.
핵심 개념
- 상태(state): 현재 UI를 구성하는 데이터 묶음입니다. 한 객체에 모아 두면 흐름을 추적하기 쉽습니다.
- 렌더 함수: 상태를 HTML 문자열이나 DOM 노드로 바꾸어 화면에 뿌리는 함수입니다.
- 이벤트 루프: 사용자 행동 → 상태 업데이트 → 렌더 → 새 화면으로 이어지는 순환입니다.
- 불변성:
map,filter, 스프레드(...)를 사용해 기존 배열을 직접 수정하지 않으면 버그를 줄일 수 있습니다.
코드로 확인하기
먼저 상태와 렌더만 있는 초간단 카운터로 미니 버전을 확인합니다.
const state = { count: 0 };
const output = document.querySelector(".count");
function render() {
output.textContent = `${state.count}회`;
}
function increase() {
state.count += 1;
render();
}
document.querySelector("button")?.addEventListener("click", increase);
render();
➡️ “데이터를 바꾸고 → 렌더를 호출한다”는 규칙만 지키면 UI가 따라온다는 점을 먼저 체감해 둡니다.
const state = {
todos: [],
filter: "all",
};
const elements = {
list: document.querySelector(".todo-list"),
form: document.querySelector(".todo-form"),
filterButtons: document.querySelectorAll("[data-filter]"),
};
DOM 참조를 객체에 저장하면 함수마다 다시 찾지 않아도 됩니다.
function getVisibleTodos() {
if (state.filter === "completed") return state.todos.filter((todo) => todo.completed);
if (state.filter === "active") return state.todos.filter((todo) => !todo.completed);
return state.todos;
}
function render() {
const visible = getVisibleTodos();
if (!elements.list) return;
elements.list.innerHTML = visible
.map(
(todo) => `
<li class="todo-item ${todo.completed ? "is-done" : ""}">
<label>
<input type="checkbox" data-id="${todo.id}" ${todo.completed ? "checked" : ""} />
<span>${todo.title}</span>
</label>
<button class="todo-remove" data-id="${todo.id}">삭제</button>
</li>
`,
)
.join("");
}
function addTodo(title) {
state.todos = [...state.todos, { id: crypto.randomUUID(), title, completed: false }];
render();
}
function toggleTodo(id) {
state.todos = state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
);
render();
}
function removeTodo(id) {
state.todos = state.todos.filter((todo) => todo.id !== id);
render();
}
function setFilter(filter) {
state.filter = filter;
render();
}
위 4개 함수는 핵심 루프입니다. 어떤 행동이든 “상태 복사 → 새 배열 저장 → render() 호출” 구조로 끝납니다.
elements.form?.addEventListener("submit", (event) => {
event.preventDefault();
const input = elements.form.querySelector("input[name=title]");
const value = input?.value.trim();
if (!value) return;
addTodo(value);
input.value = "";
});
elements.list?.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.matches("input[type=checkbox]")) toggleTodo(target.dataset.id ?? "");
if (target.matches(".todo-remove")) removeTodo(target.dataset.id ?? "");
});
elements.filterButtons.forEach((button) => {
button.addEventListener("click", () => {
setFilter(button.dataset.filter ?? "all");
});
});
render();
이벤트 리스너는 “사용자 입력을 읽어 적절한 상태 업데이트 함수를 호출한다”는 규칙만 따릅니다. 복잡해 보이지만 카운터 예제의 increase와 같은 역할입니다.
왜 중요한가
- 상태와 렌더를 분리하면 프레임워크 없이도 “컴포넌트” 사고를 연습할 수 있습니다.
- 이벤트가 상태를 바꾸고 렌더가 다시 화면을 채우는 과정을 익히면 React나 Svelte를 배울 때 훨씬 빠르게 이해합니다.
- 불변성을 지키면 언제 상태가 바뀌었는지 추적하기 쉬워, 디버깅 시간이 줄어듭니다.
실습
- 핵심 따라 하기:
state,render,add/toggle/remove함수를 그대로 작성해 Todo UI를 완성합니다. - 선택 확장하기:
localStorage에 상태를 저장·복구하거나 필터 버튼에is-active클래스를 붙여 시각적 피드백을 줍니다. - 디버깅:
state.todos.push를 사용해 의도적으로 버그를 만들고, 불변성 위반 시 어떤 문제가 생기는지 기록합니다. - 완료 기준: 추가·체크·삭제·필터 흐름을 모두 시연하고 새로고침 후에도 데이터가 유지되면 실습이 끝납니다.
마무리
상태 → 렌더 → 이벤트 → 상태라는 루프만 익숙하면 프레임워크 없이도 충분히 복잡한 UI를 제어할 수 있습니다. 다음 글에서는 폼 검증과 피드백 루프를 얹어 사용자 입력 흐름을 더 견고하게 만드는 방법을 살펴보겠습니다.
💬 댓글
이 글에 대한 의견을 남겨주세요