Data layer ​

Lists, infinite lists and async selects don't fetch anything themselves. They take a query hook — useQueryFn — and call it for each page or lookup. The kit's data utilities build those hooks on top of @tanstack/vue-query:

  • ApiClientInstance sends the requests and handles auth.
  • createRestModelApi declares a model's queries and the actions that change it, keeping every cached copy of an item in sync.
  • createUseQueryParams keeps the filters a user sets on a list in the URL.
  • handleApiError reports a failed request in a notification and in the form that sent it.

The query client has to be installed first.

API client ​

One client per backend, in its own module:

ts
import {ApiClientInstance} from 'eco-vue-js/dist/utils/ApiClient'

export const apiClient = new ApiClientInstance({
  baseUrl: '/api/v1',
  refreshUrl: '/jwt/update/',
  credentials: import.meta.env.DEV ? 'include' : 'same-origin',
})

get, post, patch and delete resolve with {data, status} and take a config with:

  • params — the query string. Arrays of ids or strings are joined with commas, objects are sent as JSON, and empty values are left out.
  • signal — pass the one vue-query gives the query function, so a query that is no longer needed cancels its request.
  • noAuth — sends the request without an auth check, e.g. for the login.

URLs start and end with a slash — /products/ or `/products/${ id }/` — and are typed that way. addInstance(baseUrl) gives a client for another prefix that shares the auth.

A failed request rejects with an ApiError, whose response has the status and the error body in data. A cancelled one rejects with ApiErrorCancel, which error handlers skip. When the server can't be reached, or a gateway answers 502, 503 or 504 in its place, the request rejects with ApiErrorUnavailable, a subclass of ApiError with no status for a network failure.

useAvailable() returns a ref that turns false on such a failure and true again on the server's next answer. Hand it to watchServerRecovery, with a cheap request as the probe, to bring back what failed during an outage. vue-query's refetchOnReconnect only follows the browser's own connection, so it never notices a server coming back:

ts
import {watchServerRecovery} from 'eco-vue-js/dist/utils/queryClient'

watchServerRecovery(queryClient, apiClient.useAvailable(), () => apiClient.get('/profile/'))

While the server is unreachable, the probe is sent every few seconds, and at once when the browser comes back online or the tab is shown. Once the server answers again, the failed queries on screen are fetched again. Their error states, such as a list's Retry, a page error or the query status message, stay in place until then. A probe answered right away means the server is up and only some endpoints fail, so their queries wait for Retry instead of being fetched again and again.

Auth ​

The client supports two kinds of auth:

  • Cookie session (the default). The server sets an exp cookie, readable from JS, with the session's expiry in seconds. Before each request the client checks it. Once it has passed, the client calls refreshUrl — or your own tokenRefresh — and then sends the request. A 401 also refreshes and retries once, except for requests sent with noAuth. A refresh that fails with ApiErrorUnavailable keeps the session and rejects the request; any other failed refresh logs out. Open tabs share the refresh, so only one of them calls the server. Without an exp cookie the client counts as logged out and rejects every request not sent with noAuth.
  • Bearer token. With tokenGetter, the client adds Authorization: Bearer <token> to each request and counts as logged out when it returns nothing.

useAuth() returns a ref of the logged-in state, for a guard that shows the login page. logout() clears the session. onFailure, or setOnFailure later, is called with every failed response, e.g. to redirect on 403.

Models ​

createRestModelApi declares every query of one model — an item, a paginated list, a list of options, a settings object — together with the actions that change it:

ts
import {createRestModelApi} from 'eco-vue-js/dist/utils/restModelApi'
import {isId} from 'eco-vue-js/dist/utils/utils'

import {apiClient} from '@/api/ApiClient'

const PRODUCT_PATH = '/products/'

export const productModelApi = createRestModelApi({
  modelKey: 'Product',
  model: {} as Product,
  queries: {
    item: {
      scope: 'item',
      dataType: {} as Product,
      isQueryParams: isId,
      queryFn: ({queryKey, signal}) => apiClient
        .get<Product>(`${ PRODUCT_PATH }${ queryKey[2] }/`, {signal})
        .then(response => response.data),
    },

    paginated: {
      scope: 'paginated',
      dataType: {} as PaginatedResponse<Product>,
      isQueryParams: (value: unknown): value is QueryParamsProducts | undefined => value === undefined || value instanceof Object,
      queryFn: ({queryKey, signal}) => apiClient
        .get<PaginatedResponse<Product>>(PRODUCT_PATH, {signal, params: queryKey[2]})
        .then(response => response.data),
    },
  },
})
  • modelKey names the model in the cache. Every query of the model is keyed under it, so all of them can be updated or invalidated together.
  • model is the shape that every query of the model shares, and must have an id. It is used only for its type, so pass {} as Product.

