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>