Vuero and Vue 3
Reuse stateful logic with composable
Composables are reusables pieces of code that handle stateful logic, using functions from the Reactivity API and Composition API. They are only meant to be called synchronously in a <script setup lang="ts">
context. _You may want to read more about on the Vue.js documentation website: https://vuejs.org/guide/extras/composition-api-faq.html_
Here are some examples of composables from the @vueuse/core
library used in Vuero:
- useCssVar: https://vueuse.org/core/useCssVar
- usePointer: https://vueuse.org/core/usepointer
- useDraggable: https://vueuse.org/core/usedraggable
- useLocalStorage: https://vueuse.org/core/useLocalStorage
You can also find internal composables in ./src/composable
, such as useNotyf
, useApi
and useDropdown
Why the Vue 3 Composition API?
TIP
View the full course available on Vue Mastery:
https://www.vuemastery.com/courses/vue-3-essentials/why-the-composition-api/
Reactive data stores with Pinia
When you need to share data accross the entire application, you can use Pinia library, which replaces Vuex in the Vue 3 ecosystem.
When to use Pinia stores:
- When you need to share data everywhere in your application, whith one source of truth.
- When you need to keep data in all app lifecycle (ex: between pages transitions).
When not to use stores:
- When you need to share data in only a component tree (ex: a single page), use provide/inject instead.
Let's see how we can create a simple store to handle the user session data:
// src/stores/userSession.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useStorage } from "@vueuse/core";
export type UserData = Record<string, any> | null;
export const useUserSession = defineStore("userSession", () => {
// token will be synced with local storage
// @see https://vueuse.org/core/useSessionStorage/
const token = useSessionStorage("token", "");
// we use ref and computed to handle reactive data
const user = ref<Partial<UserData>>();
const loading = ref(true);
const isLoggedIn = computed(
() => token.value !== undefined && token.value !== ""
);
// but we also declare functions to alter the state
function setUser(newUser: Partial<UserData>) {
user.value = newUser;
}
function setToken(newToken: string) {
token.value = newToken;
}
function setLoading(newLoading: boolean) {
loading.value = newLoading;
}
async function logoutUser() {
token.value = undefined;
user.value = undefined;
}
// we return the entire store
return {
// this is our state
user,
token,
isLoggedIn,
// and our mutations
loading,
logoutUser,
setUser,
setToken,
setLoading,
} as const;
});
We can now use the store everywhere in our application:
<script setup lang="ts">
import { watch } from "vue";
import { useUserSession } from "/@src/stores/userSession";
const userSession = useUserSession();
watch(userSession.isLoggedIn, () => {
console.log("login status changed", userSession.isLoggedIn);
});
</script>
<template>
<div v-if="userSession.isLoggedIn">
Welcome {{ userSession.user.name }} -
<VButton @click="userSession.logoutUser">Logout</VButton>
</div>
<div v-else>You are not logged in</div>
</template>
TIP
Read the pinia documentation on the official website: https://pinia.vuejs.org/
Vuero Plugins
You can create plugins for Vuero simply by creating *.ts
files in src/plugins
directory.
All plugins will be registered automatically before the app runs.
Here is a plugin boilerplate:
import { definePlugin } from '/@src/app'
export default definePlugin(async ({ app, api, router, head, pinia }) => {
// run your plugin code here
// If you need to perform conditional logic based on SSR vs. Client Only, you can use
if (import.meta.env.SSR) {
// ... server only logic
}
// You can do the same for Client Only logic
if (!import.meta.env.SSR) {
// ... client only logic
// you can lazyload libraries that won't work on server side asynchronously
import('client-only-library').then((module) => {
// ... do stuff with the module
})
}
})
TIP
This is a good place to fetch user infos / global settings prior the app starts
Advanced router management
Vue Router Data Loaders
Instead of loading data within the component lifecycle, you can use new defineLoader
helper function introduced by the vue-router author in this official Vue RFC: https://github.com/vuejs/rfcs/discussions/460
Keep in mind this is an experimental feature from vue-router that may change in future. Feel free to contribute to the RFC's discussions if you have any feedback!
This is an example on how to use defineLoader
in a page component (ex: ./src/pages/article/[slug].vue
)
<script lang="ts">
// note that we are not in a <script setup> context here
import { defineLoader } from 'vue-router/auto'
import { useApi } from '/@src/composable/useApi'
interface Article {
id: string
title: string
slug: string
content: string
comments: string[]
}
export const useArticle = defineLoader(async (route) => {
const slug = (route.params?.slug as string) ?? ''
const api = useApi()
const { data } = await api.get<Article[]>(`/articles?slug=${slug}`)
const article = data?.[0]
return article
})
</script>
<script setup lang="ts">
const { data: article, pending } = useArticle()
const router = useRouter()
if (!article.value) {
// If the article does not exist, we replace the route to the 404 page
// we also pass the original url to the 404 page as a query parameter
// http://localhost:3000/article-not-found?original=/blog/a-fake-slug
router.replace({
name: '/[...all]', // this will match the ./src/pages/[...all].vue route
params: {
all: 'article-not-found',
},
query: {
original: router.currentRoute.value.fullPath,
},
})
}
// Setup our page meta with our article data
useHead({
title: computed(() => article.value?.title ?? 'Loading article...'),
})
</script>
<template>
<LandingLayout theme="light">
<div v-if="pending">Loading article...</div>
<div v-else class="blog-detail-wrapper">
<!--
Page content goes here
You can see more complete pages content samples from
files in /src/components/pages directory
-->
<h1>{{ article?.title }}</h1>
<div>{{ article?.content }}</div>
</div>
</LandingLayout>
</template>
<style lang="scss" scoped>
.blog-detail-wrapper {
// Here we can add custom styles for the blog page
// They will be only applied to this component
}
</style>
Navigation guards
In Vuero, you can easily setup the router navigation guards by creating a plugin.
import { definePlugin } from '/@src/app'
import { useUserSession } from '/@src/stores/userSession'
export default definePlugin(({ router, api, pinia }) => {
const userSession = useUserSession(pinia)
// 1. Check token validity before the app start
if (userSession.isLoggedIn) {
try {
// Do api request call to retreive user profile.
// Note that the api requests the json-server
const { data: user } = await api.get('/api/users/me')
userSession.setUser(user)
} catch (err) {
// delete stored token if it fails
userSession.logoutUser()
}
}
router.beforeEach((to) => {
// 2. If the page requires auth, check if user is logged in
// if not, redirect to login page.
if (to.meta.requiresAuth && !userSession.isLoggedIn) {
return {
name: '/auth/login',
// save the location we were at to come back later
query: { redirect: to.fullPath },
}
}
})
}
Adding metadata to routes
You might have noticed that we used the meta
property of the to
route. This is a great feature to add metadata to routes, such as requiresAuth
, layout
or anything else that is arbitrary information about routes.
Thanks to unplugin-vue-router
, we can simply add a new root <route>
element to our *.vue
files located in the ./src/pages
directory
Let's take the blog example from the Setup Your Project - Creating new pages section.
First case: setting metadata to a single route
We want to add a new page accessible at /blog/new
for article creation, which will be only accessible to logged in users, while the rest of the blog will be accessible to all users:
my-vuero-quickstarter-project/
├── src/
│ ├── pages/
│ │ ├── blog/ // blog nested routes
+│ │ │ ├── new.vue // the article creation page accessible at `/blog/new`
│ │ │ ├── index.vue // the articles listing page accessible at `/blog/`
│ │ │ └── [slug].vue // the article detail page accessible at `/blog/:slug`
│ │ └── blog.vue // blog nested routes wrapper, should contain a `<RouterView />`
In the src/pages/blog/new.vue
file, we add the requiresAuth
metadata to the route:
<route lang="yaml">
meta:
requiresAuth: true
</route>
<script setup lang="ts">
// the article creation script
</script>
<template>
<!-- the article creation template -->
</template>
Second case: setting metadata to nested routes
We want to restrict our entire /blog/*
pages to logged in users:
my-vuero-quickstarter-project/
├── src/
│ ├── pages/
│ │ ├── blog/ // blog nested routes
│ │ │ ├── index.vue // the articles listing page accessible at `/blog/`
│ │ │ └── [slug].vue // the article detail page accessible at `/blog/:slug`
+│ │ └── blog.vue // blog nested routes wrapper, should contain a `<RouterView />`
In the src/pages/blog.vue
file, we add the requiresAuth
metadata to the wrapper route:
<route lang="yaml">
meta:
requiresAuth: true
</route>
<script setup lang="ts">
// we do not need to so anything special here, but we can!
</script>
<template>
<LandingLayout theme="light">
<RouterView />
</LandingLayout>
</template>
Advanced vue customization
Using VueUse
Vueuse is a collection of essential Vue Composition API utilities for building your next Vue application.
We recommend you to use this library to avoid reinventing the wheel, the library is well documented and has a lot of useful features, to name a few:
useFetch
: Reactive Fetch API.useShare
: Reactive Web Share API.useWebNotification
: The Web Notification interface of the Notifications APIonClickOutside
: Listen for clicks outside of an element- 'useIntersectionObserver': Detects that a target element's visibility.
TIP
read the vueuse documentation on the official website:
https://vueuse.org/
Register global components
In Vuero, you can easily setup global components or extend the global vue scope by creating a plugin. We recommends you to create a single plugin by import, for instance one for vue-tippy, another for v-calendar, etc...
import { plugin as VueTippy } from "vue-tippy";
import { definePlugin } from '/@src/app'
export default definePlugin(({ app }) => {
// register the vue-tippy plugin globally
app.use(VueTippy, {
component: "Tippy",
defaultProps: {
theme: "light",
},
});
// register global components
// here we use defineAsyncComponent, so the component will be lazyloaded
app.component(
"VCalendar",
defineAsyncComponent({
loader: () => import("v-calendar").then((mod) => mod.Calendar),
delay: 0,
suspensible: false,
})
);
// register global directives
app.directive("focus", {
mounted: (el) => el.focus(),
});
}
Vue and Typescript?
TIP
View the full course available on Vue Mastery:
Typescript is just an extention of Javascript, If you are new to it, don't be afraid because all valid JavaScript code is also TypeScript code. (TypeScript documentation)
The main advatages of using Typescript are:
- Validates your code ahead of time
- Provides auto-completion
- Supports complex Type checking
Vuero is fully compatible with Typescript. Therefore, you will gain the benefits of improved type checking and code completion! If you use typescript notation in all of your components, you will be able to run the vue-tsc
compiler to check your application for errors.
Let's create a simple MyCustomInput
with Typescript features:
<script setup lang="ts">
export interface MyCustomInputProps {
modelValue: string;
label?: string;
id?: string;
disabled?: boolean;
}
export interface MyCustomInputEmits {
(e: "update:modelValue", value: string): void;
(e: "disable"): void;
}
// withDefaults and defineProps are global helpers from @vue/sfc-compiler
const props = withDefaults(defineProps<MyCustomInputProps>(), {
label: undefined,
id: undefined,
});
// defineEmits is global helpers from @vue/sfc-compiler
const emit = defineEmits<MyCustomInputEmits>();
// this is our method that emits the event
function onInputChanged(event: Event) {
emit("update:modelValue", (event.target as HTMLInputElement).value);
}
</script>
<template>
<label v-if="props.label" :for="props.id">
{{ props.label }}
</label>
<input
:value="props.modelValue"
:disabled="props.disabled"
@change="onInputChanged"
/>
<VButton v-if="!props.disabled" @click="emit('disable')">disable me!</VButton>
</template>
We can the verify that our component is working as expected, simply load in another component
<template>
<MyCustomInput :model-value="42" />
</template>
To confirm that the vue-tsc
compiler is working, run the following command:
pnpm test:tsc
Note that the
test:tsc
command will fail, as we have set a number in the:model-value
prop.
It should output the following error:
src/pages/index.vue:13:23 - error TS2322: Type 'number' is not assignable to type 'string'.
13 <MyCustomInput :model-value="42" />
WARNING
On large applications, test:tsc
may run out of memory. You can increase the memory limit by running the following command:
NODE_OPTIONS=--max_old_space_size=4096 pnpm test:tsc
Keep it clean with linters
Because we love clean code, we have configured 4 linters which have their own purpose:
eslint
: prevent code quality concerns (no unused vars, ...)stylelint
: prevent css quality concerns (no invalid colors, ...)prettier
: handles formatting rules (max line lenght, ...)commitlint
: handles formatting commit messages
You can check the quality of your code by running
pnpm test:lint
Linters can fix a lot of issues all by themselves. To do so, try runing
pnpm lint
VSCode users
Install recommended extensions, linting will then occur each time files are saved!