데브코스

[Day 15] Todo App, 이벤트 위임, 낙관적 업데이트

라다디 2022. 11. 7. 22:18

To do App 만들기

요구사항

  • Users API를 통해 사용자 목록을 불러오고, 클릭하면 해당 사용자의 todo 목록을 가져옴
  • 할일을 추가하면 화면에 추가되고, API 호출을 통해 서버에도 추가
  • TODO를 추가하고 삭제하는 동안 낙관적 업데이트를 사용
  • 서버와 통신하는 동안 서버와 통신중임을 알리는 UI 처리

 

더보기
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Todo App</title>
  </head>
  <body>
    <main id="app" style="display: flex; flex-direction: row"></main>
    <script src="/src/main.js" type="module"></script>
  </body>
</html>
더보기
import App from "./App.js";

const $target = document.querySelector("#app");

new App({
  $target,
});
더보기
const storage = window.localStorage;

export const setItem = (key, value) => {
  try {
    storage.setItem(key, JSON.stringify(value));
    console.log(key, value);
  } catch (e) {
    console.log(e);
  }
};

export const getItem = (key, defaultValue) => {
  try {
    const storedValue = storage.getItem(key);

    if (!storedValue) {
      return defaultValue;
    }

    const pasedValue = JSON.parse(storedValue);
    return pasedValue;
  } catch (e) {
    return defaultValue;
  }
};

export const removeItem = (key) => {
  storage.removeItem(key);
};
더보기
export const API_END_POINT = "...";

export const request = async (url, options = {}) => {
  try {
    const res = await fetch(`${API_END_POINT}${url}`, {
      ...options,
      headers: {
        "Content-Type": "application/json",
      },
    });
    if (res.ok) {
      const json = await res.json();
      return json;
    }
    throw new Error("API 호출 오류");
  } catch (e) {
    alert(e.message);
  }
};
더보기
export const parse = (querystring) =>
querystring.split("&").reduce((acc, keyAndValue) => {
    const [key, value] = keyAndValue.split("=");
    if (key && value) {
      acc[key] = value;
    }
    return acc;
  }, {});
더보기
import { request } from "./api.js";
import UserList from "./UserList.js";
import Header from "./Header.js";
import TodoForm from "./TodoForm.js";
import TodoList from "./TodoList.js";
import { parse } from "./querystring.js";

