Loaders
A loader is a request plus its reactive state. It never throws : you start it, then render from status and react with useWatchTrue . Pick the loader by the shape of the answer.
Source in the kit: composables/loaderPage.ts · loaderList.ts · loaderObject.ts
The status object
Every loader exposes the same four flags. isLoaded means "finished at least once", whatever the outcome; errorData is the API's { code, message, fields? } .
interface IStatus {
isLoading: boolean // a request is running
isLoaded: boolean // a request has finished (success or error)
isSuccess: boolean
isError: boolean
errorData: { code: string, message: string, fields?: Record<string, { message: string }> } | null
}
// React to the moment a flag turns true
useWatchTrue(() => users.add.status.isSuccess, () => { /* toast, close modal, refetch */ })
useWatchTrue(() => users.add.status.isError, () => { /* StringHelper.getError(users.add.status.errorData) */ })usePageLoader: paginated lists
examples/loaders/PageLoader.vue Expects { items, page, total, limit } . Gives you fetchPage , fetchSearch , fetchPageChange , fetchNextPage and fetchAll .
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
<Input
v-model="query"
icon="ph:magnifying-glass"
placeholder="Search name or email"
@keyup.enter="users.fetchSearch(query, filters())"
/>
<Select
v-model="role"
:items="['all', 'admin', 'editor', 'viewer']"
class="w-32"
/>
<Button
label="Search"
@click="users.fetchSearch(query, filters())"
/>
</div>
<p
v-if="users.fetch.status.isLoading"
class="text-sm text-gray-500"
>
Loading…
</p>
<p
v-else-if="users.fetch.status.isError"
class="text-sm text-red-600"
>
{{ StringHelper.getError(users.fetch.status.errorData) }}
</p>
<ul
v-else
class="divide-y divide-gray-100 text-sm"
>
<li
v-for="user in users.fetch.items"
:key="user.id"
class="flex justify-between py-1.5"
>
<span>{{ user.name }}</span>
<span class="text-gray-500">{{ user.role }}</span>
</li>
</ul>
<div class="flex items-center gap-2 text-sm">
<Button
label="Previous"
color="neutral"
variant="outline"
size="sm"
:disabled="users.fetch.options.currentPage <= 1"
@click="users.fetchPageChange(users.fetch.options.currentPage - 1)"
/>
<span>Page {{ users.fetch.options.currentPage }} / {{ users.fetch.options.totalPage }}</span>
<Button
label="Next"
color="neutral"
variant="outline"
size="sm"
:disabled="users.fetch.options.currentPage >= users.fetch.options.totalPage"
@click="users.fetchPageChange(users.fetch.options.currentPage + 1)"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { createUsersLoader } from '~/loaders/users'
const users = createUsersLoader()
const query = ref('')
const role = ref('all')
// Extra query params ride along in `params`; fetchPageChange re-sends the ones of the last request
const filters = () => ({
params: {
limit: 5,
role: role.value === 'all' ? undefined : role.value,
},
})
watch(role, () => users.fetchSearch(query.value, filters()))
// fetchPage(page, query, options) sends GET /users?page=1&q=&limit=5
onMounted(() => users.fetchPage(1, '', filters()))
defineExpose({
debug: computed(() => ({
'fetch.status': users.fetch.status,
'fetch.options': users.fetch.options,
'fetch.items.length': users.fetch.items.length,
})),
})
</script>loaders/users.ts
import { defineStore } from 'pinia'
// One file per API resource: its types, then one store per loader.
// `defineStore` makes the loader a singleton, so a list page and its edit form share the same state.
export interface IUser {
id: string
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
salary: number
isActive: boolean
avatar: string
createdAt: string
}
export interface IRole {
id: string
name: string
}
// Paginated CRUD: GET /users, GET /users/:id, POST /users, PUT /users/:id, DELETE /users/:id
export const createUsersLoader = () => {
const options = useRequestOptions()
return usePageLoader<IUser>({
baseURL: '/users',
getBaseRequestOptions: options.getMock,
})
}
// The store is the shared instance: a list page and the form that edits it see the same state.
// Call createUsersLoader() instead when a component wants a private one (each docs example does).
export const useUsersLoader = defineStore('users', createUsersLoader)
// A plain array: GET /roles
export const useRolesLoader = defineStore('roles', () => {
const options = useRequestOptions()
return useListLoader<IRole>({
url: '/roles',
getRequestOptions: options.getMock,
})
})
// A single object, or any one-off request: GET /profile
export const useProfileLoader = defineStore('profile', () => {
const options = useRequestOptions()
return useObjectLoader<IUser & { loadedAt: string }>({
method: 'get',
url: '/profile',
getRequestOptions: options.getMock,
})
})composables/useRequestOptions.ts
import type { AxiosRequestConfig } from 'axios'
export const useRequestOptions = () => {
const config = useRuntimeConfig()
// The mock API is served by this same app, so its address is wherever the page came from.
// A fixed http://localhost:3000 only works on the machine running the dev server.
const origin = useRequestURL().origin
const getMock = (): Omit<AxiosRequestConfig, 'baseURL'> & {
baseURL: string
} => {
return {
baseURL: String(config.public.baseAPIMock || `${origin}/api/mock`),
}
}
const getFile = (): Omit<AxiosRequestConfig, 'baseURL'> & {
baseURL: string
} => {
// Set NUXT_PUBLIC_UPLOAD_API and NUXT_PUBLIC_UPLOAD_TOKEN to upload to a real API; falls back to the local mock
const uploadAPI = config.public.uploadAPI as string
const uploadToken = config.public.uploadToken as string
if (!uploadAPI) return getMock()
return {
baseURL: uploadAPI,
headers: uploadToken
? {
Authorization: `Bearer ${uploadToken}`,
}
: {},
}
}
const getDefault = (): Omit<AxiosRequestConfig, 'baseURL'> & {
baseURL: string
} => {
return {
baseURL: String(config.public.baseAPI),
}
}
return {
getDefault,
getMock,
getFile,
}
}usePageLoader: find, add, update, delete
examples/loaders/Crud.vue The same loader covers the whole REST resource: GET /users/:id , POST /users , PUT /users/:id , DELETE /users/:id .
Each operation keeps its own status and item, so a page can show "saving" and "deleting" separately.
<template>
<div class="space-y-3 text-sm">
<div class="flex flex-wrap gap-2">
<Button
label="findRun('1')"
size="sm"
@click="users.findRun('1')"
/>
<Button
label="addRun"
size="sm"
:loading="users.add.status.isLoading"
@click="add"
/>
<Button
label="updateRun('1')"
size="sm"
:loading="users.update.status.isLoading"
@click="users.updateRun('1', { data: { name: `Ada (${new Date().getSeconds()}s)` } })"
/>
<Button
label="deleteRun(last added)"
size="sm"
color="error"
variant="soft"
:disabled="!users.add.item"
@click="users.deleteRun(users.add.item!.id)"
/>
</div>
<p class="text-gray-500">
Each operation keeps its own status and item, so a page can show "saving" and "deleting" separately.
</p>
</div>
</template>
<script lang="ts" setup>
import { createUsersLoader } from '~/loaders/users'
const users = createUsersLoader()
const notification = useNotification()
const add = () =>
users.addRun({
data: {
name: 'New user',
email: `new.user.${Date.now()}@example.com`,
role: 'viewer',
},
})
// updateRun also replaces the item inside fetch.items and find.item; deleteRun removes it from fetch.items
useWatchTrue(
() => users.update.status.isSuccess,
() =>
notification.success({
title: `Renamed to ${users.update.item?.name}`,
})
)
useWatchTrue(
() => users.delete.status.isSuccess,
() =>
notification.info({
title: 'Deleted',
})
)
defineExpose({
debug: computed(() => ({
'find.item': users.find.item?.name ?? null,
'add.status': users.add.status,
'add.item': users.add.item,
'update.status.isSuccess': users.update.status.isSuccess,
'delete.status.isSuccess': users.delete.status.isSuccess,
})),
})
</script>useListLoader: plain arrays
examples/loaders/ListLoader.vue<template>
<div class="space-y-3">
<FormFields :options="fields" />
<Button
label="Reload roles"
color="neutral"
variant="outline"
size="sm"
:loading="roles.status.isLoading"
@click="roles.run()"
/>
</div>
</template>
<script lang="ts" setup>
import { useRolesLoader } from '~/loaders/users'
// useListLoader is for endpoints that answer with a plain array: look-up lists, select options…
const roles = useRolesLoader()
const form = useForm()
const fields = createFormFields(() => [
{
type: INPUT_TYPES.SELECT,
props: {
name: 'role',
label: 'Role',
loading: roles.status.isLoading,
// ArrayHelper.toOptions(items, valueKey = 'id', labelKey = 'name') -> [{ value, label }]
options: ArrayHelper.toOptions(roles.items),
},
},
])
// `expire` (ms) skips the request while the last answer is still fresh: leave and reopen this page
onMounted(() =>
roles.run({
expire: 60_000,
})
)
defineExpose({
debug: computed(() => ({
status: roles.status,
items: roles.items,
value: form.values,
})),
})
</script>useObjectLoader: one request, one result
examples/loaders/ObjectLoader.vueAlso shows error handling and cancelling a slow request.
Nothing loaded yet
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<Button
label="Load"
@click="profile.run()"
/>
<Button
label="Load (fails with 403)"
color="error"
variant="soft"
@click="profile.run({ params: { fail: 1 } })"
/>
<Button
label="Load slowly (3s)"
color="neutral"
variant="outline"
@click="profile.run({ params: { delay: 3000 } })"
/>
<Button
label="Cancel"
color="neutral"
variant="ghost"
:disabled="!profile.status.isLoading"
@click="profile.cancel()"
/>
<Button
label="Clear"
color="neutral"
variant="ghost"
@click="profile.clear()"
/>
</div>
<Loader :loading="profile.status.isLoading">
<p
v-if="profile.status.isError"
class="text-sm text-red-600"
>
{{ StringHelper.getError(profile.status.errorData) }}
</p>
<p
v-else-if="profile.data"
class="text-sm"
>
{{ profile.data.name }} · loaded at {{ TimeHelper.displayDateTime(profile.data.loadedAt) }}
</p>
<p
v-else
class="text-sm text-gray-500"
>
Nothing loaded yet
</p>
</Loader>
</div>
</template>
<script lang="ts" setup>
import { useProfileLoader } from '~/loaders/users'
// useObjectLoader is one request with one result. `method` can be get, post, put, delete…
// run({ data, params, urlBind, signal, expire }) — a new GET cancels the one still running.
const profile = useProfileLoader()
defineExpose({
debug: computed(() => ({
status: profile.status,
data: profile.data,
})),
})
</script>