Creating a to-do list with the Vue 3 Composition API.
4 minutes
First off let’s create a typescript interface, describing how our data will be structured. To-do components are pretty simple, we need to know a couple of things: the name of each item and whether it is checked or not. We will also handle the ToDoListItem
component props as a separate interface, using the initial interface as a base.
A good reason to start with typings before handling data is so you can have an overview of what is required and plan how you’re going to achieve your goal. We know that we need a name and checked status and we know we need to handle deletion and change (checked) at the item level – so we can cater for that and set up all the relations at the beginning.
Create a new folder and file at src/types/to-do-item.ts
– this is where we will write our types for the ToDoListItem
. The first interface we will create is ToDoItem
, this will describe the name
and checked
properties and corresponding types: string
and boolean
respectively.
1interface ToDoItem {2 name: string3 checked: boolean4}5 6export type {7 ToDoItem,8}
Don’t forget to export your types!
This interface will be used in the parent ToDoList
component to help structure our data and used in the ToDoListItem
component to define the props.
1<script setup lang="ts"> 2import { ref, defineEmits, defineProps } from 'vue' 3import type { ToDoItem } from '../types/to-do-item' 4import CheckIcon from './icons/CheckIcon.vue' 5import DeleteIcon from './icons/DeleteIcon.vue' 6 7const props = defineProps<P 8 task: ToDoItem 9}>()10 11const emit = defineEmits<{12 (e: 'change', checked: boolean): void13 (e: 'delete', task: ToDoItem): void14}>()15 16const isChecked = ref(props?.task?.checked || false)17 18const handleDelete = (): void => emit('delete', props.task)19 20const handleChange = (event: Event): void => {21 const { checked } = event.target as HTMLInputElement22 isChecked.value = checked23 emit('changed', checked)24}25</script>
Both defineProps
and defineEmits
use a generic type which allows us to pass the types to the functions in question with this syntax: myFunction<MyType>()
. So we will pass ToDoItem
to our definition of props and for our emit definition, we will add another event for delete
.
Now with access to our delete event, we can create a function called handleDelete
to emit the event with our props.task
payload.
With access to our props, we can pass props.checked
as the default value to the ref
assigned to isChecked
.
1<template> 2 <li class="item"> 3 <label class="item__label"> 4 <input 5 type="checkbox" 6 class="item__checkbox" 7 :class="{ 'item__checkbox--complete': isChecked }" 8 @change="handleChange" 9 /> 10 <CheckIcon class="item__check" /> 11 <span class="item__name" :class="{ 'item__name--complete': isChecked }">{{ props.task.name }}</span> 12 </label> 13 <DeleteIcon class="item__delete" @click="handleDelete" /> 14 </li> 15</template> 16 17<script setup lang="ts"> 18import { ref, defineEmits, defineProps } from 'vue' 19import type { ToDoItem } from '../types/to-do-item' 20import CheckIcon from './icons/CheckIcon.vue' 21import DeleteIcon from './icons/DeleteIcon.vue' 22 23const props = defineProps<{ 24 task: ToDoItem 25}>() 26 27const emit = defineEmits<{ 28 (e: 'change', checked: boolean): void 29 (e: 'delete', task: ToDoItem): void 30}>() 31 32const isChecked = ref(props?.task?.checked || false) 33 34const handleDelete = (): void => emit('delete', props.task) 35 36const handleChange = (event: Event): void => { 37 const { checked } = event.target as HTMLInputElement 38 isChecked.value = checked 39 emit('change', checked) 40} 41</script> 42 43<style lang="scss"> 44 .item { 45 width: 100%; 46 list-style: none; 47 display: flex; 48 align-items: center; 49 justify-content: space-between; 50 padding-bottom: 8px; 51 52 &__label { 53 display: flex; 54 align-items: center; 55 flex-grow: 1; 56 cursor: pointer; 57 position: relative; 58 } 59 60 &__checkbox { 61 width: 16px; 62 height: 16px; 63 border-radius: 4px; 64 border: 1px solid #fff; 65 background-color: transparent; 66 appearance: none; 67 cursor: pointer; 68 transition: border 0.2s, background 0.2s; 69 70 &:hover { 71 border-color: var(--blue); 72 } 73 74 &--complete { 75 border-color: var(--blue); 76 background-color: var(--blue); 77 78 & + .item__check { 79 opacity: 1; 80 } 81 } 82 } 83 84 &__check { 85 width: 10px; 86 position: absolute; 87 left: 7px; 88 opacity: 0; 89 transition: opacity 0.2s; 90 } 91 92 &__name { 93 padding-left: 12px; 94 95 &--complete { 96 text-decoration: line-through rgba(255, 255, 255, 0.75); 97 } 98 } 99 100 &__delete {101 cursor: pointer;102 width: 24px;103 }104 }105</style>
We will use the @click
directive to assign the function we created a moment ago, handleDelete
. Clicking the DeleteIcon
then will trigger our delete
event - just as we wanted! We will also update .item__name
content with {{ props.task.name }}
- we use the handlebars syntax to bind and display data in the template.
Next up is rendering the list of items based off a bit of mock data for now. So we will have to create a component named ToDoList
and render our tasks.
1<template> 2 <ul class="todo__list"> 3 <ToDoListItem 4 v-for="(task, ix) in tasks" 5 @change="(checked: boolean) => handleChange(task, checked)" 6 :key="task.name + ix" 7 :task="task" 8 /> 9 </ul>10</template>11 12<script setup lang="ts">13import { ref } from 'vue'14import type { ToDoItem } from '../types/to-do-item'15import ToDoListItem from './ToDoListItem.vue'16 17const props = defineProps<{18 tasks: ToDoItem[]19}>()20 21const tasks = ref(props.tasks)22 23const handleChange = (task: ToDoItem, checked: boolean): void => {24 const index = tasks.value.indexOf(task)25 tasks.value[index].checked = checked26}27</script>
We move our simple <ul>
and <ToDoListItem>
elements from App.vue
and put it in our new ToDoList
component. In our script section we set up our props to receive tasks
, and utilise the ref
API and pass the tasks prop as our default value.
Don’t forget to give your ToDoListItem
a key relative to your v-for
loop - so we can just use task.name + ix
here.
We will require an event handling function for when the ToDoListItem
gets checked - we need to find the index of the current task and then set its checked value to be what is passed via the parameters for the event handler.
As we have tasks
setup with the ref API - we can easily update the data via the .value
property, selecting our index and then setting the checked
property with our boolean! Nice and simple.
Let’s not forget to change the App.vue
to check our new component is working!
1<template> 2 <ToDoList :tasks="tasks" /> 3</template> 4 5<script setup lang="ts"> 6import ToDoList from './components/ToDoList.vue' 7 8const tasks = [ 9 { name: 'say hello', checked: false },10 { name: 'say goodbye', checked: true },11]12</script>13 14<style>15:root {16 --blue: #4D9DE0;17}18</style>
Next up we should add an input to add new tasks and do some general styling updates. We add some basic markup and styling to our new markup.
1<template> 2 <div class="todo"> 3 <input class="todo__input" placeholder="Add a new task" /> 4 <ul class="todo__list"> 5 <ToDoListItem 6 v-for="(task, ix) in tasks" 7 @change="(checked: boolean) => handleChange(task, checked)" 8 @delete="() => handleDelete(task)" 9 :key="task.name + ix"10 :task="task"11 />12 </ul>13 </div>14</template>15 16<script setup lang="ts">17import { ref } from 'vue'18import type { ToDoItem } from '../types/to-do-item'19import ToDoListItem from './ToDoListItem.vue'20 21const props = defineProps<{22 tasks: ToDoItem[]23}>()24 25const tasks = ref(props.tasks)26 27const handleChange = (task: ToDoItem, checked: boolean): void => {28 const index = tasks.value.indexOf(task)29 tasks.value[index].checked = checked30}31 32const handleDelete = (task: ToDoItem): void => {33 const index = tasks.value.indexOf(task)34 tasks.value.splice(index, 1)35}36 37const handleEnterKey = (event: KeyboardEvent): void => {38 const input = (event.target as HTMLInputElement)39 40 if (!input.value) return41 42 tasks.value.unshift({43 name: input.value,44 checked: false,45 })46 47 input.value = ''48}49</script>50 51<style lang="scss">52.todo {53 width: 300px;54 55 &__input {56 width: 100%;57 border: 0;58 border-bottom: 1px solid white;59 background-color: transparent;60 outline: 0;61 padding: 8px 0;62 box-sizing: border-box;63 font-size: 16px;64 transition: border 0.3s ease-in-out;65 margin-bottom: 8px;66 67 &:focus,68 &:hover {69 border-color: var(--blue);70 }71 }72 73 &__list {74 list-style: none;75 padding: 0;76 }77}78</style>

The last part we need to do now is hook up an event handler when the user presses the Enter key. Luckily in Vue we can use a “key modifier” - which allows us to check for specific keys. We will add this event and key modifier, then use handleEnterKey
as the name for our event handler.
1<template> 2 <div class="todo"> 3 <input class="todo__input" placeholder="Add a new task" @keyup.enter="handleEnterKey" /> 4 <ul class="todo__list"> 5 <ToDoListItem 6 v-for="(task, ix) in tasks" 7 @change="(checked: boolean) => handleChange(task, checked)" 8 @delete="() => handleDelete(task)" 9 :key="task.name + ix"10 :task="task"11 />12 </ul>13 </div>14</template>
Our event handler handleEnterKey
will add a new ToDoItem
object at the beginning of the tasks array (using unshift
) and we will reset the inputs value so we can easily add another task afterwards.
1const handleEnterKey = (event: KeyboardEvent): void => { 2 const input = (event.target as HTMLInputElement) 3 4 if (!input.value) return 5 6 tasks.value.unshift({ 7 name: input.value, 8 checked: false, 9 })10 11 input.value = ''12}
With all of this, we should now have a working to-do list that can add tasks, remove tasks and check/uncheck tasks!

Need help with a Vue Project?
Looking for help with your Vue Project? We’re passionate about creating top-quality web applications that stand out from the crowd. Let our skilled development team work with you to bring your ideas to life! Get in touch today and let’s get started!