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 .

Page 1 / 0
<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,
  }
}

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.vue

Also 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>