Vue + Ruby banner

August 15th 2023

Use Vue 3 with a Rails 7 API

How to set up a Vue application with client side state management for our Rails API and authentication.

In my past two articles, I created a Rails 7 REST API that has a Task model and a couple CRUD actions to create, destroy and complete a given task or to get a list of all existing tasks, and I added authentication with Devise and JWT to this Rails API.

It's finally time to get this to good use and build our frontend application.

I am a Vue dev so that's what I'm gonna go with in this article but I'll try to make it relevant for other frameworks too by talking about the concepts involved in using an API in our client app. I'll also handle Client Side State Management which will help enable our user's state to persist through pages, and Client Side Routing.

Now that the introduction is done, let's get building.

Creating a Vue app

The simplest way to create a Vue app nowadays, and the simplest way to create an app with any other SPA framework really, is to use create-vite. This time though, because I know I'll be using some Vue specific packages, Vue Router and Pinia, I'll use create-vue instead which can install those for us automatically.

Let's get back inside the to-do-list repository, which already contains our Rails API and a couple executable bin files, and create a new client directory for our Vue app.

cd to-do-list
npm create vue@3

Don't forget to select Yes for at least Vue Router and Pinia during the creation process.

Vue.js - The Progressive JavaScript Framework

 Project name: client
 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 an End-to-End Testing Solution? No
 Add ESLint for code quality? No

Scaffolding project in path/to/to-do-list/client...

Done. Now run:

  cd client
  npm install
  npm run dev

Just like we did in the first article of this serie, we'll create a couple executables inside our bin folder to enable us to interact with our client directory without having to cd in and out of it.

I'll call the first one bin/vite, it'll will run any command for our client from our root repository

bin/vite
#!/bin/bash

cd client && npm "$@"

And another, bin/dev, which will be used to run both our Rails server and our Vite server from a single command inside a single terminal with the help of Concurrently.

bin/dev
#!/bin/bash

if ! npm list -g concurrently >/dev/null 2>&1; then
  echo "Installing Concurrently..."
  npm i --location=global concurrently
fi

echo "Running servers..."
echo "###############################"
concurrently --kill-others -n Rails:,Vite:, -c red,green, "bin/rails s" "bin/vite run dev"

We can thel make them executable by modifying their rights.

chmod +x bin/vite
chmod +x bin/dev

You can now install the dependencies for your client app and run both servers with these commands.

bin/vite install
bin/dev

Once you've made it work, you can remove all the boilerplate code from create-vue as we'll be writing our own from now on.

Handling user authentication with State Management

The first thing that'll be asked from us when using the app is to log in, so the first thing we should focus on is to write the logic to authenticate users and persist their sessions.

Most of the time this is done by creating a Store that will preserve the data related to the current user and make it consistent and accessible across any area of our application. This method of preserving data inside stores is called State Management. The most common way to implement a State Management logic in our frontend apps is to install a dedicated library.

In Vue the officially supported state management library is called Pinia like I mentionned before. It was installed during the creation of our Vue app so we can directly create a new store as client/src/stores/UserStore.js.

client/src/stores/UserStore.js
import { computed, ref } from "vue"
import { defineStore } from "pinia"

const API_URL = "http://localhost:3000"

const useUserStore = defineStore("UserStore", () => {
  // state

  // getters

  // actions
})

export default useUserStore

Pinia can be used with Vue's Option API or Vue's Composition API but the Composition API is the new standard for Vue applications so that's what I used above and will be using across the whole article. If you want to learn more you can check the Composition API FAQ.

As you can see we need to define some a state, some getters and actions.

The state is the central part of the store so let's start there. It defines the data that will be added to the State of our application and in our case that would be a user Object and the JSON Web Token linked to this user.

client/src/stores/UserStore.js
...

const useUserStore = defineStore("UserStore", () => {
  // state
  const user = ref(null)
  const bearerToken = ref(null)

  ...

  return { user, bearerToken }
})

...

The getters are readonly variables computed from our state variables. You might or might not need one in your stores but in the context of this UserStore it'd be nice to have a computed value of whether a user is logged in or not so I'll make one.

client/src/stores/UserStore.js
...

const useUserStore = defineStore("UserStore", () => {
  ...

  // getters
  const isLoggedIn = computed(() => bearerToken.value !== null)

  ...

  return { ..., isLoggedIn }
})

...

Finally the actions enable us to change the state of this store. These are used to make your HTTP Request for example, or to reset the state of the Store and whatnot.
Any change you want to apply to the state of a Store is best done through an action.
Let's make a few to register, sign in and sign out our users.

client/src/stores/UserStore.js
...