Each query has:

  • scope — what the query holds: item (one item), list (an array of items), paginated (a page, in PaginatedResponse), or single (anything else, such as settings). The first three hold items with an id, and actions keep them in sync. A single query is left alone.
  • isQueryParams — a type guard for the query's params. Without it the query takes no params. The query only runs when its params pass the guard, so isId holds back an item query until there is an id. A guard that accepts undefined also takes {}, and both share one cache entry.
  • queryFn — fetches the data. Its params are at queryKey[2].
  • dataType — the type of the query's data, again as {} as Type. Declare it whenever queryFn reads queryKey, and whenever a query returns a richer shape than model, such as the full item where the list returns a short one.
  • options — default vue-query options for the query, e.g. placeholderData.

The query's name is the last part of its key. Several queries of one scope, such as two paginated endpoints with different filters, don't collide.

Using the queries ​

Every query gives a hook at <name>.use:

ts
const queryProduct = productModelApi.item.use(computed(() => props.productId ?? 0))
  • use(params, options?) — runs the query and returns the vue-query result, with setItem and removeItem added. params can be a ref, and a change runs the query for the new params.
  • use.config(params) — the query's options, for queryClient.ensureQueryData or prefetchQuery outside of a component.
  • use.setItem(item) and use.removeItem(id) — write to the cache without a component.

The use of a paginated query is what WList, WInfiniteList and the async selects take as useQueryFn. When a row changes, the list's setter calls setItem, which updates the item in every cached query of the model.

Actions ​

Actions are the requests that change the model, declared on the query they belong to:

ts
item: {
  scope: 'item',
  // …
  actions: {
    update: ({set, invalidate}, id, payload: Partial<Product>) => apiClient
      .patch<Product>(`${ PRODUCT_PATH }${ id }/`, payload)
      .then(response => {
        set(response.data)
        invalidate('paginated')

        return response.data
      }),

    delete: (context, id) => apiClient
      .delete(`${ PRODUCT_PATH }${ id }/`)
      .then(() => () => null),
  },
},

They are called as <name>.actions.<action>(params, payload), where params is left out for a query without them:

ts
await productModelApi.item.actions.update(product.id, {name})

An action gets its query's params, its payload, and a context to change the cache with:

  • set(item) — puts the item, or an array of items, in every cached query of the model that holds it — items, lists and pages alike. On an item query it also sets that query's data.
  • update(item => newItem) — changes the cached items the action is about. On an item query that is the item with the id in the params. On a list or paginated query it is the items its bulk params select: id__in, or id__not_in and slice_indexes against the loaded pages with the same filters. Return null to remove an item. Call it before the request for an optimistic update: when the action rejects, the model's cache is restored.
  • invalidate(scope?) — refetches the model's queries of that scope, or all of them, once the action succeeds.

An action can also return an updater instead of calling update, and it then resolves with nothing. () => null above drops the deleted product from every list. For another model, use its invalidate(scope?), e.g. to refresh a dashboard after a product is added.

In the demo, the toggle's action changes the task at once, in both the list and the detail. Turn on failing requests to see the change rolled back.

vue
<template>
  <div class="grid gap-6">
    <div class="grid gap-6 md:grid-cols-2">
      <div class="grid content-start gap-1">
        <span class="text-description text-sm">taskModelApi.paginated</span>

        <button
          v-for="task in queryTasks.data.value?.results"
          :key="task.id"
          class="flex items-center gap-2 rounded-lg px-2 py-1 text-left [&_svg]:square-4"
          :class="{'bg-surface-muted': task.id === selected}"
          @click="selected = task.id"
        >
          <WStatusIcon :has-value="task.done" />
          {{ task.title }}
        </button>

        <WSkeleton
          v-for="index in queryTasks.data.value ? 0 : 4"
          :key="index"
        />
      </div>

      <div class="grid content-start gap-3">
        <span class="text-description text-sm">taskModelApi.item</span>

        <span class="font-semibold">
          <WSkeleton v-if="!queryTask.data.value" />

          <template v-else>
            {{ queryTask.data.value.title }}
          </template>
        </span>

        <WToggle
          :model-value="queryTask.data.value?.done ?? false"
          title="Done"
          :skeleton="!queryTask.data.value"
          @update:model-value="toggle"
        />
      </div>
    </div>

    <div class="flex flex-wrap items-center gap-4">
      <WToggle
        v-model="server.fail"
        title="Requests fail"
      />

      <span
        v-if="error"
        class="tone-negative text-tone text-sm"
      >
        {{ error }}
      </span>
    </div>
  </div>
</template>

<script lang="ts" setup>
import {ref} from 'vue'

import WSkeleton from 'eco-vue-js/dist/components/Skeleton/WSkeleton.vue'
import WStatusIcon from 'eco-vue-js/dist/components/Status/WStatusIcon.vue'
import WToggle from 'eco-vue-js/dist/components/Toggle/WToggle.vue'

import {server, taskModelApi} from './api/Task'

const selected = ref(1)
const error = ref<string>()

const queryTasks = taskModelApi.paginated.use({})
const queryTask = taskModelApi.item.use(selected)

const toggle = (done: boolean) => {
  error.value = undefined

  taskModelApi.item.actions.update(selected.value, {done})
    .catch(() => error.value = 'The update failed and was rolled back.')
}
</script>

