Building Your First Single-Page Application in Vue
Tags: vue, javascript, tutorial, webdev
Published: 2022-09-17 13:59:24

Building a complete single-page application (SPA) from scratch can be a little bit overwhelming for developers who used Vue before but never built a complete SPA from scratch.

After finishing this tutorial, you should have a good idea on how real-world SPAs are built including authentication, state management, and adding helpful tools like Tailwind CSS.

Even though the title says building your first app, it can also be useful to developers who have built SPAs before but not used everything covered here (like Pinia for state management or how to use Vue Apollo with composition API).

## What are we going to build?
To focus on the concepts, we will build a simple todo app that lets us create an account, log in, add todos, complete them, and delete them.

To see exactly what we are going to build, you can install and run the [backend](https://github.com/TahaSh/todoapp-backend) and the [frontend](https://github.com/TahaSh/todoapp-vue) of the app from GitHub.

## What are we going to use?
Well, we will use Vue 3 of course. But first make sure you are familiar with the [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html). If you are not, I encourage you to learn it first before following the tutorial. I recommend learning it not only to be able to follow the tutorial, but also because it's really great and will make your apps structured better.

Here are the things we will use for this app:
- [Vue Router](https://router.vuejs.org/guide/advanced/composition-api.html) for routing
- [Pinia](https://pinia.vuejs.org/) for state management
- [Tailwind CSS](https://tailwindcss.com/) for styling
- [Vue Apollo](https://v4.apollo.vuejs.org/) for the GraphQL Client

## Create a new Vue project

From your terminal run:
```bash
npm create vite@latest
```

Then select the following options:
```bash
✔ Project name: todoapp-vue
✔ Select a framework: › Vue
✔ Select a variant: › Customize with create-vue

Vue.js - The Progressive JavaScript Framework

✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit Testing? … No
✔ Add Cypress for both Unit and End-to-End testing? … No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
```

Note how we used "Customize with create-vue" to install Vue Router and Pinia.

After that, go to that project and run `npm install`:
```bash
cd todoapp-vue
npm install
```

Now run the app using:
```bash
npm run dev
```

Open it up in the browser, and let's start preparing the project.

## Prepare the project
Before we start writing code, let's get rid of the boilerplate code that we don't need. So, follow these steps:

- Delete everything in `src/components`
- Delete everything in `src/stores`
- Delete `src/assets/base.css` and `src/assets/main.css`
- Open `src/main.js` and remove `import './assets/main.css'`
- Delete `src/views/AboutView.vue`

Because we deleted `AboutView.vue`, we need to remove its route. So, open `src/router/index.js` and remove `/about` route.

After that, your `src/router/index.js` should look like this:
```js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    }
  ]
})

export default router
```

Now let's replace the content of `src/App.vue` and `src/views/HomeView.vue` with this:

```html
<template>
  <div></div>
</template>

<script setup></script>
```

If you check your browser now, you should see a blank page. If that's the case, then everything is working, and we can move on to the next step.

## Install Tailwind CSS

Run the following to install it and create the config file:
```bash
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```

Open `tailwind.config.js` and replace its content with:
```js
module.exports = {
  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
  theme: {
    fontFamily: {
      roboto: ['Roboto', 'sans-serif']
    }
  },
  plugins: []
}
```

We want to use Roboto font for this app, so we added `roboto` to the `fontFamily`.

Next, create `src/index.css` and add this to it:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

Now we can import `./index.css` from our `src/main.js` like this:
```js
import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

import './index.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')
```

## Add font and colors
As I mentioned earlier, we will use Roboto as the font. So let's import and configure our app's background and text color in `index.html`:
```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap"
      rel="stylesheet"
    />
    <title>Todo App</title>
  </head>
  <body class="bg-gray-900 text-gray-100 antialiased font-roboto">
    <div class="h-full" id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
```

Note how we've set the height of `#app` to 100% using `h-full`. We need this to center our content vertically in some pages later.

## How are we going to add pages?
To make things easier to follow, I will break the rest of this tutorial into two phases. The first phase will be just adding the UI of the pages and components. And the second phase will be integrating them with the backend.

## Specifying where we want to display the page's content
Vue Router provides us with a component called `<RouterView />`. This component tells the app where to display the current route's content.

Since `App.vue` is our root component, we should display the router view there. So, add `<RouterView />` in `src/App.vue`:

```html
<template>
  <RouterView />
</template>

<script setup></script>
```

## Add the login page
Create `LoginView.vue` in `src/views`, and add this code to it:

```html
<template>
  <div class="min-h-screen flex flex-col justify-center items-center">
    <h1 class="text-4xl font-bold text-center">Todo App</h1>
    <form class="mt-5 w-full max-w-lg mx-auto flex flex-col">
      <input
        class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
        type="text"
        placeholder="Username"
        required
      />
      <input
        class="p-3.5 rounded-b text-gray-900 outline-none"
        type="password"
        placeholder="Password"
        required
      />
      <button class="bg-blue-500 hover:bg-blue-600 mt-2.5 py-2.5 rounded">
        Log in
      </button>
    </form>
  </div>
</template>

<script setup></script>
```


Next, we need to link this view to a route. So let's add a new route called `Login` to our routes list.

So, update `src/router/index.js` like this:

```js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/login',
      name: 'Login',
      component: () => import('../views/LoginView.vue')
    }
  ]
})

export default router
```

If you go to [http://localhost:5173/login](http://localhost:5173/login) in your browser, you should see this:
![](https://tahazsh.com/images/blog/building-spa-in-vue-1.png)


## Add the signup page
First, add `src/views/SignupView.vue`:
```html
<template>
  <div class="min-h-screen flex flex-col justify-center items-center">
    <h1 class="text-4xl font-bold text-center">Todo App</h1>
    <form class="mt-5 w-full max-w-lg mx-auto flex flex-col">
      <input
        class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
        type="text"
        placeholder="Name"
        required
      />
      <input
        class="p-3.5 border-b border-gray-300 text-gray-900 outline-none"
        type="text"
        placeholder="Username"
        required
      />
      <input
        class="p-3.5 rounded-b text-gray-900 outline-none"
        type="password"
        placeholder="Password"
        required
      />
      <button class="bg-blue-500 hover:bg-blue-600 mt-2.5 py-2.5 rounded">
        Sign up
      </button>
    </form>
    <div class="mt-5">
      Already have an account?
      <RouterLink class="text-blue-400" :to="{ name: 'Login' }"
        >Log in here</RouterLink
      >
    </div>
  </div>
</template>

<script setup></script>
```

Note how we are using `<RouterLink>` component here. It's used to create a link to another route, and in this case it's the login page.

Before using `RouterLink`, you need to make sure that the route you're linking to exists, otherwise it will error. That's why we didn't add the signup link in the login page.

So let's add the signup route in `src/router/index.js`:
```js
// ...
routes: [
{
  path: '/',
  name: 'home',
  component: HomeView
},
{
  path: '/login',
  name: 'Login',
  component: () => import('../views/LoginView.vue')
},
{
  path: '/signup',
  name: 'Signup',
  component: () => import('../views/SignupView.vue')
}
]
// ...
```

Now we have that added, we can add the signup link to the login page.

So, add this below `</form>` in `src/views/LoginView.vue`:
```html
<div class="mt-5">
  Don't have an account?
  <RouterLink class="text-blue-400" :to="{ name: 'Signup' }">
    Sign up here
  </RouterLink>
</div>
```

Now let's check our signup page to make sure it's working. So if you go to [http://localhost:5173/signup](http://localhost:5173/signup) in your browser, you should see this:

![](https://tahazsh.com/images/blog/building-spa-in-vue-2.png)

## The homepage
The homepage view already exists in our app. So, let's start adding the needed content to it.

Add the following to `src/views/HomeView.vue`:
```html
<template>
  <div class="mt-20 w-full max-w-lg mx-auto">
    <h1 class="text-4xl font-bold text-center">Todo App</h1>
    <div class="mt-10 flex items-center justify-between">
      <div>Hey <span class="font-bold">User Name</span></div>
      <button class="cursor-pointer text-gray-200 hover:text-white">
        Log out
      </button>
    </div>
    <div class="mt-2.5">
      <AddTodoInput />
      <div class="mt-3.5">
        <div class="space-y-2.5">
          <TodoItem />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup></script>
```

This contains two components that we didn't create yet. They are `<AddTodoInput />` and `<TodoItem />`.

`<AddTodoInput />` is used to display the text input for the todo, and it will also handle creating the todo (about this later).

`<TodoItem />` will display the todo item including the title, complete checkbox, and a delete button.

Let's create their UIs.

So create `AddTodoInput.vue` in `src/components` and add this to it:
```html
<template>
  <div
    class="px-5 w-full bg-gray-100 rounded-lg outline-none text-gray-900 flex items-center justify-between"
  >
    <input
      class="rounded-lg py-3.5 flex-1 bg-gray-100 outline-none pr-2.5"
      type="text"
      placeholder="What needs to be done?"
    />
  </div>
</template>

<script setup></script>
```

And create `src/components/TodoItem.vue` with this:
```html
<template>
  <div
    class="group flex items-center justify-between bg-gray-200 rounded-lg px-5 py-3.5 text-gray-900"
  >
    <div class="flex items-center">
      <button
        :class="[
          completed ? 'bg-blue-500 border-blue-500' : 'border-gray-500',
          'hover:border-blue-500 border-2 w-5 h-5 rounded-full flex items-center justify-center text-white cursor-pointer'
        ]"
        @click="toggle"
      >
        <svg
          v-if="completed"
          style="width: 15px; height: 15px"
          viewBox="0 0 24 24"
        >
          <path
            fill="currentColor"
            d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"
          />
        </svg>
      </button>
      <span :class="[completed && 'line-through text-gray-500', 'ml-2.5']">
        Todo Title
      </span>
    </div>

    <button
      class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-gray-900"
    >
      <svg style="width: 24px; height: 24px" viewBox="0 0 24 24">
        <path
          fill="currentColor"
          d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M9,8H11V17H9V8M13,8H15V17H13V8Z"
        />
      </svg>
    </button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const completed = ref(false)

const toggle = () => {
  completed.value = !completed.value
}
</script>
```

For now we are handling the checkbox complete locally, but later calling `toggle()` should update it on the backend.

Most of what this component contains is updating the styling of the elements based on the `completed` data prop. For example, if `completed` is true, we should fill the checkbox element with blue and show a check icon.

Now we have created the components, let's import them in our homepage.

So, update `<script setup>` in `HomeView.vue` like this:
```html
<script setup>
import AddTodoInput from '../components/AddTodoInput.vue'
import TodoItem from '../components/TodoItem.vue'
</script>
```

Now you should see this in your browser:
![](https://tahazsh.com/images/blog/building-spa-in-vue-3.png)


## Download and run the backend server
This article focuses only on the frontend of the app, but it wouldn't be a complete tutorial if we don't learn how to integrate it to a real backend server. So I created the backend for this app and pushed it to [GitHub](https://github.com/TahaSh/todoapp-backend).

Learning how the backend was created is beyond the scope of this tutorial. So all we need to do here is download it and run  it so we start communicating with it.

So from your terminal run:
```bash
git clone https://github.com/TahaSh/todoapp-backend
npm install
npm run dev
```

This should run the server on [http://localhost:4000](http://localhost:4000). To make sure it's working, go to that link and see if GraphQL loads successfully.

![](https://tahazsh.com/images/blog/building-spa-in-vue-4.png)

## Install Vue Apollo
Since our backend API is built with GraphQL, we need a GraphQL client to query it. [Vue Apollo v4](https://v4.apollo.vuejs.org/) is a good one, and it supports Vue 3 composition API, which what we are using.

To install it, run this:
```bash
npm install --save graphql graphql-tag @apollo/client @vue/apollo-composable
```

To start using Vue Apollo, we need to create a client instance of it.

Let's handle this part in a new file in the `src/` directory.

Create `src/apolloClient.js` and put this into it:

```js
import {
  ApolloClient,
  createHttpLink,
  InMemoryCache,
  ApolloLink,
  concat
} from '@apollo/client/core'

export function createApolloClient() {
  const httpLink = createHttpLink({
    uri: 'http://localhost:4000/graphql'
  })

  const authMiddleware = new ApolloLink((operation, forward) => {
    const token = localStorage.getItem('todoapp-token')
    operation.setContext({
      headers: {
        authorization: token ? token : ''
      }
    })
    return forward(operation)
  })

  const cache = new InMemoryCache()

  const apolloClient = new ApolloClient({
    link: concat(authMiddleware, httpLink),
    cache
  })

  return apolloClient
}
```

Here's what we need to know in this code:
- When we create the client using `new ApolloClient` we pass to it three things: `httpLink`, `authMiddleware`, and `cache`.
- In `httpLink` we specify the GraphQL server we want to connect to. In this case it's `http://localhost:4000/graphql`.
- In `authMiddleware` we include the [JWT token](https://tahazsh.com/blog/understand-jwt-in-3-minutes/) of the current user to the header of every request we send.
- `cache` here is used to cache query results to make subsequent queries faster.

Note how we are storing the token in the localStorage in an item named `todoapp-token` (you can name it whatever you want). We needed to do this to keep the user logged in even after reloading the page or opening it in another page.

So two things you might need to change in this file: the GraphQL server link (`http://localhost:4000/graphql` – it's recommended to put it in an env file), and the localStorage key name (`todoapp-token`).

We created the function to create the client, but we haven't used it yet. To do this, update `src/main.js` to this:

```js
import { createApp, provide, h } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './index.css'

import { createApolloClient } from './apolloClient'
import { DefaultApolloClient } from '@vue/apollo-composable'

const app = createApp({
  setup() {
    provide(DefaultApolloClient, createApolloClient())
  },
  render: () => h(App)
})

app.use(createPinia())
app.use(router)

app.mount('#app')
```

## Integrate the signup page
The first step when you want to integrate some UI is to add the query or the mutation somewhere in your project.

I usually store queries in `queries.js` and mutations in `mutations.js`. So let's create these files in a new folder called `src/graphql/`.

For the signup page, we need to use the `signupUser` mutation that our GraphQL server provides. So let's add this to `src/graphql/mutations.js`:

```js
import { gql } from 'graphql-tag'

export const SIGNUP_USER = gql`
  mutation signupUser($input: SignupUserInput!) {
    signupUser(input: $input) {
      status
    }
  }
`
```

Let's use that mutation in `src/views/SignupView.vue` (I'll explain the changes below).

```html
<template>
  <div class="min-h-screen flex flex-col justify-center items-center">
    <h1 class="text-4xl font-bold text-center">Todo App</h1>
    <form
      class="mt-5 w-full max-w-lg mx-auto flex flex-col"
      @submit.prevent="submit"
    >
      <input
        class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
        v-model="name"
        :readonly="loading"
        type="text"
        placeholder="Name"
        required
      />
      <input
        class="p-3.5 border-b border-gray-300 text-gray-900 outline-none"
        v-model="username"
        :readonly="loading"
        type="text"
        placeholder="Username"
        required
      />
      <input
        class="p-3.5 rounded-b text-gray-900 outline-none"
        v-model="password"
        :readonly="loading"
        type="password"
        placeholder="Password"
        required
      />
      <button
        :class="[
          loading
            ? 'bg-gray-500 hover:bg-gray-500'
            : 'bg-blue-500 hover:bg-blue-600',
          'mt-2.5 py-2.5 rounded '
        ]"
        :disabled="loading"
      >
        Sign up
      </button>
    </form>
    <div class="mt-5">
      Already have an account?
      <RouterLink class="text-blue-400" :to="{ name: 'Login' }"
        >Log in here</RouterLink
      >
    </div>
  </div>
</template>

<script setup>
import { useMutation } from '@vue/apollo-composable'
import { ref } from 'vue'
import { SIGNUP_USER } from '../graphql/mutations'
import { useRouter } from 'vue-router'

const router = useRouter()

const { mutate: signup, loading, onDone } = useMutation(SIGNUP_USER)

const name = ref('')
const username = ref('')
const password = ref('')

const submit = () => {
  signup({
    input: {
      name: name.value,
      username: username.value,
      password: password.value
    }
  })
}

onDone(() => {
  router.push({ name: 'Login' })
})
</script>
```

To use the inputs in this page (`name`, `username`, and `password`), we added the needed variables and bound them using `v-model`.

To send a mutation with Vue Apollo, we need to create the mutation using `useMutation` and pass to it the mutation we want (`SIGNUP_USER` in this case, which we just added in `src/graphql/mutations.js`).

`useMutation` returns a few things that help us use the mutation. You can see the full list in the [docs](https://v4.apollo.vuejs.org/api/use-mutation.html#return).

For this mutation we're only using `mutate`, `loading`, and `onDone`:
- `mutate(variables, overrideOptions)` is used to actually send the mutation along with the needed variables and some options. It's always a good idea to rename it to something specific to your mutation, like `signup` for this example.
- `loading` tells if the mutation is in progress.
- `onDone` is an event hook which is called when the mutation completes successfully.

When the form is submitted, we call the mutation like this:
```js
const submit = () => {
  signup({
    input: {
      name: name.value,
      username: username.value,
      password: password.value
    }
  })
}
```

After it completes successfully, we redirect to he login page:
```js
onDone(() => {
  router.push({ name: 'Login' })
})
```

To improve the UX of the app, I've used `loading` to disable the inputs and the button while the mutation is in progress.
```html
// Make the input readonly if loading
<input
  ...
  :readonly="loading"
  ...
/>

// Disable the button and change its color on loading
<button
  :class="[
    loading
      ? 'bg-gray-500 hover:bg-gray-500'
      : 'bg-blue-500 hover:bg-blue-600',
    'mt-2.5 py-2.5 rounded '
  ]"
  :disabled="loading"
>
  Sign up
</button>
```

Let's test the signup page to see if it's working. After clicking "Sign up" check to see if the mutation is sent successfully like this:
![](https://tahazsh.com/images/blog/building-spa-in-vue-5.png)

## Integrate the login page
Like the signup page, let's first by adding the mutation to `src/graphql/mutations.js`:
```js
// ...
export const LOGIN_USER = gql`
  mutation loginUser($username: String!, $password: String!) {
    loginUser(username: $username, password: $password) {
      token
    }
  }
`
```

Next, copy the following code to `src/views/LoginView.vue`, which I'll explain below.

```html
<template>
  <div class="min-h-screen flex flex-col justify-center items-center">
    <div
      v-if="showErrorMessage"
      class="mb-3.5 px-3.5 py-2 rounded-lg bg-red-700 text-red-50 text-sm font-medium"
    >
      Your username or password is incorrect
    </div>
    <h1 class="text-4xl font-bold text-center">Todo App</h1>
    <form
      class="mt-5 w-full max-w-lg mx-auto flex flex-col"
      @submit.prevent="submit"
    >
      <input
        class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
        v-model="username"
        :readonly="loading"
        type="text"
        placeholder="Username"
        required
      />
      <input
        class="p-3.5 rounded-b text-gray-900 outline-none"
        v-model="password"
        :readonly="loading"
        type="password"
        placeholder="Password"
        required
      />
      <button
        :class="[
          loading
            ? 'bg-gray-500 hover:bg-gray-500'
            : 'bg-blue-500 hover:bg-blue-600',
          'mt-2.5 py-2.5 rounded '
        ]"
        :disabled="loading"
      >
        Log in
      </button>
    </form>
    <div class="mt-5">
      Don't have an account?
      <RouterLink class="text-blue-400" :to="{ name: 'Signup' }"
        >Sign up here</RouterLink
      >
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useMutation } from '@vue/apollo-composable'
import { LOGIN_USER } from '../graphql/mutations'
import { useRouter } from 'vue-router'

const router = useRouter()

const { mutate: login, loading, onDone, onError } = useMutation(LOGIN_USER)

const username = ref('')
const password = ref('')

const showErrorMessage = ref(false)

const submit = async () => {
  login({
    username: username.value,
    password: password.value
  })
}

onDone((result) => {
  const token = result.data?.loginUser?.token
  if (!token) {
    showErrorMessage.value = true
    return
  }
  localStorage.setItem('todoapp-token', token)
  router.push({ name: 'home' })
})

onError(() => {
  showErrorMessage.value = true
})
</script>
```

Most changes are similar to what we did for the signup page. So let me point out the new things here.

When the user logs in successfully, we access the token that the server generated for us through the `result` parameter in `onDone`. If, for some reason, the token wasn't included, we display an error and then return. But if it returned the token successfully, then we store it in the localStorage and redirect to the homepage.

I'm assuming here that the server is returning the same error, if any. That's why I'm just toggling `showErrorMessage` to true regardless what error we got. But in real world apps, you need to check what the error is first, then handle it accordingly. For this example, I'm just showing "Your username or password is incorrect" above the form if there's any error.

```html
<div
  v-if="showErrorMessage"
  class="mb-3.5 px-3.5 py-2 rounded-lg bg-red-700 text-red-50 text-sm font-medium"
>
  Your username or password is incorrect
</div>
```

To check if login is working, you should see the token added to your localStorage, like this:
![](https://tahazsh.com/images/blog/building-spa-in-vue-6.png)

## Fetching current user in Pinia
When the user logs in, we just store the token in the browser, but we don't fetch the user's data. We need to fetch the user after that to make sure that the token is valid (because the server will not return the user data if the token is invalid). We also need the user data for our app (like displaying the user name, etc).

The GraphQL server for this app gives us the logged-in user data through the `currentUser` query. So, let's create it in `src/graphql/queries.js`:

```js
import { gql } from 'graphql-tag'

export const CURRENT_USER = gql`
  query currentUser {
    currentUser {
      id
      username
      name
    }
  }
`
```

Since the user data is global to the app, we should store it globally using Pinia.

So, in `src/store` create a new file named `auth.js`, then put this into it (I'll explain below):

```js
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useLazyQuery } from '@vue/apollo-composable'
import { CURRENT_USER } from '../graphql/queries'

export const useAuth = defineStore('auth', () => {
  const currentUser = ref(null)

  const { onResult, load, networkStatus, refetch, onError } = useLazyQuery(
    CURRENT_USER,
    null,
    {
      fetchPolicy: 'no-cache'
    }
  )

  const getUser = () => {
    return new Promise((resolve) => {
      if (!localStorage.getItem('todoapp-token')) {
        currentUser.value = null
        return resolve(null)
      }
      if (currentUser.value !== null) {
        return resolve(currentUser)
      }
      if (networkStatus.value) {
        refetch()
      } else {
        load()
      }
      onResult((result) => {
        if (result.data?.currentUser) {
          currentUser.value = result.data.currentUser
          resolve(currentUser)
        }
      })
      onError(() => {
        resolve(null)
      })
    })
  }

  return { currentUser, getUser }
})
```

Stores in Pinia are similar to the setup function in components, but we declare that "setup" function in `defineStore()`.

We need to expose two things from this store: `currentUser` and `getUser`.
- `getUser` is a function that fetches the user data (more on that later)
- `currentUser` will contain the user data fetched by `getUser`.

Since `currentUser` is a query, we need to fetch it using `useQuery`. Actually for this case we will use `useLazyQuery` which is the same as `useQuery` except it's not sent immediately, instead when we call the `load()` function that it provides.

We don't want to fetch it immediately because we will call it upon `getUser` function call.

To avoid bugs in the future, it's better to not cache the logged-in user data. To do so, we set `fetchPolicy: 'no-cache'`.

Now let's see how `getUser` works here.

Calling `getUser` would look something like this:
```js
const user = await getUser()
```

Note how we are using `await` here. To allow this, we need to return a promise from it — and that's why `getUser` returns `new Promise`.

Before sending the query, we need to check if we even have a token in localStorage. If not, then just resolve with `null`.
```js
if (!localStorage.getItem('todoapp-token')) {
  currentUser.value = null
  return resolve(null)
}
```

After that, we check if the user is already fetched. If yes, then just return it.
```js
if (currentUser.value !== null) {
  return resolve(currentUser)
}
```

If none of this is true, then let's fetch the user. Normally, we should just call `load()` and it will start fetching it. But, unfortunately, `load` can't be called twice (which we might need if the user logs out and then logs in in the same session). To fix that, we need to call `load` the first time, then call `refetch` for the rest. The way we know if it's the first time or not is by checking `networkStatus`. If it's `null`, then it's the first time and we call `load`.
```js
if (networkStatus.value) {
  refetch()
} else {
  load()
}
```

If the query errored, then we return with `null`. We do this in `onError`.

But if it was successful, then store the user data in `currentUser` and return with that data.
```js
onResult((result) => {
  if (result.data?.currentUser) {
    currentUser.value = result.data.currentUser
    resolve(currentUser)
  }
})
```

`onResult` in queries is the same as `onDone` in mutations, just a different name.

Now we can fetch the user, but where should we fetch it? That's for the next section.

## Handle routing based on authentication
Some pages require the user to be logged in, and other pages require the user to be not logged in (which we call a guest user).

We can associate any data we want to any route through the `meta` property. So, if we add a flag to each route specifying whether it's for users or guests, then we know whether a user should be redirected to the login page to log in or not.

Let's update our `src/router/index.js` to include this.
```js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
      meta: {
        requiresAuth: true
      }
    },
    {
      path: '/login',
      name: 'Login',
      component: () => import('../views/LoginView.vue'),
      meta: {
        requiresGuest: true
      }
    },
    {
      path: '/signup',
      name: 'Signup',
      component: () => import('../views/SignupView.vue'),
      meta: {
        requiresGuest: true
      }
    }
  ]
})

export default router
```

Note that these will do nothing for now, they are just flags that we will use next.

The idea here is to check if the user is logged in before each route is shown. And based on route's `meta` data and the status of the user (logged in or not), we will redirect to the correct route.

We can do this using router's `beforeEach` navigation guard.

Let's handle this in `src/App.vue` since it's the root component.

So update `src/App.vue` like this:

``` html
<template>
  <RouterView />
</template>

<script setup>
import { useRouter } from 'vue-router'
import { useAuth } from './stores/auth'

const { getUser } = useAuth()

useRouter().beforeEach(async (to, from, next) => {
  try {
    const user = await getUser()
    if (!user && !to.meta?.requiresAuth) {
      return next()
    }
    if (user && to.meta?.requiresGuest) {
      return next({ name: 'home' })
    }
    if (!user && to.meta?.requiresAuth) {
      return next({ name: 'Login' })
    }
    next()
  } catch (err) {
    next({ name: 'Login' })
  }
})
</script>
```

So, on each route we do three checks:
- `if (!user && !to.meta?.requiresAuth)` checks if the user is not logged in and the page navigating to does not require auth. In this case the user can continue to that page.
- `if (user && to.meta?.requiresGuest)` checks if the user is logged in but trying to go to a guest-only page. In this case just redirect back to the homepage (or you can use `next(from)` to redirect back to whatever page they were on).
- `if (!user && to.meta?.requiresAuth)` checks if the user is not logged in and going to a page that requires authentication. In this case, we redirect them to the login page.

For all other cases, we should allow navigation. That's what's the last `next()` is for, which comes after `if (!user && to.meta?.requiresAuth)`.

If you now log in to the app and try to go to the login or to the signup page, you should be redirected to the homepage. And the same, if you try to go to the homepage without logging in, you'll be redirected to the login page.

## Display the user name on the homepage
Let's test our auth store and see if we can display the logged in name at the top where it says "Hey User Name".

In `src/views/HomeView.vue` import our auth store like this:
```js
<script setup>
import AddTodoInput from '../components/AddTodoInput.vue'
import TodoItem from '../components/TodoItem.vue'
import { useAuth } from '../stores/auth'

const { currentUser } = useAuth()
</script>
```

Now just replace `User Name` with `{{ currentUser.name }}`:
```html
Hey <span class="font-bold">{{ currentUser.name }}</span>
```

Check the app, and you should see your user's name.
![](https://tahazsh.com/images/blog/building-spa-in-vue-7.png)

## Implement logout
Until now, if we want to log out, the only way we can do it is by removing `todoapp-token` from the localStorage manually.

Let's now implement a proper way to log out. Logout is a feature related to authentication so it makes sense to include it as a function in our auth store.

So in `src/store/auth.js` add this at the bottom of the function:
```js
const logout = () => {
  localStorage.removeItem('todoapp-token')
  client.cache.reset()
  router.push({ name: 'Login' })
}

return { currentUser, getUser, logout }
```

When the logout function is called, we do three things. We remove the token from the localStorage, we clear apollo's cache, and then redirect to the login page.

Note how we are now exposing the logout function.

This will not work yet because `router` and `client` are not imported yet.

So, let's import the router like this:
```js
// ...
import { useRouter } from 'vue-router'
// ...
export const useAuth = defineStore('auth', () => {
  const router = useRouter()
// ...
```

The `client` variable here is the current client of apollo we're using in our app. To import it, add this:

```js
// ...
import { useLazyQuery, useApolloClient } from '@vue/apollo-composable'
// ...
export const useAuth = defineStore('auth', () => {
  const router = useRouter()
  const { client } = useApolloClient()
// ...
```

After these changes, your `src/store/auth.js` should look like this:
```js
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useLazyQuery, useApolloClient } from '@vue/apollo-composable'
import { CURRENT_USER } from '../graphql/queries'
import { useRouter } from 'vue-router'

export const useAuth = defineStore('auth', () => {
  const router = useRouter()
  const { client } = useApolloClient()

  const currentUser = ref(null)

  const { onResult, load, networkStatus, refetch, onError } = useLazyQuery(
    CURRENT_USER,
    null,
    {
      fetchPolicy: 'no-cache'
    }
  )

  const getUser = () => {
    return new Promise((resolve) => {
      if (!localStorage.getItem('todoapp-token')) {
        currentUser.value = null
        return resolve(null)
      }
      if (currentUser.value !== null) {
        return resolve(currentUser)
      }
      if (networkStatus.value) {
        refetch()
      } else {
        load()
      }
      onResult((result) => {
        if (result.data?.currentUser) {
          currentUser.value = result.data.currentUser
          resolve(currentUser)
        }
      })
      onError(() => {
        resolve(null)
      })
    })
  }

  const logout = () => {
    localStorage.removeItem('todoapp-token')
    client.cache.reset()
    router.push({ name: 'Login' })
  }

  return { currentUser, getUser, logout }
})
```

Now let's use our `logout` function in `src/views/HomeView.vue`:

```html
// ...
<button
  class="cursor-pointer text-gray-200 hover:text-white"
  @click="logout"
>
Log out
</button>

//...

<script setup>
import AddTodoInput from '../components/AddTodoInput.vue'
import TodoItem from '../components/TodoItem.vue'
import { useAuth } from '../stores/auth'

const { currentUser, logout } = useAuth()
</script>
```

All we did here is getting `logout` function from `useAuth()` and use it as the click handler for the logout button.

The log out should work as expected now.

## Integrate adding todos
Like other mutations, we need to add this mutation to our `src/graphql/mutations.js`.
```js
// ...
export const ADD_TODO = gql`
  mutation addTodo($input: AddTodoInput!) {
    addTodo(input: $input) {
      id
      title
      completed
    }
  }
`
```

Now let's use this mutation in `src/components/AddTodoInput.vue`:
```html
<template>
  <div
    class="px-5 w-full bg-gray-100 rounded-lg outline-none text-gray-900 flex items-center justify-between"
  >
    <input
      class="rounded-lg py-3.5 flex-1 bg-gray-100 outline-none pr-2.5"
      type="text"
      placeholder="What needs to be done?"
      :readonly="loading"
      v-model="title"
      @keypress.enter="onAdd"
    />
    <Spinner v-if="loading" class="text-blue-900 w-8" />
  </div>
</template>

<script setup>
import Spinner from './Spinner.vue'
import { ref } from 'vue'
import { useMutation } from '@vue/apollo-composable'
import { ADD_TODO } from '../graphql/mutations'

const title = ref('')

const { mutate: addTodo, onError, onDone, loading } = useMutation(ADD_TODO)

const onAdd = () => {
  addTodo({
    input: {
      title: title.value
    }
  })
}

onDone(() => {
  title.value = ''
})

onError(() => {
  console.error('An error occurred while adding a todo')
})
</script>
```

This should be familiar to you by now. But for this to work we need to add the `<Spinner />` component that we are using here.

Add `src/components/Spinner.vue`:
```html
<template>
  <div class="w-full">
    <!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
    <svg
      viewBox="0 0 38 38"
      xmlns="http://www.w3.org/2000/svg"
      stroke="currentColor"
    >
      <g fill="none" fill-rule="evenodd">
        <g transform="translate(1 1)" stroke-width="2">
          <circle stroke-opacity=".5" cx="18" cy="18" r="18" />
          <path d="M36 18c0-9.94-8.06-18-18-18">
            <animateTransform
              attributeName="transform"
              type="rotate"
              from="0 18 18"
              to="360 18 18"
              dur="1s"
              repeatCount="indefinite"
            />
          </path>
        </g>
      </g>
    </svg>
  </div>
</template>
```

It's just a simple SVG spinner that we show while the todo is being created.

Right now we don't display the todos, but you can test if this is working by checking the network tab in your browser to see if the mutation was sent successfully.

## Fetch and display todos
Let's first add the query for fetching todos to `src/graphql/queries.js`:
```js
// ...
export const TODOS = gql`
  query todos {
    todos {
      id
      title
      completed
    }
  }
`
```

We will fetch these todos in `src/views/HomeView.vue`, so update it like this:

```html
<template>
  <div class="mt-20 w-full max-w-lg mx-auto">
    <h1 class="text-4xl font-bold text-center">Todo App</h1>
    <div class="mt-10 flex items-center justify-between">
      <div>
        Hey <span class="font-bold">{{ currentUser.name }}</span>
      </div>
      <button
        class="cursor-pointer text-gray-200 hover:text-white"
        @click="logout"
      >
        Log out
      </button>
    </div>
    <div class="mt-2.5">
      <AddTodoInput />
      <div class="mt-3.5">
        <div
          v-if="loading"
          class="mt-10 flex items-center justify-center text-xl font-medium"
        >
          <Spinner class="w-5 mr-2.5" />
          Loading Todos
        </div>
        <div v-else-if="result.todos.length > 0" class="space-y-2.5">
          <TodoItem v-for="todo in result.todos" :key="todo.id" :todo="todo" />
        </div>
        <div v-else class="mt-10 text-center text-blue-50 text-opacity-40">
          Your Todo List is Empty
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import Spinner from '../components/Spinner.vue'
import AddTodoInput from '../components/AddTodoInput.vue'
import TodoItem from '../components/TodoItem.vue'
import { useAuth } from '../stores/auth'
import { useQuery } from '@vue/apollo-composable'
import { TODOS } from '../graphql/queries'

const { currentUser, logout } = useAuth()

const { loading, result } = useQuery(TODOS)
</script>
```

We are sending a simple query that returns todos in `result.todos`.

The main thing to pay attention to here is how we are:
- displaying the spinner when the query is loading
- displaying the todos if the list is not empty (`result.todos.length > 0`)
- displaying the empty state if the list is empty

Here's where we are handling this:
```html
<div class="mt-3.5">
  <div
    v-if="loading"
    class="mt-10 flex items-center justify-center text-xl font-medium"
  >
    <Spinner class="w-5 mr-2.5" />
    Loading Todos
  </div>
  <div v-else-if="result.todos.length > 0" class="space-y-2.5">
    <TodoItem v-for="todo in result.todos" :key="todo.id" :todo="todo" />
  </div>
  <div v-else class="mt-10 text-center text-blue-50 text-opacity-40">
    Your Todo List is Empty
  </div>
</div>
```

Also, note how we are passing each todo data to `<TodoItem />` as a prop.

So, the next step is to modify `src/components/TodoItem.vue` to use that prop.
```html
<template>
  <div
    class="group flex items-center justify-between bg-gray-200 rounded-lg px-5 py-3.5 text-gray-900"
  >
    <div class="flex items-center">
      <button
        :class="[
          todo.completed ? 'bg-blue-500 border-blue-500' : 'border-gray-500',
          'hover:border-blue-500 border-2 w-5 h-5 rounded-full flex items-center justify-center text-white cursor-pointer'
        ]"
        @click="toggle"
      >
        <svg
          v-if="todo.completed"
          style="width: 15px; height: 15px"
          viewBox="0 0 24 24"
        >
          <path
            fill="currentColor"
            d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"
          />
        </svg>
      </button>
      <span :class="[todo.completed && 'line-through text-gray-500', 'ml-2.5']">
        {{ todo.title }}
      </span>
    </div>

    <button
      class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-gray-900"
    >
      <svg style="width: 24px; height: 24px" viewBox="0 0 24 24">
        <path
          fill="currentColor"
          d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M9,8H11V17H9V8M13,8H15V17H13V8Z"
        />
      </svg>
    </button>
  </div>
</template>

<script setup>
defineProps({
  todo: Object
})
</script>
```

So we replaced our local `completed` data here with `todo.completed`. We also displayed the todo title using `{{ todo.title }}`.

## Refetch todos on add
Each time we add a new todo, we need to reload the page to see them added to the list. To fix this, we need to refetch todos every time we add a new one.

With Vue Apollo we can easily do this using the `refetchQueries` option in mutations.

Here's how to add it to `src/components/AddTodoInput.vue`:
```js
// ...
import { TODOS } from '../graphql/queries'

// ...

const {
  mutate: addTodo,
  onError,
  onDone,
  loading
} = useMutation(ADD_TODO, {
  refetchQueries: [{ query: TODOS }]
})

// ...
```

With that change, you should see new todos displayed in the list after they are added on the backend.

## Mark todos as complete
We will use `updateTodo` mutation for this. So let's add it to `src/graphql/mutations.js`:
```js
export const UPDATE_TODO = gql`
  mutation updateTodo($input: UpdateTodoInput) {
    updateTodo(input: $input) {
      status
    }
  }
`
```

Update `src/components/TodoItem.vue` to use this mutation, like this:
```js
<script setup>
import { useMutation } from '@vue/apollo-composable'
import { TODOS } from '../graphql/queries'
import { UPDATE_TODO } from '../graphql/mutations'

const props = defineProps({
  todo: Object
})

const { mutate: updateTodo, onError: onUpdateError } = useMutation(
  UPDATE_TODO,
  {
    refetchQueries: [{ query: TODOS }]
  }
)

const toggle = () => {
  updateTodo({
    input: {
      todoId: props.todo.id,
      completed: !props.todo.completed
    }
  })
}

onUpdateError(() => {
  console.error('An error occurred while updating todos')
})
</script>
```

If toggling todos work, move on to the last step.

## Delete todos
Deleting todos follow the exact same workflow as the previous one.

So first add the mutation:
```js
export const DELETE_TODO = gql`
  mutation deleteTodo($todoId: ID) {
    deleteTodo(todoId: $todoId) {
      status
    }
  }
`
```

And update `src/components/TodoItem.vue` to use it.
```js
import { UPDATE_TODO, DELETE_TODO } from '../graphql/mutations'

// ...

const { mutate: deleteTodo, onError: onDeleteError } = useMutation(
  DELETE_TODO,
  {
    refetchQueries: [{ query: TODOS }]
  }
)

const onDeleteClick = async () => {
  deleteTodo({
    todoId: props.todo.id
  })
}

onDeleteError(() => {
  console.error('An error occurred while deleting todo')
})
```

## 🎉 You did it!
So now users can create accounts, log in, log out, add todos, complete them, or delete them.

So officially, you've created your first single-page application in Vue.

## A UX challenge
Because our backend is running locally, the app feels so fast that we don't see the loading indicator.

However, if you run it on a real server and your internet is slow, you'll see some UX issues like a delay between clicking the todo checkbox and seeing it completed.

We can fix it by following a pattern called Optimistic UI, which means updating the UI even before we receive the response from the server.

So set your network throttling to "Slow 3G" and see how you can fix it.

After that, compare your solution with mine on [GitHub](https://github.com/TahaSh/todoapp-vue).