[JavaScript 시리즈 7편] 미니 프로젝트로 상태와 렌더링 감각 잡기

English version

앞선 글에서 배운 값을 모두 엮어 하나의 화면을 설계해 봅니다. 상태(state)는 화면 데이터를 뜻하고, 렌더(render)는 그 데이터를 HTML로 변환하는 과정이며, 이벤트는 사용자의 행동입니다. 본문은 핵심 루프(상태 ↔ 렌더)선택 확장(필터·불변성)으로 나뉘니, 우선 핵심을 확인한 후 필요한 부분을 덧붙이세요.

이번 글에서 새로 나오는 용어

  1. 상태(state): 지금 화면을 채우는 데이터 덩어리로, 배열·객체로 묶어 보관합니다.
  2. 렌더(render): 상태를 읽어 HTML 문자열이나 DOM 노드로 바꾸는 단계입니다.
  3. 불변성: 기존 배열을 직접 고치지 않고 복사본을 만들어 수정하는 원칙이라 디버깅이 쉬워집니다.
  4. 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를 제어할 수 있습니다. 다음 글에서는 폼 검증과 피드백 루프를 얹어 사용자 입력 흐름을 더 견고하게 만드는 방법을 살펴보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요