DEV Community

Cover image for Build a To-do List App with Pinia and Vue 3
SandraRodgers for Deepgram

Posted on • Updated on • Originally published at developers.deepgram.com

Build a To-do List App with Pinia and Vue 3

I was building a Vue 3 project for my recent blog series on how to build a full-stack live streaming web app. I wanted to use Vuex to manage some global state properties. It was my first time using Vuex with Vue 3 since I began my journey to learn the Composition API.

When I arrived at the Vuex documentation page, I saw this:

Announcement: The official state management library for Vue has changed to Pinia

Well, that was a surprise! I had been hearing the word "Pinia" in relation to Vue but didn't know exactly what it was. Pinia is now the official state management library for Vue!

I pushed onwards with using Vuex in that project but made a mental note to come back soon to Pinia to find out what it is all about.

Soon is now! Today I will learn a little about Pinia by building a to-do list. I'll show how I build it and provide some of my thoughts about the experience. Let's dive in!

The Project

Here is a screenshot of the final project. It's a to-do list that lets me add, delete, and check off an item on the list.

The project repo can be found here.

Example of the to-do list app I'll build

Getting Started with Pinia

I'll create my Vue project (making sure to select Vue 3 since I want to use the Composition API). Pinia also works with Vue 2, but I've personally gone totally in on Vue 3 (and haven't looked back - check out my series on Vue 3 to read about my journey).

vue create todo-pinia
Enter fullscreen mode Exit fullscreen mode

After I cd into the project folder, I'll install pinia:

npm install pinia
Enter fullscreen mode Exit fullscreen mode

Then I'll go into the main.js file and import createPinia. This creates a Pinia instance to be used by my application. The .use() tells the Vue app to install Pinia as a plugin.

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

createApp(App).use(createPinia()).mount('#app')
Enter fullscreen mode Exit fullscreen mode

In the src folder, in components, I'll create the three components that will make up my todo list app - TodoApp.vue (the parent component), TodoForm.vue (a child component), and TodoList.vue (another child component).

Components folder with files

Here is the plan for how these components will be organized in the browser:

Example of the to-do list app with component outline

In each component, I can quickly scaffold out the basic code structure for my template and script. I do that with an extension in VS Code called Vue VSCode Snippets. Since I have that, I just type the letters vbase-3, and the code writes itself for me:

vbase-3 snippet to scaffold out my code

Now I'll import each component to where it needs to be -TodoForm.vue and TodoList.vue into the TodoApp.vue - and I'll import the TodoApp.vue component into App.vue. I like to write the name of the component in each to start so I can see them on the screen.

Here's my screen now. The layout is there, but no logic or styles yet:

Component layout in browser

Pinia - What is it?

Next, I'll create a store and set up my global state with Pinia.

The concept of a Pinia store is the same as it is for Vuex or Redux - it is a place to hold global state, and it makes it easy for any component in the project to track changes to that global state.

Not all state needs to go in the store - just state properties that I want to make available throughout the app. This is especially useful when I want to share state between two sibling components like the TodoForm.vue and TodoList.vue because I can avoid sending props down ('prop drilling') and emitting events up through the parent.

Define a Pinia Store

I will create a store folder in src, and in the folder, I'll make a file called useTodoListStore.js. I'm naming it starting with the word 'use' because a common convention of Vue 3, both for Pinia store files and for Vue composables, is to start the file name with 'use'.

I can have as many stores as I want; in fact, I should have separate stores for separate logical concerns, similar to how Vue 3 composables are built around distinct logical concerns. Each store should be in a different file.

However, since this is such a small project, I only need one store - one store for the to-do list logic.

I will first import the defineStore function from Pinia. Under the hood, this is going to create the useStore function that I will need in my components to retrieve the store I made.

import { defineStore } from 'pinia'
Enter fullscreen mode Exit fullscreen mode

I set it to a const and use the keyword export since I'll need to be able to import it into my components.

This defineStore function will take two arguments: a string (the unique name of the store) and an object (options such as state, getters, and actions).

import { defineStore } from 'pinia'

export const useTodoListStore = defineStore('todoList', {
  // state
  // getters
  // actions
})
Enter fullscreen mode Exit fullscreen mode

State, Getters, and Actions

The options that I pass to the defineStore function are my store's state, getters, and actions. Unlike Vuex, there is no longer the need for mutations. This makes me happy!

I always found mutations confusing because it felt like I was repeating myself when I had to write an action to commit a mutation, which would then make the state change. Pinia has gotten rid of that middleman, and instead, the flow is just action -> change state.

I already have a mental model around the way methods, data, and computed work in Vue 2. The methods make stuff happen, the data contains my state properties, and the computed returns an automatically updated property that has had a calculation performed on it.

Pinia's options follow the same mental model - I can think of the state as being like data in the Vue Options API, the actions like methods, and the getters like computed properties.

I really like this change, and it's one of the first things that made me think, "Wow, I think I'm really going to like Pinia!"

Create Initial State

Now I'll start creating a global state object in my useTodoListStore.

The state is actually a function, and it's recommended that I use an arrow function (this is because Pinia has excellent Typescript integration, and using an arrow function will allow Typescript inference to work on the state properties).

I'll add a todoList property, which will be an array meant to contain each to-do item (each item is going to be an object, but there's nothing in the todoList array at the moment).

