UI

Composables and helpers

Look up the composables for confirmations, toasts, cached loading, formatting, tables and translated text.

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
  • errorMessage reads data.message, data.statusMessage, response._data.message, statusMessage and message, in that order. So the server's own message wins, for $fetch errors, createError payloads and plain Errors alike. It returns fallback when there is nothing to read.
  • errorToToast is a pure helper, so its default title, "Something went wrong.", is English. In components, use useErrorToast(), whose default title is translated.
  • useAction().run sets busy while fn runs. On success it shows the success toast. On failure it shows an error toast titled error and resolves undefined. It replaces hand-written try, catch and saving flags.
  • useCopyToClipboard copies with VueUse's useClipboard and shows a "Copied" toast, or message.
<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:

  1. The server's response in the payload, while hydrating. It is remembered too.
  2. The remembered response, while it is younger than ttl.
  3. 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 leaves data undefined, or the last good response, and sets error. Derive lists with data.value ?? [], and check error before an empty state.
  • loading is true only while there is nothing on screen yet, not during a background refresh. It is not named pending, which useFetch has 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 awaits useMyThing(), 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 useCachedFetch calls 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 the ttl of 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 for account.
Call it from a route middleware, not from the layout. A layout stays mounted between the area's pages, and its setup may finish after the page's. A middleware runs before every page, on the server and on each client navigation.
// 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')
})

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[]>
Set the time zone in 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.
  • useLocaleLink is not named useLocalePath, so it never clashes with the composable of @nuxtjs/i18n.
  • useNavLinks turns menu items from useMenu into navigation items with a localised to and an active flag. Paths in exact match 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.
Copyright © 2026