Composables and helpers
These composables and helpers do the small jobs every page repeats: confirm, show an error, keep data on screen, format a date, sort a table. This page is for app developers and module authors. Set up the module first: Overview.
Everything here is auto-imported in a Nuxt app. Code outside the app imports the same names from
@frigear-nu/ui/composables and @frigear-nu/ui/utils.
Confirmations
useConfirmDialog asks the user to confirm and resolves with the answer.
interface ConfirmDialogOptions {
title: string
description?: string
confirmText?: string // default 'Confirm'
cancelText?: string // default 'Cancel'
destructive?: boolean // style the confirm button as destructive
}
useConfirmDialog(): (options: ConfirmDialogOptions) => Promise<boolean> // true = confirmed, false = cancelled
It is built on Nuxt UI's useOverlay. A click outside or Escape does not close the dialog, so the promise resolves only
through its buttons. What the title, description and button should say is on
Forms and actions.
<script setup lang="ts">
const confirm = useConfirmDialog()
async function removeApplication(app: { id: string, name: string }) {
const confirmed = await confirm({
title: `Delete ${app.name}?`,
description: 'Every token issued to it stops working. This cannot be undone.',
confirmText: 'Delete application',
destructive: true,
})
if (!confirmed) return
await $fetch(`/api/admin/oauth-server/applications/${app.id}`, { method: 'DELETE' })
}
</script>
Errors, actions and clipboard
These helpers turn a failed request into a toast and track a running action.
errorMessage(e: unknown, fallback: string): string
errorToToast(e: unknown, overrides?: Partial<ToastProps>): Partial<ToastProps> // an error toast carrying errorMessage(e)
useErrorToast(): (e: unknown, title?: string) => void // toast.add(errorToToast(e, { title })), translated default title
useAction(): {
busy: Ref<boolean>
run<T>(fn: () => Promise<T>, options?: { success?: string, error?: string }): Promise<T | undefined>
}
useCopyToClipboard(): { copy(text: string, message?: string): Promise<boolean> } // false on failure
errorMessagereadsdata.message,data.statusMessage,response._data.message,statusMessageandmessage, in that order. So the server's own message wins, for$fetcherrors,createErrorpayloads and plainErrors alike. It returnsfallbackwhen there is nothing to read.errorToToastis a pure helper, so its default title, "Something went wrong.", is English. In components, useuseErrorToast(), whose default title is translated.useAction().runsetsbusywhilefnruns. On success it shows thesuccesstoast. On failure it shows an error toast titlederrorand resolvesundefined. It replaces hand-written try, catch and saving flags.useCopyToClipboardcopies with VueUse'suseClipboardand shows a "Copied" toast, ormessage.
<script setup lang="ts">
const { busy, run } = useAction()
const { copy } = useCopyToClipboard()
const revoke = (id: string) => run(() => $fetch(`/api/auth/me/tokens/${id}`, { method: 'DELETE' }), { success: 'Token revoked' })
</script>
Loading data that survives navigation
useCachedFetch keeps a page's data on screen when the visitor leaves and comes back.
useCachedFetch<ResT, DataT = ResT>(url: string | Ref<string> | (() => string), options: UseFetchOptions & {
key: MaybeRefOrGetter<string> // required: names the cache entry
ttl?: number // ms a remembered response is reused on mount; omitted = until refresh()
}): {
data: Ref<DataT | undefined> // undefined until the first response; there is no `default`
status, error, refresh, execute, clear
loading: ComputedRef<boolean> // status === 'pending' && data === undefined: nothing to show yet
} // and awaitable: `await useCachedFetch(...)` resolves once the first response is in
primeCachedFetch(key: string, data: unknown): void // remember a response loaded some other way
clearCachedFetch(keys?: string | string[] | ((key: string) => boolean)): void // forget them, e.g. on sign-out
loadBootstrap(scope: string, options?: { ttl?: number }): Promise<void> // one request primes an area's keys
resetBootstrap(scope: string): void // forget the area's bootstrap and the entries it primed
Nuxt drops useFetch data once the last component that uses its key unmounts. Coming back then flashes the empty
state until the refetch lands. useCachedFetch wraps useFetch and remembers each response by key. On mount it
takes the data from the first of these:
- The server's response in the payload, while hydrating. It is remembered too.
- The remembered response, while it is younger than
ttl. - A request. Until it lands, an older remembered response stays on screen.
refresh(), refreshNuxtData() and watchers always ask the server and update the remembered response. Only the
browser remembers responses, so the cache adds nothing to the server-rendered payload. On the server,
primeCachedFetch puts the data in that request's payload instead.
- No
default. A failed request leavesdataundefined, or the last good response, and setserror. Derive lists withdata.value ?? [], and checkerrorbefore an empty state. loadingistrueonly while there is nothing on screen yet, not during a background refresh. It is not namedpending, whichuseFetchhas deprecated.- Await it in pages, so the server renders the data. When to load lazily instead is on Pages.
- Awaitable composables. A composable built on it can return
Object.assign(fetched.then(() => api), api). A page then awaitsuseMyThing(), and a widget calls it directly.
<script setup lang="ts">
const { data, error, loading } = await useCachedFetch<TicketDto[]>('/api/events/me/tickets', {
key: 'events:tickets:mine',
ttl: 60_000,
})
const tickets = computed(() => data.value ?? [])
</script>
<template>
<SectionCard v-if="loading" loading />
<UEmpty v-else-if="error" icon="i-lucide-circle-alert" :description="errorMessage(error, 'Could not load tickets.')" />
<UEmpty v-else-if="!tickets.length" icon="i-lucide-ticket" title="No tickets yet" />
<TicketList v-else :tickets="tickets" />
</template>
One request for an area: loadBootstrap
loadBootstrap loads several modules' data for an area in one request instead of one per module. The account pages
use it for membership, tickets and applications.
Each module registers a provider for the area's scope with
registerBootstrapProvider. It uses the key
its composable already passes to useCachedFetch. The area calls loadBootstrap(scope) from a route middleware. That
fetches GET /api/auth/bootstrap?scope=<scope> and primes each returned key, so the composables need no request of
their own.
- On the server, the data goes into the payload. The page's
useCachedFetchcalls read it while rendering, and the browser reads it while hydrating. - On client navigation, it sends a request only when the last bootstrap is older than
ttl, one minute by default. Keep it at or below thettlof the composables it primes. - On failure, it never throws. The composables then fetch from their own endpoints.
- When the user changes, call
resetBootstrap(scope)on sign-in and sign-out. The Account module does this foraccount.
// runtime/app/middleware/bootstrap.ts, added to the area's routes with a route rule
// (`appMiddleware: { 'my-area-bootstrap': true }`)
export default defineNuxtRouteMiddleware(async () => {
if (useUserSession().loggedIn.value) await loadBootstrap('my-area')
})
Formatting and links
These helpers format dates and money in the visitor's locale and keep links under the locale prefix.
// utils: pure functions; accept a Date, an ISO string or a timestamp. `locale` defaults to English
formatDate(value, locale?): string
formatDateTime(value, locale?): string
formatTime(value, locale?): string
formatDateRange(start, end, locale?): string
useFormat(): { locale, timeZone, date, dateTime, time, dateRange, money } // bound to the locale and the app time zone
// money(15000, 'DKK') is formatMoney from @frigear-nu/ui/money with the locale bound; amounts are in minor units
useLocaleLink(): {
localize(to: string): string // prefixes the active locale when @nuxtjs/i18n is installed; unchanged otherwise
strip(path: string): string // removes a leading locale code
isActive(to: string, options?: { exact?: boolean }): boolean // the current route is `to` or below it
}
useNavLinks(items: MaybeRefOrGetter<MenuItem[]>, options?: { exact?: string[] }): ComputedRef<NavigationMenuItem[]>
app.config.ts, as frigearUi: { timeZone: 'Europe/Copenhagen' }. Without it, a server in UTC
and the visitor's browser format the same instant differently, and the page fails to hydrate.useLocaleLinkis not nameduseLocalePath, so it never clashes with the composable of@nuxtjs/i18n.useNavLinksturns menu items fromuseMenuinto navigation items with a localisedtoand anactiveflag. Paths inexactmatch only exactly, for an item such as a dashboard at the area's base path. The admin sidebar, the account navigation and the site header use it.
When to use these is on Text and formatting.
Status and styling
These helpers give every status badge, card and table the same look.
statusColor(status: string | null | undefined, overrides?: Record<string, StatusColor>): StatusColor
// success: published, active, trialing, paid, succeeded
// warning: pending, past_due, incomplete, scheduled
// error: failed
// neutral: everything else
surfaceClass // 'bg-elevated/50 ring ring-default rounded-lg overflow-hidden', the frame of every card
tableUi(overrides?: TableUi, options?: { sticky?: boolean }): TableUi
// the shared UTable styling inside TableCard. `overrides` replace single slots (`tableUi({ tr: '' })` for rows that
// open nothing); `sticky` lets the table scroll inside its card (pair it with `<UTable sticky>`)
numericColumn // column meta for numbers: right-aligned, tabular figures (`{ meta: numericColumn }`)
emptyCell(): VNode // the dimmed dash of a cell without a value
Pass overrides to statusColor to recolour a status of your own module. StatusBadge uses it for you.
Tables
useTableSort and useTableQuery give every list the same sortable headers, search and pagination.
useTableSort(initial?: { id, desc } | null, state?: Ref<{ id, desc } | null>): {
sort: Ref<{ id, desc } | null>
sorting: WritableComputedRef<{ id, desc }[]> // UTable's shape: `v-model:sorting` for client-sorted lists
query: ComputedRef<{ sort?, order? }> // for server-sorted lists
header(id: string, label: string): () => VNode // a column's sortable `header`
toggle(id: string): void
}
useTableQuery({ pageSize?, sort?, filters?, debounce? }): {
page, pageSize, sort, // refs, kept in the URL (`?page=2&sort=name&order=asc`)
search, // what the search input binds
q, // `search` after a 300 ms pause
filters, // reactive, one string per filter; a filter at its default stays out of the URL
query, // what a server-paginated list sends to its API
filtered, // the search or a filter narrows the list
reset(): void // clears the search and the filters
}
A sortable header cycles through ascending, descending and unsorted. useTableQuery goes back to page 1 whenever the
search, a filter, the sort or the page size changes. Pass its sort to useTableSort so both share one state:
const { page, pageSize, search, sort: sortState, filters, query } = useTableQuery({
sort: { id: 'createdAt', desc: true },
filters: { role: 'all' },
})
const { header } = useTableSort(null, sortState)
const { data } = await useFetch('/api/admin/users', { query })
The complete list page is on Pages.
Translated text and locale
These composables tell components which locale to show and translate built-in strings.
useTranslatedLocale(): ComputedRef<string> // the visitor's locale; `en` without @nuxtjs/i18n
useTranslatedLocales(): ComputedRef<string[]> // locales content can be translated into; ['en'] without @nuxtjs/i18n
useTranslatedFallbackLocale(): ComputedRef<string> // the default locale, used when a text lacks the current one
useUiT(namespace: string): (key: string, fallback: string, params?: Record<string, string | number>) => string
stripLocale(path: string, codes: string[]): string // '/da/events' with ['da', 'en'] -> '/events'
useUiT translates when @nuxtjs/i18n has the key. Otherwise it returns the English fallback, with {name} params
filled in. The components use it, so their strings are translated without making @nuxtjs/i18n required. Modules use
useModuleT for their own text.
Next steps
- Pages – see these composables in a complete list and detail page.
- UI reference – look up the money and translated-text helpers and the string keys.