export default function App({ $target }) {
  const $userListContainer = document.createElement("div");
  const $todoListContainer = document.createElement("div");

  $target.appendChild($userListContainer);
  $target.appendChild($todoListContainer);

  this.state = {
    userList: [],
    selectedUsername: null,
    todos: [],
    isTodoLoading: false,
  };

  const userList = new UserList({
    $target: $userListContainer,
    initialState: this.state.userList,
    onSelect: async (username) => {
      history.pushState(null, null, `/?selectedUsername=${username}`);
      this.setState({
        ...this.state,
        selectedUsername: username,
      });
      await await fetchTodos();
    },
  });

  const header = new Header({
    $target: $todoListContainer,
    initialState: {
      isLoading: this.state.isTodoLoading,
      selectedUsername: this.state.selectedUsername,
    },
  });

  new TodoForm({
    $target: $todoListContainer,
    onSubmit: async (content) => {
      const isFirstTodoAdd = this.state.todos.length === 0;

      const todo = {
        content,
        isCompleted: false,
      };
      this.setState({
        ...this.state,
        todos: [...this.state.todos, todo],
      });

      await request(`/${this.state.selectedUsername}`, {
        method: "POST",
        body: JSON.stringify(todo),
      });
      await fetchTodos();

      if (isFirstTodoAdd) {
        await fetchUserList();
      }
    },
  });

  this.setState = (nextState) => {
    this.state = nextState;

    header.setState({
      isLoading: this.state.isTodoLoading,
      selectedUsername: this.state.selectedUsername,
    });

    todoList.setState({
      isLoading: this.state.isTodoLoading,
      todos: this.state.todos,
      selectedUsername: this.state.selectedUsername,
    });

    userList.setState(this.state.userList);
    this.render();
  };

  this.render = () => {
    const { selectedUsername } = this.state;
    $todoListContainer.style.display = selectedUsername ? "block" : "none";
  };

  const todoList = new TodoList({
    $target: $todoListContainer,
    initialState: {
      isLoading: this.state.isTodoLoading,
      todos: this.state.todos,
      selectedUsername: this.state.selectedUsername,
    },
    onToggle: async (id) => {
      const todoIndex = this.state.todos.findIndex((todo) => todo._id === id);

      const nextTodos = [...this.state.todos];
      nextTodos[todoIndex].isCompleted = !nextTodos[todoIndex].isCompleted;
      this.setState({
        ...this.state,
        todos: nextTodos,
      });

      await request(`/${this.state.selectedUsername}/${id}/toggle`, {
        method: "PUT",
      });
      await fetchTodos();
    },
    onRemove: async (id) => {
      const todoIndex = this.state.todos.findIndex((todo) => todo._id === id);

      const nextTodos = [...this.state.todos];
      nextTodos.splice(todoIndex, 1);
      this.setState({
        ...this.state,
        todos: nextTodos,
      });

      await request(`/${this.state.selectedUsername}/${id}?`, {
        method: "DELETE",
      });
      await fetchTodos();
    },
  });

  const fetchUserList = async () => {
    const userList = await request("/users");
    this.setState({
      ...this.state,
      userList,
    });
  };

  const fetchTodos = async () => {
    const { selectedUsername } = this.state;

    if (selectedUsername) {
      this.setState({
        ...this.state,
        isTodoLoading: true,
      });
      const todos = await request(`/${selectedUsername}`);
      this.setState({
        ...this.state,
        todos,
        isTodoLoading: false,
      });
    }
  };

  const init = async () => {
    await fetchUserList();

    // url에 특정 사용자를 나타내는 값이 있을 경우
    const { search } = location;

    if (search.length > 0) {
      const { selectedUsername } = parse(search.substring(1));

      if (selectedUsername) {
        this.setState({
          ...this.state,
          selectedUsername,
        });
        await fetchTodos();
      }
    }
  };

  this.render();
  init();

  window.addEventListener("popstate", () => {
    init();
  });
}
더보기
export default function Header({ $target, initialState }) {
  const $h2 = document.createElement("h2");

  $target.appendChild($h2);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    const { selectedUsername, isLoading } = this.state;
    if (!selectedUsername) {
      $h2.innerHTML = "";
      return;
    }
    $h2.innerHTML = `
      ${selectedUsername} 님의 할 일 목록 ${isLoading ? "로딩 중..." : ""}
    `;
  };

  this.render();
}
더보기
export default function TodoList({
  $target,
  initialState,
  onToggle,
  onRemove,
}) {
  const $todo = document.createElement("div");

  $target.appendChild($todo);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    const { isLoading, todos } = this.state;

    if (!isLoading && todos.length === 0) {
      $todo.innerHTML = "Todo가 없습니다!";
      return;
    }

    $todo.innerHTML = `
      <ul>${todos
        .map(
          ({ _id, content, isCompleted }) => `
        <li data-id=${_id} class="todo-item">
          ${isCompleted ? `<s>${content}</s>` : content}
          <button class="remove">x</button>
        </li>
      `
        )
        .join("")}</ul>
    `;
  };

  $todo.addEventListener("click", (e) => {
    const $li = e.target.closest(".todo-item");

    if ($li) {
      const { id } = $li.dataset;
      const { className } = e.target;
      if (className === "remove") {
        onRemove(id);
      } else {
        onToggle(id);
      }
    }
  });

  this.render();
}
더보기
import { setItem, getItem, removeItem } from "./storage.js";

const TODO_TEMP_SAVE_KEY = "TODO_TEMP_SAVE_KEY";