The model behind it, with a stand-in for the server:

ts
import {reactive} from 'vue'

import {createRestModelApi} from 'eco-vue-js/dist/utils/restModelApi'
import {paginateList} from 'eco-vue-js/dist/utils/useDefaultQuery'
import {isId} from 'eco-vue-js/dist/utils/utils'

export type Task = {
  id: number
  title: string
  done: boolean
}

export type QueryParamsTasks = {
  page?: number
}

let tasks: Task[] = [
  {id: 1, title: 'Repot the monstera', done: false},
  {id: 2, title: 'Prune the roses', done: true},
  {id: 3, title: 'Sow the tomato seeds', done: false},
  {id: 4, title: 'Clean the greenhouse gutters', done: false},
]

/** Lets the demo make every request fail, to show the rollback. */
export const server = reactive({fail: false})

/** Stands in for a request to the API: answers after half a second, or fails. */
const respond = <Data>(handler: () => Data) => new Promise<Data>((resolve, reject) => {
  setTimeout(() => {
    if (server.fail) reject(new Error('Server error'))
    else resolve(structuredClone(handler()))
  }, 500)
})

export const taskModelApi = createRestModelApi({
  modelKey: 'Task',
  model: {} as Task,
  queries: {
    item: {
      scope: 'item',
      dataType: {} as Task,
      isQueryParams: isId,
      queryFn: ({queryKey}) => respond(() => tasks.find(task => task.id === queryKey[2])!),
      actions: {
        update: ({update, set}, id, payload: Partial<Task>) => {
          // Shows the change at once, in every cached query holding the task. Rolled back if the request fails.
          update(task => ({...task, ...payload}))

          return respond(() => {
            tasks = tasks.map(task => task.id === id ? {...task, ...payload} : task)

            return tasks.find(task => task.id === id)!
          })
            .then(task => set(task))
        },
      },
    },

    paginated: {
      scope: 'paginated',
      dataType: {} as PaginatedResponse<Task>,
      isQueryParams: (value: unknown): value is QueryParamsTasks | undefined => value === undefined || value instanceof Object,
      queryFn: ({queryKey}) => respond(() => paginateList(tasks, queryKey[2]?.page)),
    },
  },
})

Filters in the URL ​

The filters a user sets on a list — a search, a type, a sort order — can be kept in the URL, so a reload or a shared link opens the list as it was. createUseQueryParams declares them with a parser for each, which turns the value from the URL back into its type:

ts
import {createUseQueryParams} from 'eco-vue-js/dist/utils/api'
import {parseBoolean, parseIdList, parseString} from 'eco-vue-js/dist/utils/utils'

export const useQueryParamsProducts = createUseQueryParams({
  search: parseString,
  product_type__in: parseIdList,
  is_default: parseBoolean,
  ordering: parseString,
})

export type QueryParamsProducts = typeof useQueryParamsProducts['QueryParams']

Leave page and size out: the list adds them to each page's query itself.

On a page, the declaration reads the filters from the route and writes changes back to it:

ts
const route = useRoute()
const {queryParams, updateQueryParams} = useQueryParamsProducts(route)
  • queryParams is a reactive object parsed from the route's query. It goes to the list as query-params. The object is created once per declaration, so the pages that use one declaration share it.
  • updateQueryParams(value) merges value into the URL with router.replace. An empty value removes its param. Bind it to the filters and to the list's update:query-params, which sets ordering when a column is sorted.
  • useQueryParamsProducts.useQueryParamsLocal(initial?) gives the same pair without the URL, for filters that don't need to survive a reload, e.g. in a modal.

The parsers in eco-vue-js/dist/utils/utils are parseString, parseBoolean, parseInteger, parseId, parseIdList, parseIntegerList, parseStringList and parseJson(guard). A declaration's config can be spread into another, to share filters between lists.

Errors ​

handleApiError shows an error notification with the server's detail or non_field_errors. With a form, it also shows the error body's field errors under the fields they name. It skips cancelled requests and rejects again, so a caller can still react:

ts
const submit = () => productModelApi.item.actions.update(id, payload)
  .then(() => emit('close:modal'))
  .catch(error => handleApiError(error, formRef.value))

The field errors are expected in the Django REST framework shape: {name: ['This field is required.']}.

Lower-level helpers ​

  • createDefaultQuery(modelKey, scope, name, queryFn, isQueryParams?, options?) from eco-vue-js/dist/utils/useDefaultQuery builds one query hook, the way createRestModelApi builds each of its queries.
  • makeQueryPaginated(modelKey, getter, setter?, pageLength?) serves a paginated query from an array in memory, and wrapUseQueryPaginated pages the result of a query that returns the whole list. paginateList pages an array into a PaginatedResponse.
  • setQueryItem, setQueryItems, removeQueryItem, removeQueryItems and updateQueryItems from eco-vue-js/dist/utils/queryCache change a model's cached items outside an action, e.g. on a message from a websocket. snapshotQueries saves the model's cache and returns a function that restores it.