Composables
Read when: writing or calling one of the shared composables, or adding a new one.
Owns: useTranslations, useAppearance, useListFilters, useClientValidation, can /
canAny, their signatures and code.
TL;DR
- A composable owns reactive state or wraps Inertia; its file is
useX.tsexportinguseX(). - State shared across pages sits in a
refdeclared outside the exported function, so every caller shares one value; inside, each call would get its own. - Guard every
window,documentandlocalStoragetouch: pages render under SSR and in private windows. can()andcanAny()only read the page, so they are plain functions inlib/permissions.ts.
| Composable | Signature |
|---|---|
useTranslations |
() => { t(key: string, replace?: Record<string, string | number>): string } |
useAppearance |
() => { appearance: Ref<Appearance>; update(value: Appearance): void }, plus initializeTheme() |
useListFilters |
<T extends Record<string, string>>(url: string, initial: T, options?: { delay?: number }) => { filters: Ref<T>; dirty: ComputedRef<boolean>; clear(): void } |
useClientValidation |
<T>(schema: MaybeRefOrGetter<ZodType<T>>) => { clientErrors: Ref<Record<string, string>>; validate(data): boolean; reset(): void; revalidateOnChange(form): void } |
can / canAny |
(permission: string) => boolean / (permissions: string[]) => boolean |
useTranslations
The server shares the current locale's UI lines as translations (only the lang files the front end
reads, since every shared prop rides on every response).
import { usePage } from '@inertiajs/vue3';
export function useTranslations() {
const page = usePage();
function t(key: string, replace: Record<string, string | number> = {}): string {
const line = key
.split('.')
.reduce<unknown>(
(carry, part) => (carry && typeof carry === 'object' ? (carry as Record<string, unknown>)[part] : undefined),
page.props.translations,
);
if (typeof line !== 'string') return key;
return Object.entries(replace).reduce(
(text, [token, value]) => text.replace(new RegExp(`:${token}(?![A-Za-z0-9_])`, 'g'), String(value)),
line,
);
}
return { t };
}
The bounded pattern stops :to from matching inside :total. An app with one language may write
its copy straight into templates instead; how to word it either way is copy.md.
useAppearance
import { ref } from 'vue';
export type Appearance = 'light' | 'dark' | 'system';
const appearance = ref<Appearance>('system');
function apply(value: Appearance): void {
const dark = value === 'dark' || (value === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
}
function stored(): Appearance {
try {
const value = localStorage.getItem('appearance');
return value === 'light' || value === 'dark' ? value : 'system';
} catch {
return 'system';
}
}
export function initializeTheme(): void {
if (typeof window === 'undefined') return;
appearance.value = stored();
apply(appearance.value);
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => appearance.value === 'system' && apply('system'));
}
export function useAppearance() {
function update(value: Appearance): void {
appearance.value = value;
apply(value);
document.cookie = `appearance=${value};path=/;max-age=31536000;SameSite=Lax`;
try {
localStorage.setItem('appearance', value);
} catch {}
}
return { appearance, update };
}
- The
refsits outside the function, so every caller shares one value. - The cookie lets the root view print
class="dark"on the server, so the first paint is already dark.localStoragecan throw in a private window, hence thetry.
app.ts calls initializeTheme() once at boot, beside syncDocumentLanguage() and
initializeFlashToast() (blueprint.md).
useListFilters
export const ALL = '__all__';
export function useListFilters<T extends Record<string, string>>(url: string, initial: T, options: { delay?: number } = {}) {
const defaults = { ...initial };
const filters = ref<T>({ ...initial });
watchDebounced(
filters,
() => {
const data = Object.fromEntries(Object.entries(filters.value).filter(([, value]) => value !== '' && value !== ALL));
router.get(url, data, { preserveState: true, preserveScroll: true, replace: true });
},
{ debounce: options.delay ?? 300, deep: true },
);
return {
filters,
dirty: computed(() => Object.keys(defaults).some((key) => filters.value[key] !== defaults[key])),
clear: () => (filters.value = { ...defaults }),
};
}
router.get to the bare list URL replaces the whole query, so an emptied filter and the old page
both drop out: a narrowed list never strands the reader on a page that no longer exists.
useClientValidation
export function useClientValidation<T extends Record<string, unknown>>(schema: MaybeRefOrGetter<ZodType<T>>) {
const clientErrors = ref<Record<string, string>>({});
const submitted = ref(false);
const reset = () => (clientErrors.value = {});
function validate(data: Record<string, unknown>): boolean {
submitted.value = true;
const result = toValue(schema).safeParse(data);
if (result.success) {
reset();
return true;
}
const next: Record<string, string> = {};
for (const issue of result.error.issues) {
const field = issue.path.join('.');
if (field && !next[field]) next[field] = issue.message;
}
clientErrors.value = next;
return false;
}
function revalidateOnChange<F extends Record<string, unknown>>(form: { data: () => F; clearErrors: (...fields: Array<keyof F & string>) => unknown }): void {
watch(
() => ({ ...form.data() }),
(next, previous) => {
const changed = (Object.keys(next) as Array<keyof F & string>).filter((key) => next[key] !== previous[key]);
if (changed.length) form.clearErrors(...changed);
if (submitted.value) validate(next);
},
);
}
return { clientErrors, validate, reset, revalidateOnChange };
}
- One message per field, the first, so a control never stacks messages.
- The dotted path (
name.en) matches the key Laravel uses for a nested error, so both land on the same field. - Editing a field clears its server error at once; after the first submit, the schema re-runs on every change, so a fixed field stops showing red without another submit.
can and canAny
The code and the rules for using them are in data-flow.md.