const useUserStore = defineStore("UserStore", () => {
  ...

  // action
  const login = formData => postRequest("/users/sign_in", formData)

  const signup = formData => postRequest("/users", formData)

  const postRequest = async (endPoint, formData) => {
    try {
      const params = { user: Object.fromEntries(formData) }
      const response = await fetch(`${API_URL}${endPoint}`, {
        method: "POST",
        body: JSON.stringify(params),
        headers: { "Content-Type": "application/json" }
      })

      const isSuccessful = response.ok
      const data = await response.json()

      if (!isSuccessful) {
        throw new Error(data.messages)
      }

      user.value = data.user
      bearerToken.value = Object.fromEntries(response.headers).authorization
      localStorage.bearerToken = bearerToken.value
    } catch (error) {
      return { error }
    }
  }

  const loginWithToken = async () => {
    const token = localStorage.bearerToken
    const tokenExists = token !== undefined && token !== null

    if (tokenExists) {
      try {
        const response = await fetch(`${API_URL}/current_user`, {
          headers: { Authorization: token }
        })

        if (!response.ok) {
          throw new Error("Invalid Bearer Token")
        }

        const data = await response.json()
        user.value = data.user
        bearerToken.value = token
      } catch (error) {
        console.log(error)
      }
    }
  }

  const logout = async () => {
    try {
      const response = await fetch(`${API_URL}/users/sign_out`, {
        method: "DELETE",
        headers: { Authorization: bearerToken.value }
      })

      if (!response.ok) {
        const data = await response.json()
        throw new Error(data.messages)
      }

      user.value = null
      bearerToken.value = null
      localStorage.removeItem("bearerToken")
    } catch (error) {
      console.log(error)
    }
  }

  return { ..., login, signup, loginWithToken, logout }
})

...

And that's it for our UserStore.

As you can see in the postRequest method, everytime a user sucessfully login we save his Bearer token inside its browser's localeStorage. This will allow us to attempt to log the user in everytime he revisits the website.

To do that let's go inside the main file of our application, in Vue that would be src/main.js, and just before we mount the app to the DOM let's use our loginWithToken() method.

client/src/main.js
import "./assets/main.css"
import { createApp } from "vue"
import { createPinia } from "pinia"

import App from "./App.vue"
import router from "./router"
import useUserStore from "./stores/UserStore.js"

const app = createApp(App)

app.use(createPinia())

useUserStore()
  .loginWithToken()
  .then(() => {
    app
      .use(router)
      .mount("#app")
  })

Your app will now log your user in everytime the user loads the page as long as he has a valid Bearer token in his localStorage.

Let's not wrap up user authentication with a couple views

client/src/App.vue
<template>
  <router-view />
</template>

<script setup>
</script>
client/src/views/Home.vue
<template>
  <div>
    <h1>To Do List manager app</h1>

    <router-link :to="{ name: 'Logout' }">Log out</router-link>
  </div>
</template>

<script setup>
</script>
client/src/views/Login.vue
<template>
  <div>
    <nav>
      <router-link :to="{ name: 'Login' }">Login</router-link>
      <router-link :to="{ name: 'Signup' }">Sign up</router-link>
    </nav>

    <form @submit.prevent="handleFormSubmit">
      <label for="email">Email:</label>
      <input type="text" name="email">
      <label for="password">Password:</label>
      <input type="password" name="password">
      <input type="submit" value="Sign in">
    </form>
  </div>
</template>

<script setup>
  import { useRouter } from "vue-router"
  import useUserStore from "../stores/UserStore.js"

  const router = useRouter()
  const userStore = useUserStore()

  const handleFormSubmit = async event => {
    const form = event.target
    const formData = new FormData(form)
    const response = await userStore.login(formData)

    if (response?.error) {
      console.log(response.error)
    } else {
      router.push("/")
      form.reset()
    }
  }
</script>

<style scoped>
</style>
client/src/views/Signup.vue
<template>
  <div>
    <nav>
      <router-link :to="{ name: 'Login' }">Login</router-link>
      <router-link :to="{ name: 'Signup' }">Sign up</router-link>
    </nav>

    <form @submit.prevent="handleFormSubmit">
      <label for="email">Email:</label>
      <input type="text" name="email">
      <label for="password">Password:</label>
      <input type="password" name="password">
      <label for="password_confirmation">Password Confirmation:</label>
      <input type="password" name="password_confirmation">
      <input type="submit" value="Sign in">
    </form>
  </div>
</template>

<script setup>
  import { useRouter } from "vue-router"
  import useUserStore from "../stores/UserStore.js"

  const router = useRouter()
  const userStore = useUserStore()

  const handleFormSubmit = async event => {
    const form = event.target
    const formData = new FormData(form)
    const response = await userStore.signup(formData)

    if (response?.error) {
      console.log(response.error)
    } else {
      router.push("/")
      form.reset()
    }
  }
</script>

<style scoped>
</style>

And the routes we need to access these views.
While we're in the router we'll also add an auth guard system that will verify whether a user is logged in or not before he can access some pages.

client/src/router/router.js
import { createRouter, createWebHistory } from "vue-router"
import useUserStore from "../stores/UserStore.js"

import Home from "../views/Home.vue"

