Skip to main content

Vue 03 - State Management & API Integration

Pinia state management

As apps grow, passing data through long chains of props becomes unwieldy. State management provides a centralized place for data that is shared across components — user info in the navbar, shopping cart contents, auth tokens, etc.

Pinia is the official state management library for Vue. https://pinia.vuejs.org/

Setup

Register Pinia in main.ts:

import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";

const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

Defining a store

A store holds state and business logic. Use the Composition API style (Setup Store):

// src/stores/counter.ts
import { ref, computed } from "vue";
import { defineStore } from "pinia";

export const useCounterStore = defineStore("counter", () => {
// ref() → state
const count = ref(0);

// computed() → getters
const doubleCount = computed(() => count.value * 2);

// functions → actions
const increment = () => count.value++;
const incrementBy = (val: number) => (count.value += val);

return { count, doubleCount, increment, incrementBy };
});

Using a store in components

<script setup lang="ts">
import { useCounterStore } from "@/stores/counter";

const store = useCounterStore();
</script>

<template>
<div>Count: {{ store.count }} (double: {{ store.doubleCount }})</div>
<button @click="store.increment()">+1</button>
<button @click="store.incrementBy(5)">+5</button>
</template>

Destructuring store — storeToRefs

To destructure reactive state from a store, use storeToRefs(). Actions (functions) can be destructured directly.

import { storeToRefs } from "pinia";
import { useCounterStore } from "@/stores/counter";

const store = useCounterStore();
const { count, doubleCount } = storeToRefs(store);
const { increment, incrementBy } = store;

Environment variables

Vite exposes environment variables via import.meta.env. Variables must be prefixed with VITE_ to be available in client code.

Create .env files in project root:

# .env — shared defaults
VITE_API_BASE_URL=https://localhost:5001/api/v1/

# .env.production — production overrides
VITE_API_BASE_URL=https://taltech.akaver.com/api/v1/

Use in code:

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;

Add .env*.local to .gitignore for secrets that should not be committed.


Using REST APIs with Axios

Axios is a promise-based HTTP client for the browser and Node.js.

npm install axios

Basic usage with async/await

import axios from "axios";

async function getUser(id: number) {
try {
const response = await axios.get(`/api/users/${id}`);
console.log(response.data);
} catch (error: unknown) {
console.error("Failed to fetch user:", error);
}
}

Typed API service

Create a reusable service class with typed responses. Use environment variables for the base URL.

// src/types/IResultObject.ts
export interface IResultObject<TData> {
errors?: string[];
data?: TData;
}
// src/services/EntityService.ts
import axios from "axios";
import type { IResultObject } from "@/types/IResultObject";

export default class EntityService<TEntity> {
private httpClient;

constructor(basePath: string) {
this.httpClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL + basePath,
});
}

async getAll(): Promise<IResultObject<TEntity[]>> {
try {
const response = await this.httpClient.get<TEntity[]>("");
return { data: response.data };
} catch (error: unknown) {
return {
errors: [error instanceof Error ? error.message : "Unknown error"],
};
}
}

async getById(id: string): Promise<IResultObject<TEntity>> {
try {
const response = await this.httpClient.get<TEntity>(id);
return { data: response.data };
} catch (error: unknown) {
return {
errors: [error instanceof Error ? error.message : "Unknown error"],
};
}
}
}

Axios interceptors

Interceptors modify requests or responses globally — for example, attaching the JWT token to every request automatically.

// src/services/httpClient.ts
import axios from "axios";
import { useAuthStore } from "@/stores/auth";

const httpClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});

// Request interceptor — attach JWT to every request
httpClient.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.jwt) {
config.headers.Authorization = `Bearer ${authStore.jwt}`;
}
return config;
});

// Response interceptor — handle 401 globally
httpClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token expired — could trigger refresh or redirect to login
const authStore = useAuthStore();
authStore.$reset();
}
return Promise.reject(error);
}
);

export default httpClient;

Then use this shared httpClient in all services instead of creating separate Axios instances.


Authentication

Auth store

// src/stores/auth.ts
import { ref, computed } from "vue";
import { defineStore } from "pinia";

export const useAuthStore = defineStore("auth", () => {
const jwt = ref<string | null>(null);
const refreshToken = ref<string | null>(null);
const userName = ref<string | null>(null);

const isAuthenticated = computed(() => !!jwt.value);

return { jwt, refreshToken, userName, isAuthenticated };
});

Auth types

// src/types/IUserInfo.ts
export interface IUserInfo {
token: string;
refreshToken: string;
firstName: string;
lastName: string;
}

Login service

// src/services/AccountService.ts
import type { IResultObject } from "@/types/IResultObject";
import type { IUserInfo } from "@/types/IUserInfo";
import axios from "axios";

export default class AccountService {
private static httpClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL + "account/",
});

