Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 58

Refactoring to Multi-Component Architecture

Recommended resources

Hello! Welcome to the mini-project for our module on Vue Components.

In our previous lessons, we've covered the essential tools for building complex applications with Vue:

  • Props: For passing data down from parent to child.
  • Events: For communicating up from child to parent.
  • Slots: For creating flexible and reusable layout components.

We also discussed the architectural challenge of "prop drilling" and why it's a sign that a more robust state management solution might be needed. Today, we'll put all this theory into practice.

This lesson is fully hands-on, aligning with your preference for learning by building. Our goal is to refactor the single-file to-do app we built earlier into a clean, multi-component architecture. This is a critical skill for creating applications that are easy to maintain, scale, and reason about.

By the end of this project, you will have a to-do app that is broken down into logical, reusable components that communicate using the standard props-down, events-up pattern.


Part 1: Planning the New Architecture

Before writing any code, it's a best practice to plan your component structure. Let's look at our current to-do app's UI and identify the distinct, logical pieces.

Our app has:

  1. An input form to add new tasks.
  2. A list that displays the existing tasks.
  3. Each individual task in that list, with a checkbox, text, and a delete button.

This structure naturally suggests breaking the app into the following components:

  • App.vue: The root component. It will own the application's state (the array of tasks) and the logic for manipulating that state. It will act as the orchestrator.
  • TodoForm.vue: A "presentational" component responsible only for displaying the form. When the user submits the form, it will simply tell the parent component, "Hey, a new task was added with this text!"
  • TodoList.vue: Another presentational component. Its job is to receive a list of tasks and render them.
  • TodoItem.vue: The most granular component. It will receive a single task object and be responsible for displaying it. It will also tell its parent when the user wants to toggle its completion status or delete it.

This separation of concerns is a core principle of good software design. Each component has a single, clear responsibility.



Part 2: Hands-On Refactoring

Let's start refactoring. I'll guide you step-by-step. The process we'll follow is very common in real-world development.

Step 1: Create the Component Files

First, in your project's src/components directory, create the following three new files. For now, you can just give them a basic template and script block.

  1. TodoForm.vue
  2. TodoList.vue
  3. TodoItem.vue

Step 2: The Parent (App.vue) as the Source of Truth

Our App.vue component will hold the state and the methods to change that state. All the other components will be "dumb" and simply receive data (props) and emit events.

Your App.vue <script setup> block should contain:

  • The tasks reactive array (ref([])).
  • The addTask function.
  • The deleteTask function.
  • The toggleComplete function.

Its <template> will be simplified to just render the TodoForm and TodoList components. It will look something like this:

<template>
  <h1>My To-Do List</h1>
  
  <TodoForm @add-task="addTask" />
  
  <TodoList 
    :tasks="tasks" 
    @delete-task="deleteTask" 
    @toggle-complete="toggleComplete" 
  />
</template>

Notice how App.vue is now like a switchboard: it passes the tasks data down to TodoList via a prop and listens for events from both TodoForm and TodoList to trigger its methods.

Step 3: TodoForm.vue - Emitting Events Up

Now, let's build the TodoForm.vue component.

  1. Move the HTML: Cut the <form> element from your original App.vue and paste it into the <template> of TodoForm.vue.
  2. Move the Logic:
    • Create a ref for the new task input (e.g., const newTask = ref('')).
    • Create a function that runs on form submission.
  3. Emit the Event: Instead of adding the task to an array, this function will now use defineEmits to send an event to the parent.
// In TodoForm.vue

import { ref } from 'vue'

const newTask = ref('')
const emit = defineEmits(['addTask'])

const handleSubmit = () => {
  if (newTask.value.trim() !== '') {
    emit('addTask', newTask.value) // Send the new task text to the parent
    newTask.value = '' // Clear the input
  }
}

The parent (App.vue) will listen for this @addTask event and call its own addTask method with the payload. This is a classic example of child-to-parent communication.

The following video demonstrates this exact pattern of refactoring a form into its own component and using emit to communicate with the parent.

Modern Vue.js Crash Course | with TypeScript + script setup + Composition API

To see this in action, watch this segment from the 'Modern Vue.js Crash Course' by Syntax. It shows how to move a form into a TaskForm component and emit a custom addTask event.

Watch from 00:10:28 to 00:14:01. Focus on how defineEmits is used in the child and how the @add-task listener is set up in the parent.

Step 4: TodoList.vue & TodoItem.vue - Passing Props Down

