🛠️ Todolist: Deleting tasks with Optimistic UI

Hey ,

I'm thrilled to help you learn JavaScript. Unfortunately, you've landed on a page where you cannot access with your current purchase.

Please upgrade (use this link) access this content.

I'm super eager to help you learn more!

🛠️ Todolist: Deleting tasks with Optimistic UI

When we delete tasks the Optimistic UI way, we need to:

  1. Delete the task from the DOM
  2. Send a DELETE request to the server
  3. Handle the loading state
  4. Handle the loaded state
  5. Handle any errors that surface

Now, this sounds straightforward. But it isn’t.

Let’s walk through the steps and you’ll see why.

Optimistic UI for deleting tasks: First try

Deleting a task from the DOM

When a user clicks on the delete icon, we want to remove the task from the DOM. This should be easy since you’ve done it before.

taskList.addEventListener('click', event => {
  if (!event.target.matches('.task__delete-button')) return

  const taskElement = event.target.parentElement
  taskList.removeChild(taskElement)

  // Triggers empty state
  if (taskList.children.length === 0) taskList.innerHTML = ''
})

Send a delete request

To send a DELETE request, we need to know the task’s id. We can find the task’s id from its checkbox.

taskList.addEventListener('click', event => {
  // ...
  const checkbox = taskElement.querySelector('input[type="checkbox"]')
  const id = checkbox.id
})

Once we have the id, we can send a DELETE request.

taskList.addEventListener('click', event => {
  // ...
  zlFetch.delete(`${rootendpoint}/tasks/${id}`, { auth })
    .then(response => console.log(response))
    .catch(error => console.log(error))
})

Handle the loading state

When we added a task, we used a spinner to tell users we’re adding their task to the database.

When we delete tasks, we don’t need a loading state because users won’t interact with the task anymore. This means we don’t need to do anything for the loading state.

Handle the loaded state

We need to remove the deleted task from state.tasks.

First, we need to find the location of the task in state.tasks. We can find this location with findIndex.

taskList.addEventListener('click', event => {
  // ...
  zlFetch.delete(`${rootendpoint}/tasks/${id}`, { auth })
    .then(response => {
      const index = state.tasks.findIndex(t => t.id === id)
     })
    .catch(error => console.log(error))
})

Then, we remove the task from state.tasks by using a combination of spread operators and slice. (Read this lesson again if this step sounds confusing).

taskList.addEventListener('click', event => {
  // ...
  zlFetch.delete(`${rootendpoint}/tasks/${id}`, { auth })
    .then(response => {
      const index = state.tasks.findIndex(t => t.id === id)
      state.tasks = [
        ...state.tasks.slice(0, index),
        ...state.tasks.slice(index + 1)
      ]
     })
    .catch(error => console.log(error))
})

Handling errors

If an error occurs, we need to:

  1. Create an error message
  2. Restore the task back to the DOM

It’s easy to create an error message. You already know what to do.

taskList.addEventListener('click', event => {
  // ...
  zlFetch.delete(/*...*/)
    .catch(error => {
      let errorMessage = ''
      const { message } = error.body
      if (message === 'TypeError: Failed to fetch') {
        errorMessage = 'Failed to reach server. Please try again later.'
      } else if (message === 'Unauthorized') {
        errorMessage = 'Invalid username or password. Please check your username or password.'
      } else {
        errorMessage = error.body.message
      }

      const errorElement = makeErrorElement(errorMessage)
      flashContainer.appendChild(errorElement)
    })
})

We also need to restore the task back to the DOM. Here, we want to restore the task back to its original location.

If the user deletes one item at a time, restoring the task back to its original location is easy. We can use insertBefore to insert the task back to the original location.

To use insertBefore, you need to know the next task. To get the next task, you can use nextElementSibling.

If the deleted task is the last task, we need to use appendChild instead.

taskList.addEventListener('click', event => {
  // ...
  const nextTaskElement = taskElement.nextElementSibling

  zlFetch.delete(/*...*/)
    .catch(error => {
      // ...
      if (nextTaskElement) {
        taskList.insertBefore(taskElement, nextTaskElement)
      } else {
        taskList.appendChild(taskElement)
      }
  })
})
Deletes a task. Shows error. Task returned to original position.

Unfortunately, things go haywire if users delete more than one task at a time. We won’t be able to put the deleted task back to where it belongs.

(I disabled error messages for the gif below).

Unable to restore the original task positions

We need to use another method.

Optimistic UI for deleting tasks: Second try

Here’s a possible method:

  1. We hide the task when user clicks the delete button.
  2. Then, we send a DELETE request to the server.
  3. If successful response: we delete task from DOM.
  4. If error response: we show the hidden task.

Hiding the task

To hide the task, we can use the hidden attribute.

taskList.addEventListener('click', event => {
  // ...
  const taskElement = event.target.parentElement
  taskElement.setAttribute('hidden', true)
})

Sending the delete request