export default function TodoForm({ $target, onSubmit }) {
  const $form = document.createElement("form");

  $target.appendChild($form);

  this.render = () => {
    $form.innerHTML = `
      <input type="text" placeholder="할 일을 입력하세요.">
      <button>추가하기</button>
    `;
  };

  $form.addEventListener("submit", (e) => {
    e.preventDefault();

    const $input = $form.querySelector("input");
    const content = $input.value;

    onSubmit(content);

    $input.value = "";
    removeItem(TODO_TEMP_SAVE_KEY);
  });

  this.render();

  const $input = $form.querySelector("input");
  $form.querySelector("input").value = getItem(TODO_TEMP_SAVE_KEY, "");

  $input.addEventListener("keyup", (e) => {
    setItem(TODO_TEMP_SAVE_KEY, e.target.value);
  });
}
더보기
export default function UserList({ $target, initialState, onSelect }) {
  const $userList = document.createElement("div");
  $target.appendChild($userList);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    $userList.innerHTML = `
      <h1>Users</h1>
      <ul>
        ${this.state.map((username) => 
          `<li data-username=${username}>${username}</li>`
          ).join("")}
          <li>
            <form>
              <input class="new-user" type="text" placeholder="add username">
            </form>
          </li>
      </ul>
    `;
  };

  this.render();

  $userList.addEventListener("click", (e) => {
    const $li = e.target.closest("li[data-username]");

    if ($li) {
      const { username } = $li.dataset;
      onSelect(username);
    }
  });

  $userList.addEventListener("submit", (e) => {
    const $newUser = $userList.querySelector(".new-user");
    const newUserValue = $newUser.value;

    if (newUserValue.length > 0) {
      onSelect($newUser.value);
      $newUser.value = "";
    }
  });
}

 

이벤트 위임(delegation)

이벤트 위임을 사용하면 요소마다 핸들러를 할당하지 않고, 요소의 공통 조상에 이벤트 핸들러를 단 하나만 할당하여 여러 요소를 한번에 다룰 수 있다.

공통 조상에 할당한 핸들러에서 e.target을 이용하면 실제 이벤트가 어디에서 발생했는지 알 수 있다.

// TodoList.js
...
    $todo.innerHTML = `
      <ul>${todos.map(({ _id, content, isCompleted }) => 
        `<li data-id=${_id} class="todo-item">
          ${isCompleted ? `<s>${content}</s>` : content}
          <button class="remove">x</button>
        </li>`
      ).join("")}</ul>
    `;
  };

  $todo.addEventListener("click", (e) => {
    const $li = e.target.closest(".todo-item");

    if ($li) {
      const { id } = $li.dataset;
      const { className } = e.target;
      if (className === "remove") {
        onRemove(id);
      } else {
        onToggle(id);
      }
    }
  });
  ...

 

낙관적 업데이트

낙관적 업데이트는 요청이 성공할 것이라고 낙관적으로 보고 화면을 먼저 갱신한 다음 서버에 요청을 보내는 것이다. 

 

낙관적 업데이트: 사용자 요청 → 화면 갱신 → 서버에 수정 요청 (실패 시 이전 상태로 되돌리기)

비관적 업데이트: 사용자 수정 요청 서버에 수정 요청 성공하면 화면 갱신

 

낙관적 업데이트는 사용자의 경험을 존중하기 위해 실시간 업데이트가 필요하거나, 요청이 실패되어도 위험도가 낮은 좋아요 버튼과 같은 것들에 주로 사용된다.

// App.js
...
  new TodoForm({
   ...
    onSubmit: async (content) => {
    ...
      // 낙관적 업데이트
      const todo = {
        content,
        isCompleted: false,
      };
      // 서버 요청 전 상태를 먼저 갱신
      this.setState({
        ...this.state,
        todos: [...this.state.todos, todo],
      });
      
      // 서버 요청
      await request(`/${this.state.username}`, {
        method: "POST",
        body: JSON.stringify(todo),
      });
      
      await fetchTodos();
    },
  });
 ...

 


출처: 프로그래머스 프론트엔드 데브코스 

[Day 15] VanillaJS를 통한 자바스크립트 기본 역량 강화 I (7)