const authGuard = (to, next) => {
  const isLoggedIn = useUserStore().isLoggedIn
  const requiresAuth = to.meta.requiresAuth

  if (isLoggedIn && !requiresAuth) return next({ name: "Home" })
  if (!isLoggedIn && requiresAuth) return next({ name: "Login" })

  return next()
}

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
    meta: { requiresAuth: true }
  },
  {
    path: "/users/sign_in",
    name: "Login",
    component: () => import("../views/Login.vue"),
    meta: { requiresAuth: false }
  },
  {
    path: "/users/sign_up",
    name: "Signup",
    component: () => import("../views/Signup.vue"),
    meta: { requiresAuth: false }
  },
  {
    path: "/users/logout",
    name: "Logout",
    meta: { requiresAuth: true },
    beforeEnter: async (_to, _from, next) => {
      await useUserStore().logout()
      next({ name: "Login" })
    }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

router.beforeEach((to, _from, next) => {
  authGuard(to, next)
})

export default router

A lot of things happened in the last couple code blocks but basically what I did was; I created a login and a signup form which do not require a user to be authenticate to be accessed (obviously) and I created a homepage which we will use later to display our to-do list. Because the Task model of our api requires a user, I also added an auth guard to this route so that a visitor would be prompted to log in if he tries to access the homepage.

Now that all of this is done authentication is completly handled by our Vue client app and we can move on to the to-do list itself.

Adding Task CRUD actions to our homepage

All we have left to do is add a form that will send a POST request to our API to create a new task, make a GET request to display all our existing tasks and make PATCH and DELETE requests to, respectively, complete and destroy a given task with a couple buttons.

We've already tested all the endpoints of our API on Postman when we created them so we know how to make the requests, there shouldn't be any difficulty anymore.

client/src/views/Home.vue
<template>
  <div>
    <h1>To Do List manager app</h1>

    <form @submit.prevent="handleFormSubmit">
      <input type="text" name="title" placeholder="Got a new task to do ?">
      <input type="submit" value="Add to list">
    </form>

    <ul>
      <li v-for="(task, index) in sortedTasks" :key="task.id">
        <p>{{ task.title }}</p>
        <p>Task is {{ task.completed ? '' : 'not ' }}completed</p>
        <button @click="completeTask(task.id, index)">Complete</button>
        <button @click="deleteTask(task.id, index)">Delete</button>
      </li>
    </ul>

    <router-link :to="{ name: 'Logout' }">Log out</router-link>
  </div>
</template>

<script setup>
  import { computed, onBeforeMount, ref, toRefs } from "vue"
  import useUserStore from "../stores/UserStore.js"

  const API_URL = "http://localhost:3001"

  const userStore = useUserStore()
  const { bearerToken } = toRefs(userStore)

  const tasks = ref([])
  const sortedTasks = computed(() => tasks.value.sort((a, b) => b.id - a.id))

  const completeTask = async (id, index) => {
    await fetch(`${API_URL}/tasks/${id}/complete`, {
      method: "PATCH",
      headers: { Authorization: bearerToken.value }
    })

    const task = tasks.value[index]
    task.completed = true
  }

  const deleteTask = async (id, index) => {
    await fetch(`${API_URL}/tasks/${id}`, {
      method: "DELETE",
      headers: { Authorization: bearerToken.value }
    })

    tasks.value.splice(index, 1)
  }

  const handleFormSubmit = async event => {
    const form = event.target
    const formData = new FormData(form)
    const params = { task: Object.fromEntries(formData) }

    try {
      const response = await fetch(`${API_URL}/tasks`, {
        method: "POST",
        body: JSON.stringify(params),
        headers: {
          Authorization: bearerToken.value,
          "Content-type": "application/json"
        }
      })

      const isSuccessful = response.ok
      const data = await response.json()

      if(!isSuccessful) {
        throw new Error(data.messages)
      }

      tasks.value.push(data.task)
      form.reset()
    } catch (error) {
      console.log(error)
    }
  }

  onBeforeMount(async () => {
    const response = await fetch(`${API_URL}/tasks`, {
      headers: { Authorization: bearerToken.value }
    })

    const data = await response.json()
    tasks.value = data.tasks
  })
</script>

For non Vue users : `ref` is a way to define a reactive variable. This means that if its value is updated it will also update the DOM. `toRefs` is a way to deconstruct a reactive Object into `ref` variables which makes it easier to use. Without `toRefs` we'd need to do `useUserStore().bearerToken` everytime we want to access it. And `computed` is a readonly reactive variable.
If interested you can read more about Vue's Reactivity API.

And at long last, here we are with our working Vue on Rails To do list application !

Well... The style is not great I admit but I'll let you work on that part if you wanna. But as far as the rest goes; The API's TasksControllers correctly reponds to JSON requests. It safely handles authentication. The client is able to properly deal with authentication and authorization. And it execute CRUD actions to our API as expected.

It might have been a lot to handle on a first try but now that you've done it once you'll see that it end up being nearly the same procedure everytime and you'll quickly get used to it.

Congratulation on following along this serie of articles ! REST APIs are a very common way to build applications nowadays and you can now practice this architecture with confidence.

Cheers

More resources

Want to read more ?

More blogs