import { defineStore } from 'pinia'

export const useTodoListStore = defineStore('todoList', {
  state: () => ({
    todoList: [],
  }),
})
Enter fullscreen mode Exit fullscreen mode

Actions - Add and Delete an Item

I can also set up my first action. I know the main logic to start will be adding an item to the to-do list. I'll write a function addTodo that will perform the logic of pushing an item object into the todoList array.

Individual actions are methods within the actions object in the store.

I will also add an id property to state since I will want each item to have an id that increments each time a new item is pushed into the toDoList array:

import { defineStore } from 'pinia'

export const useTodoListStore = defineStore('todoList', {
  state: () => ({
    todoList: [],
    id: 0,
  }),
  actions: {
    addTodo(item) {
      this.todoList.push({ item, id: this.id++, completed: false })
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Maybe while I'm here, I should go ahead and write an action to delete an item from the to-do list since I know I'll want to have a delete feature. Under the last line of code in the addToDo action, I'll add a deleteTodo:

deleteTodo(itemID) {
  this.todoList = this.todoList.filter((object) => {
    return object.id !== itemID;
  });
},
Enter fullscreen mode Exit fullscreen mode

Input Form to Add an Item

I'll jump back into the TodoForm.vue component now. I want to write a form to enter a to-do item. I'll use the dev-tools to check that the item is getting into the state I set up in the Pinia store.

In the template, I'll create the basic form:

<!-- TodoForm.vue -->

<template>
  <form @submit.prevent="">
    <input v-model="todo" type="text" /><button>Add</button>
  </form>
</template>
Enter fullscreen mode Exit fullscreen mode

The input has a v-model="todo" which I'll connect to a ref in the script to make this property reactive so it updates as the user types the item into the input:

// TodoForm.vue

<script>
import { ref } from "vue";
export default {
  setup() {
    const todo = ref("");
    return { todo };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

I haven't added a method yet for the @submit event listener because I need to set up the logic in the script first. The submit button is going to trigger a function to add an item to the todo list, so I'll need to somehow invoke the addTodo action in the store.

Access Pinia Store from a Component

To use a Pinia store in a component, I need to import the store and then set a const store to the invoked store function:

// TodoForm.vue

import { useTodoListStore } from '@/store/useTodoListStore'
export default {
  setup() {
    const todo = ref('')
    // use Pinia store:
    const store = useTodoListStore()

    return { todo }
  },
}
Enter fullscreen mode Exit fullscreen mode

Now I will have access to state, actions, and getters in the store through that const store.

I'll write a method in the TodoForm.vue component that will be triggered when the submit button is clicked. I want that method to do two things: add an item to the todoList array in the store, and clear the todo ref so it returns to being an empty string after the item is added to the list:

// in setup function in script in TodoForm.vue:

function addItemAndClear(item) {
  if (item.length === 0) {
    return
  }
  // invokes function in the store:
  store.addTodo(item)
  todo.value = ''
}
Enter fullscreen mode Exit fullscreen mode

And I'll make sure that function is added to the form's @submit event listener in the template:

<form @submit.prevent="addItemAndClear(todo)">
Enter fullscreen mode Exit fullscreen mode

I'll type npm run serve in the terminal to start up the Vue development server.

Now I can open the Vue dev-tools and see that the item is being added to the todoList array in the store.

Gif showing an item added to the to-do list and data in the store

Reactive Properties in Pinia

In the previous section, I used an action from the Pinia store - addTodo - in my todoForm.vue component. In this section, I'll use a state property in the todoList.vue component, and I need it to be reactive to changes that might happen. I'll be using it in the component template, and it has to be reactive so it updates in sync with the state change.

There's an important function I'll want to use that comes with the Pinia library - storeToRefs. Each to-do list item displayed in the todoList component will actually come from the store, and since the store's state is an object, I will use this helper method to destructure the returned object without losing reactivity. It is similar to Vue 3's utility function toRefs. I'll demonstrate its usage as I build the next feature.

Todo List - Show Item

I want access to the todoList that's in the store (which now has data to represent the items I've added to the list), so in the todoList.vue component I'll need to bring in the store, just like I did in todoForm.vue. I'll also set const store to the invoked store function.

Then I need to wrap the todoList property that I want to pull from the store in the function storeToRefs:

<script>
import { useTodoListStore } from "../store/useTodoListStore";
import { storeToRefs } from "pinia";
export default {
  setup() {
    const store = useTodoListStore();
    // storeToRefs lets todoList keep reactivity:
    const { todoList } = storeToRefs(store);

    return { todoList };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

Now I can use todoList in my template, and it will stay in sync with the store. I'll write a v-for loop to create the list:

<template>
  <div v-for="todo in todoList" :key="todo.id">
    <div>{{ todo.item }}</div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

And the list is displaying now:

Gif showing the list as items are added

To-do List - Mark as Completed

I want to add some styles to each item to show if the to-do item has been completed.

First, I need the logic to toggle an item to be complete or not complete. Right now, in the store, each item that is added to the list also has a completed property set to false:

// useTodoListStore.js

this.todoList.push({ item, id: this.id++, completed: false })
Enter fullscreen mode Exit fullscreen mode

I can write an action in the store to toggle that to true:

toggleCompleted(idToFind) {
      const todo = this.todoList.find((obj) => obj.id === idToFind);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
Enter fullscreen mode Exit fullscreen mode

In the todoList.vue component, I'll add a checkmark emoji as a span to the template with an event listener to listen for a click on the checkmark. The Unicode is &#10004; for a checkmark.

<div v-for="todo in todoList" :key="todo.id">
    <div>
      <span>{{ todo.item }}</span>
      <span @click.stop="toggleCompleted(todo.id)">&#10004;</span>
    </div>
  </div>
Enter fullscreen mode Exit fullscreen mode

However, I need to make sure that I have brought toggleCompleted into the component. Since it's an action method and not a reactive state property, I won't use storeToRefs for toggleCompleted:

<script>
import { useTodoListStore } from "../store/useTodoListStore";
import { storeToRefs } from "pinia";
export default {
  setup() {
    const store = useTodoListStore();
    const { todoList } = storeToRefs(store);
    // destructuring action method doesn't require using storeToRefs:
    const { toggleCompleted } = store;

    return { todoList, toggleCompleted };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

To add the styles, I first will add a dynamic class to the to-do item span in the template:

<span :class="{ completed: todo.completed }">{{ todo.item }}</span>
Enter fullscreen mode Exit fullscreen mode

And CSS to change the look of the item as it is toggled true and false:

/* CSS Styles */

.completed {
  text-decoration: line-through;
}
Enter fullscreen mode Exit fullscreen mode

Gif showing an item marked complete with checkmark

To-Do List - Delete Item

I had already added the deleteTodo function to the store, so I can jump into writing the delete feature in the todoList.vue component.

I'll do the same thing I did in the previous section, bringing in the store's action deleteTodo and using a cross mark emoji for the delete button. I won't explain every step since I just need to repeat what I did in the previous section for marking an item complete, but this time hooking it up to the delete action. But I'll show the code.

Here's the todoList.vue component after I added the delete feature:

// todoList.vue

<template>
  <div v-for="todo in todoList" :key="todo.id">
    <div>
      <span :class="{ completed: todo.completed }">{{ todo.item }}</span>
      <span @click.stop="toggleCompleted(todo.id)">&#10004;</span>
      <span @click="deleteTodo(todo.id)">&#10060;</span>
    </div>
  </div>
</template>

<script>
import { useTodoListStore } from "../store/useTodoListStore";
import { storeToRefs } from "pinia";
export default {
  setup() {
    const store = useTodoListStore();
    const { todoList } = storeToRefs(store);
    const { toggleCompleted, deleteTodo } = store;

    return { todoList, toggleCompleted, deleteTodo };
  },
};
</script>

<style>
.completed {
  text-decoration: line-through;
}
</style>

Enter fullscreen mode Exit fullscreen mode

And here is the store now that I have all the logic working:

// useTodoListStore

import { defineStore } from 'pinia'

export const useTodoListStore = defineStore('todoList', {
  state: () => ({
    todoList: [],
    id: 0,
  }),
  actions: {
    addTodo(item) {
      this.todoList.push({ item, id: this.id++, completed: false })
    },
    deleteTodo(itemID) {
      this.todoList = this.todoList.filter((object) => {
        return object.id !== itemID
      })
    },
    toggleCompleted(idToFind) {
      const todo = this.todoList.find((obj) => obj.id === idToFind)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

I've finished a barebones to-do list app with Pinia, minus styling. This code is available on the 'just-pinia' branch of my project repo for anyone who would like to see it in its entirety.

Bonus Section: Add Typescript

One of the best features of Pinia is that it works very well with Typescript. I first chose to build the to-do list without Typescript so I could just focus on how to use Pinia, but I also want to demonstrate how it works with Typescript since that is a huge advantage of Pinia.

Setting up Vuex with Typescript was always challenging for me because of the need to create custom complex wrappers. It wasn't easy to just dive in.

But with Pinia, I don't have to do that. I can just add Typescript to my project and start using it.

I'll add Typescript to my existing project with this command:

vue add Typescript
Enter fullscreen mode Exit fullscreen mode

When it prompts me to make some choices, I'll be sure to say yes to "Convert all .js files to .ts". That way it will turn the store file into a .ts file.

Prompts when adding Typescript to Vue project

Then I'll delete the HelloWorld file because I don't need that. I might need to delete one of the extends properties from the .eslintrc.js file.

I'll go to the store file and see that Typescript is pointing out all the missing types I need to add.

Store with Typescript errors

I'm not going to go through how to use Typescript since this blog post isn't meant to teach how to write Typescript. But I'll add the types and show how my store looks after I revise it to include Typescript:

import { defineStore } from "pinia";

interface ToDoItem {
  item: string;
  id: number;
  completed: boolean;
}

export const useTodoListStore = defineStore("todoList", {
  state: () => ({
    todoList: [] as ToDoItem[],
    id: 0,
  }),
  actions: {
    addTodo(item: string) {
      this.todoList.push({ item, id: this.id++, completed: false });
    },
    deleteTodo(itemID: number) {
      this.todoList = this.todoList.filter((object) => {
        return object.id !== itemID;
      });
    },
    toggleCompleted(idToFind: number) {
      const todo = this.todoList.find((obj) => obj.id === idToFind);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

In the components, I'll need to add lang="ts" to the script and import defineComponent. The export will need to be wrapped in the defineComponent function.

<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
...
});
</script>
Enter fullscreen mode Exit fullscreen mode

And that's how I would add Typescript to my project after-the fact; although I highly recommend starting the project from the beginning with Typescript, since it will help with the developer experience of catching errors and thinking about types.

The Typescript version of the to-do list can be found in my repo on the branch called pinia-typescript.

Conclusion

I went through creating a to-do list using just Pinia and then I also showed how to build one with Typescript. I've since added styles and an alert feature to the application, and the most updated code can be found on the main branch of the project repo.

I hope this blog post has been helpful. I'm very excited about Pinia because of how straightforward it was to jump in and start using, especially with Typescript.

If you have any questions, feel free to reach out on Twitter!

Top comments (0)