Next, we'll handle displaying the list. This involves passing data two levels deep: App.vue -> TodoList.vue -> TodoItem.vue.

A. TodoList.vue

  1. Define Props: In TodoList.vue, use defineProps to declare that it expects a tasks prop, which will be an array.
  2. Move the v-for: Move the <ul> and the v-for loop from App.vue into the <template> of TodoList.vue.
  3. Render TodoItem: Inside the loop, you will now render the <TodoItem /> component instead of the <li> content directly. Pass the individual task object from the loop down to each TodoItem as a prop.

Your TodoList.vue template will look like this:

<!-- TodoList.vue -->
<template>
  <ul>
    <TodoItem 
      v-for="task in props.tasks" 
      :key="task.id"
      :task="task"
      @delete-task="emit('deleteTask', $event)"
      @toggle-complete="emit('toggleComplete', $event)"
    />
  </ul>
</template>

Notice we are also defining emits here. TodoList acts as a middle-man, listening for events from TodoItem and passing them straight up to App.vue.

B. TodoItem.vue

  1. Define Props: In TodoItem.vue, use defineProps to accept the single task object.
  2. Define Emits: Use defineEmits to declare that this component can emit deleteTask and toggleComplete events.
  3. Move the <li> Content: Move the content of the list item (the checkbox, the task text, the delete button) into the <template> of TodoItem.vue.
  4. Wire up Events:
    • When the delete button is clicked, emit the deleteTask event with props.task.id as the payload.
    • When the checkbox value changes, emit the toggleComplete event with props.task.id as the payload.

This is a classic example of parent-to-child communication.

The following video sections are excellent guides for this process of passing data down through props and handling events from list items.

Modern Vue.js Crash Course | with TypeScript + script setup + Composition API

These sections from the same 'Modern Vue.js Crash Course' demonstrate passing an array as a prop and then emitting events from the child items to modify the parent's state.

Watch from 00:20:22 to 00:22:26 for passing props to a list component. Then watch from 00:24:40 to 00:26:48 and 00:29:57 to 00:32:15 to see how to emit toggle and remove events from the list items.

For a different perspective that reinforces these same concepts, you can also review the "Creating a to-do list..." article.

Creating a to-do list with the Vue 3 Composition API

This article from Jump24 builds a to-do list from scratch using the same component architecture. It's a great textual reference for defineProps and defineEmits in this context.

Skim through the code snippets in the article. Notice how ToDoListItem (our TodoItem) defines props and emits, and how ToDoList uses v-for to render the items and handles their events.



Part 3: Reviewing the Final Data Flow

Once you have completed the refactoring, your application will have the exact same functionality, but its internal structure will be vastly improved.

Let's review the data flow:

Vue.js Parent Component with Props and Custom Events
This image perfectly captures our new architecture. Data (the `tasks` array) flows down from `App.vue` via props, and user actions (like 'add task' or 'delete task') trigger events that flow back up.

Our Data Flow:

  1. State: The single source of truth, the tasks array, lives in App.vue.
  2. Props Down:
    • App.vue passes the full tasks array to TodoList.vue.
    • TodoList.vue loops over tasks and passes each individual task object to a TodoItem.vue.
  3. Events Up:
    • TodoForm.vue emits addTask directly to App.vue.
    • TodoItem.vue emits deleteTask or toggleComplete to TodoList.vue.
    • TodoList.vue doesn't handle these events itself; it just passes them up again to App.vue, which contains the actual logic to modify the state.

This "event bubbling" through TodoList.vue is a small example of the prop/event drilling we discussed. For this small app, it's perfectly fine. But you can imagine if the nesting was 5 levels deep, it would become tedious.



Conclusion

Congratulations! You have successfully refactored an application into a clean, reusable, and maintainable component architecture. This is a fundamental skill that separates simple scripts from professional-grade applications.

Key Takeaways:

  • Component Planning: Breaking down a UI into logical components is the first step.
  • Single Source of Truth: For a given feature, state should live in the highest-level common ancestor component (App.vue in our case).
  • Props Down, Events Up: This is the standard, predictable pattern for communication between parent and child components.
  • Separation of Concerns: Our new TodoItem and TodoForm components are now highly reusable because they are "presentational"—they just display data and report on user actions, without containing any business logic.

You've now mastered the core concepts of component communication. In the next module, we'll address the minor "event drilling" we saw by introducing Pinia, a state management library that will allow any component to interact with the application state directly, simplifying our data flow even further.