static async loginAsync(
email: string,
password: string
): Promise<IResultObject<IUserInfo>> {
try {
const response = await this.httpClient.post<IUserInfo>("login", {
email,
password,
});
if (response.status < 300) {
return { data: response.data };
}
return {
errors: [response.status.toString() + " " + response.statusText],
};
} catch (error: unknown) {
return {
errors: [error instanceof Error ? error.message : "Unknown error"],
};
}
}
}

Login view

<script setup lang="ts">
import AccountService from "@/services/AccountService";
import { useAuthStore } from "@/stores/auth";
import { useRouter } from "vue-router";
import { ref } from "vue";

const authStore = useAuthStore();
const router = useRouter();

const loginName = ref("");
const loginPassword = ref("");
const loginIsOngoing = ref(false);
const errors = ref<string[]>([]);

const doLogin = async () => {
loginIsOngoing.value = true;
errors.value = [];

const res = await AccountService.loginAsync(
loginName.value,
loginPassword.value
);

if (res.data) {
authStore.jwt = res.data.token;
authStore.refreshToken = res.data.refreshToken;
authStore.userName = res.data.firstName + " " + res.data.lastName;
router.push({ name: "Home" });
} else {
errors.value = res.errors ?? ["Login failed"];
}

loginIsOngoing.value = false;
};
</script>

<template>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6">
<form @submit.prevent="doLogin">
<div v-if="errors.length > 0" class="alert alert-danger">
<div v-for="error in errors" :key="error">{{ error }}</div>
</div>

<div class="form-floating mb-3">
<input
v-model="loginName"
class="form-control"
autocomplete="username"
placeholder="name@example.com"
type="email"
id="loginEmail"
/>
<label for="loginEmail">Email</label>
</div>

<div class="form-floating mb-3">
<input
v-model="loginPassword"
class="form-control"
autocomplete="current-password"
placeholder="password"
type="password"
id="loginPassword"
/>
<label for="loginPassword">Password</label>
</div>

<button
type="submit"
class="w-100 btn btn-lg btn-primary"
:disabled="loginIsOngoing"
>
{{ loginIsOngoing ? "Logging in..." : "Log in" }}
</button>
</form>
</div>
</div>
</template>

Loading and error patterns

A reusable pattern for views that fetch data:

<script setup lang="ts">
import { ref, onMounted } from "vue";
import type { IOwner } from "@/types/IOwner";
import EntityService from "@/services/EntityService";

const ownerService = new EntityService<IOwner>("owners/");

const owners = ref<IOwner[]>([]);
const isLoading = ref(true);
const errorMessage = ref<string | null>(null);

onMounted(async () => {
const result = await ownerService.getAll();
if (result.data) {
owners.value = result.data;
} else {
errorMessage.value = result.errors?.join(", ") ?? "Failed to load";
}
isLoading.value = false;
});
</script>

<template>
<h1>Owners</h1>

<div v-if="isLoading">Loading...</div>
<div v-else-if="errorMessage" class="alert alert-danger">
{{ errorMessage }}
</div>
<div v-else>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="owner in owners" :key="owner.id">
<td>{{ owner.name }}</td>
<td>
<RouterLink :to="{ name: 'OwnersEdit', params: { id: owner.id } }">
Edit
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</template>

CORS

When your frontend (e.g., localhost:5173) calls an API on a different origin (e.g., localhost:5001), the browser blocks the request due to Cross-Origin Resource Sharing (CORS) policy.

The fix is on the server side — the API must include CORS headers (Access-Control-Allow-Origin). In development, you can also use Vite's proxy:

// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api": {
target: "https://localhost:5001",
changeOrigin: true,
},
},
},
});

Composables

Composables are functions that encapsulate reusable stateful logic using Vue's Composition API — similar to React's custom hooks.

// src/composables/useApi.ts
import { ref } from "vue";
import type { IResultObject } from "@/types/IResultObject";

export function useApi<T>(apiFn: () => Promise<IResultObject<T>>) {
const data = ref<T>();
const isLoading = ref(false);
const error = ref<string | null>(null);

const execute = async () => {
isLoading.value = true;
error.value = null;

const result = await apiFn();

if (result.data) {
data.value = result.data;
} else {
error.value = result.errors?.join(", ") ?? "Unknown error";
}
isLoading.value = false;
};

return { data, isLoading, error, execute };
}

Usage in a component:

<script setup lang="ts">
import { onMounted } from "vue";
import { useApi } from "@/composables/useApi";
import EntityService from "@/services/EntityService";
import type { IOwner } from "@/types/IOwner";

const ownerService = new EntityService<IOwner>("owners/");
const { data: owners, isLoading, error, execute } = useApi(() =>
ownerService.getAll()
);

onMounted(execute);
</script>

<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="owner in owners" :key="owner.id">{{ owner.name }}</li>
</ul>
</template>