Next, we get the task’s id to send a DELETE request.

taskList.addEventListener('click', event => {
  // ...
  const checkbox = taskElement.querySelector('input[type="checkbox"]')
  const id = checkbox.id

  zlFetch.delete(`${rootendpoint}/tasks/${id}`, { auth })
    .then(response => console.log(response))
    .catch(error => console.log(error))
})

Handling a successful response

When we receive a successful response, we delete the task.

taskList.addEventListener('click', event => {
  // ...

  zlFetch.delete(/*...*/)
    .then(response => {
      // Deletes task
      taskList.removeChild(taskElement)
    })
    .catch(error => console.log(error))
})

We also update state.tasks.

taskList.addEventListener('click', event => {
  // ...

  zlFetch.delete(/*...*/)
    .then(response => {
      // ...
      // Update state.tasks
      const index = state.tasks.findIndex(t => t.id === id)
      state.tasks = [
        ...state.tasks.slice(0, index),
        ...state.tasks.slice(index + 1)
      ]
    })
    .catch(error => console.log(error))
})

Handling errors

If we get an error, we show the task by removing the hidden attribute.

taskList.addEventListener('click', event => {
  // ...

  zlFetch.delete(/*...*/)
    .then(/*...*/)
    .catch(error => {
      // Show the task
      taskElement.removeAttribute('hidden')
     })
})

We also create an error message.

taskList.addEventListener('click', event => {
  // ...

  zlFetch.delete(/*...*/)
    .then(/*...*/)
    .catch(error => {
      // ...
      taskElement.removeAttribute('hidden')

      // Create error message
      let errorMessage = ''
      const { message } = error.body
      if (message === 'TypeError: Failed to fetch') {
        errorMessage = 'Failed to reach server. Please try again later.'
      } else if (message === 'Unauthorized') {
        errorMessage = 'Invalid username or password. Please check your username or password.'
      } else {
        errorMessage = error.body.message
      }

      const errorElement = makeErrorElement(errorMessage)
      flashContainer.appendChild(errorElement)
     })
})

You can delete tasks in any order now. The deleted tasks will always go back to their rightful positions if an error occurs.

(I removed the error messages in the gif below).

Tasks go back to their rightful positions.

Fixing the empty state

The empty state shows up if taskList contains 0 children elements. It doesn’t show up if taskList contains children elements (even if they’re hidden).

Empty state doesn't show when tasks are hidden

We need it to show up if all children elements in taskList are hidden. The easiest way is to add an is-empty class to taskList.

We can use every to check if all tasks contain the hidden attribute.

every loops through an array of items. For each iteration, it checks if the callback is truthy. If the callback is truthy, it continues to the next iteration. If all iterations are truthy, every returns true. Otherwise, it returns false.

// Every example
const biggerThanTen = item => {
  return item > 10
}

const array1 = [15, 20, 25].every(biggerThanTen)
console.log(array1) // true

const array2 = [5, 10, 15].every(biggerThanTen)
console.log(array2) // false

We can use every like this:

taskList.addEventListener('click', event => {
  // ...
  const allTasksAreHidden = [...taskList.children]
    .every(element => {
      return element.hasAttribute('hidden')
    })
  // ...
})

If all tasks are hidden, we add the is-empty class. Otherwise, we remove it.

taskList.addEventListener('click', event => {
  // ...
  if (allTasksAreHidden) {
    taskList.classList.add('is-empty')
  } else {
    taskList.classList.remove('is-empty')
  }
  // ...
})

We’ll have to add this CSS for the code above to work.

.todolist__tasks.is-empty ~ .todolist__empty-state {
  display: block;
}
Empty state shows when hiding tasks.

If the server returns an error, we need to hide the empty state. We can copy-paste the code we wrote above into the .catch method to do this.

taskList.addEventListener('click', event => {
  // ...
  zlFetch.delete(/*...*/)
    .then(/*...*/)
    .catch({
      // ...
      // Checks empty state
      const allTasksAreHidden = [...taskList.children]
        .every(element => {
          return element.hasAttribute('hidden')
        })

      if (allTasksAreHidden) {
        taskList.classList.add('is-empty')
      } else {
        taskList.classList.remove('is-empty')
      }
    })
})

Hiding the empty state when adding tasks

Try deleting all your tasks. This should trigger the empty state. Once you see the empty state, try adding a task. You should still see the empty state.

Delete all tasks. Create a task. Empty state still shows.

This happens because we left the is-empty class in the HTML after we deleted tasks.

The best way to hide the empty state when adding tasks is to remove the is-empty class.

todolist.addEventListener('submit', event => {
  // ...

  // Hides the empty state
  taskList.classList.remove('is-empty')

  zlFetch.post(/*...*/)
    .then(/*...*/)
    .catch(/*...*/)
})

If the server responds with an error, we need to show the empty state again. We don’t have to do anything here because the :empty pseudo selector will take care of the rest.

That’s it!