Skip to content

Components

Vue components are broken down into these classifications:

  • Views
    • Pages
    • Dialogs
  • Sub-Components
    • Forms
    • Displays

Views

A view is a component which can be asynchronously loaded, and is the absolute unit of code splitting in a SAF SPA. Its code can be asynchronously loaded, and its data required for rendering fetched simultaneously while the code is loading. This helps keep the base SPA code small, and page loading and navigation snappy. All pages are views, and can render other views on-demand such as dialogs and side panels.

A sub-component is synchronously loaded and is typically provided the data it needs to render, to ensure all data needed for the initial render is part of a view loader. However, it is not wholly decoupled from networking; it should do its own on-demand fetching of data (such as for searches) or perform mutations (such as form submissions). The nice thing about Tanstack Query is it provides this flexibility and manages shared state.

Each page in a SPA has its own directory that looks like this:

{page-name}/
├── PageName.fixture.ts        # Playwright fixture
├── PageName.loader.ts         # Data fetching (TanStack queries)
├── PageName.strings.ts        # Localized strings for the page
├── PageName.test.ts           # Render smoke test
├── PageName.vue               # Page component (thin template)
├── PageNameAsync.vue          # Async wrapper for code splitting

│   Sub-components and their extracted logic:
├── SubComponent.vue           # Sub-component (thin template)
├── SubComponent.strings.ts    # Its own strings file
├── SubComponent.logic.ts      # Pure business logic (validation, transforms)
├── SubComponent.logic.test.ts # Unit tests for pure logic
├── useSubComponentFlow.ts     # Composable (stateful + networking logic)
└── useSubComponentFlow.test.ts # Integration tests for composable

Not every sub-component needs all of these files — only create logic files and composables when there is logic worth extracting and testing. Simple presentational components may only need a .vue and .strings.ts.

Other types of views follow the same pattern.

Files Explained

There are very good reasons to break down every view into several files! Each file has a role, and so it's easy to find the code that has the responsibility you're looking for.

Async Component: Code Splitting, Loading/Error States

Template file

By default, SAF SPAs will split out every page. This is controlled by the Async vue component, which will normally look like this:

vue
<template>
  <AsyncPage :loader="usePageLoader" :page-component="Page" />
</template>

<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { usePageLoader } from "./PageName.loader.ts";
import { page_name as strings } from "./PageName.strings.ts";
import { AsyncPage } from "@saflib/vue/components";
import { useAsyncPageDocumentTitle } from "@saflib/vue";

useAsyncPageDocumentTitle(strings.documentTitle);

const Page = defineAsyncComponent(() => import("./PageName.vue"));
</script>

Add a documentTitle string to the page's .strings.ts file (for example documentTitle: "Settings"). The Async component sets document.title as soon as it mounts, before the page code loads. Configure the app suffix once in main.ts with configureAppDocumentTitle("My Product") so tabs read like Settings — My Product.

When the tab title should include loaded data (for example a resource name), call the composable with a second argument — a ref or computed from the loader. The Async shell and AsyncPage may each call the loader independently; TanStack Query deduplicates the requests.

ts
const loader = usePageLoader();

useAsyncPageDocumentTitle(
  strings.documentTitle,
  computed(() => loader.resourceQuery.data.value?.name),
);

The Vue router, and by extension the Vue app, do not directly import or render the page component. This is where the majority of the app's business logic will go, and so by default only necessary code is loaded by the application at first. The async component decides what will render while the code is loading and the data is being fetched; AsyncPage only renders the page component when both are present. AsyncPage also handles generic error states when either fail to arrive.

If it's important to have certain common landing pages loaded sooner, the tradeoff can be decided to import the page component directly.

It's important that no other components in the app render async components. By centralizing that responsibility on async components, it makes it easier to understand and manage how the app is chunked. If a page becomes large (such as a dashboard with many widgets), the page's async component can render all async components with their own loader methods. This way code and responsibilities can still be broken down, while also loading all code and fetching all data as quickly as possible.

The only exception to this is if the component is not rendered on page load. For example if there's a heavy dialog, the page might render the async component only when the dialog is opened, or after the page is initially loaded to preload the code.

Nested routes

When a page has several sub-views that share chrome (sidebar, breadcrumbs, header), use nested routes with AsyncPage. The parent *Async.vue renders shared chrome through AsyncPage and a sibling <router-view>; each child route is its own *Async.vue with its own loader. Parent and child loaders run in parallel, child code is split per route, and TanStack Query deduplicates shared requests.

Loader: Data Fetching

Template file

Both the async component and the page component use the loader; the async component uses it to start fetching data and know when it's done, while the page component uses it to get the data to render. Since both depend on it, the loader exists in a separate file.

Because the loader always uses Tanstack queries to fetch data, and the Tanstack client is configured to allow stale data for a few seconds, the page calling the loader will usually use the same data without causing extra requests.

Loaders must have a bounded, statically-known number of queries. Each query in a loader fires in parallel with the code load, so the set of queries must be determinable at route-navigation time — not dependent on the results of other queries. For example, a loader can fetch a recipe, its notes, and its files (three queries), but it should not loop over notes to fire one query per note.

If you find yourself needing a data-dependent number of queries, that's a signal the API needs a batch endpoint. For example, instead of fetching note-files per-note with N queries, the API should provide GET /recipe-note-files/by-note-ids?noteIds=... so the loader can fetch them all in one query.

Component: Rendering

Template file

Because the async component ensures the page component doesn't render until the data is fetched, the page component can assume the data is available and render it. It doesn't need to worry about checking for errors or handling loading states, and can instead focus on the happy path.

Strings: Localization and Testability

Template file

Strings are important to keep separate from the Vue component because they:

  • need to be localized, and
  • are invaluable for testing

By keeping them in separate files, they can be exported and used by processes which don't need to know about, parse, or compile Vue components, in particular: Playwright.

Each sub-component should have its own strings file (e.g. MyDialog.strings.ts). Don't pile all strings into the view's strings file — it becomes hard to manage and makes it unclear which strings belong to which component.

Duplication across components is fine. If a create form and an edit form use the same labels, each should have its own strings file with its own copy. They get separate i18n keys (e.g. create_menu_form.name_label vs menu_edit_form.name_label), which means they can be translated independently per context — even if the English text is identical. Don't try to share strings across routes or components to reduce duplication; the added import complexity and path fragility isn't worth it.

For localization, see i18n, including how to interpolate values without breaking production message compilation.

Logic Files: Pure Business Logic

Logic files (ComponentName.logic.ts) contain pure TypeScript functions extracted from Vue components. This includes:

  • Validation logic (e.g. "can this form be submitted?")
  • Data transformation (e.g. building API payloads, mapping response data to UI state)
  • Formatting (e.g. dates, scores, percentages)
  • Type coercion and defaulting

These functions take plain values and return plain values — no Vue reactivity, no DOM, no network calls. This makes them trivial to unit test:

typescript
// EvalCreateDialog.logic.ts
export function canCreate(
  name: string,
  prompt: string,
  formId: string | null,
  selectedGroupHeaders: string[],
): boolean {
  if (!name.trim() || !prompt.trim()) return false;
  if (!formId) return false;
  return selectedGroupHeaders.length > 0;
}

// EvalCreateDialog.logic.test.ts
it("returns false when name is empty", () => {
  expect(canCreate("", "prompt", "form-1", ["group"])).toBe(false);
});

The component then imports and calls these functions, keeping its <script setup> focused on wiring reactivity to the template.

Ownership: Payload builders and validation helpers used by sub-components can live in the parent page's logic file (e.g. buildUpdateMenuPayload in Detail.logic.ts used by MenuEditForm.vue). This keeps all testable pure logic for a page consolidated in one place, regardless of which sub-component calls it.

Composables: Stateful and Networking Logic

When a component has stateful logic involving networking — TanStack mutations, multi-step flows, state machines, or complex error handling — extract it into a composable (useComponentFlow.ts).

The composable owns:

  • Reactive state (refs, computed properties)
  • TanStack queries and mutations
  • Orchestration logic (e.g. create → upload → run → set expected)
  • Error handling and step transitions

It exposes reactive state and action methods to the component, which can then be a thin template:

typescript
// useEvalCreateFlow.ts
export function useEvalCreateFlow(callbacks: {
  onClose: () => void;
  onCreated: (evalId: string) => void;
}) {
  const createStep = ref<CreateStep>("setup");
  const createName = ref("");
  // ... state, queries, mutations, orchestration ...
  return { createStep, createName, handleCreate, handleSaveExpected, ... };
}

When not to extract: A single mutation with local UI state (e.g. an edit form with one save call, or a delete button with a redirect) can stay in the component. Composables are for multi-step flows, shared stateful logic, or complex error handling — not every use of a TanStack mutation.

Scoping note: Each component that calls a composable gets its own instance with its own local state. This is usually what you want — each sub-component manages its own editing/uploading state independently. However, when multiple siblings need coordinated state (e.g. "only one note in edit mode at a time"), the parent should own that coordination state and pass it as a simple prop (like isEditing: boolean), while the sub-component still calls its own composable for mutations and other stateful flows.

Composables are tested using withVueQuery and setupMockServer with the resource-group fake handlers the flow actually calls — full integration tests that exercise the state machine and networking without a DOM:

typescript
// useEvalCreateFlow.test.ts
import { evalsFakeHandlers } from "@acme/product-sdk/requests/evals/index.fakes";
import { formsFakeHandlers } from "@acme/product-sdk/requests/forms/index.fakes";

setupMockServer([...evalsFakeHandlers, ...formsFakeHandlers]);

it("create without file: creates eval, calls onClose", async () => {
  const [flow, app] = withVueQuery(() =>
    useEvalCreateFlow({ onClose, onCreated }),
  );
  flow.createName.value = "Test Eval";
  // ... set up state ...
  flow.handleCreate();
  await vi.waitFor(() => expect(createdId).not.toBeNull());
  expect(closeCalled).toBe(true);
  app.unmount();
});

Test: Integration Testing

Component tests are optional. Prefer .logic.test.ts, use*.test.ts, and Playwright. When a page needs a component test, mount the async component (or <RouterView /> for nested routes) only to exercise behavior — not render-only smokes.

See testing for more info.

Fixture: the Playwright Kind

Template file

A Playwright Fixture provides a consistent, reusable way for Playwright tests to interact with the application. Since the fixture is tightly coupled to the implementation and feature set of a view, it lives in the same directory as the view (*.fixture.ts). Tests import that file via the SPA package glob (e.g. @scope/pkg/pages/.../Page.fixture.ts) or a same-package relative path — do not re-export page fixtures from a root fixtures.ts barrel.

Sub-Components

Naturally, pages will often be complex enough to warrant breaking down into sub-components. Where these components live depends:

  • If they're specific to the page, they should live in the page's directory alongside it.
  • If they're shared across multiple pages, they should go in common package which is adjacent to all the SPA packages.

Keep Interfaces Simple

Sub-components should not have overly complicated prop/emit interfaces. Props should be limited to:

  • Loader data — the data the view already loaded (passed as plain values or objects, not refs).
  • Simple display state — booleans, IDs, or other scalar values that control rendering.

Do not pass query loading or error state down from the parent for data the page loader already owns. The *Async.vue wrapper’s AsyncPage blocks on the loader’s queries and surfaces fetch failures; by the time the page and its sub-components render, that data path is the happy path. (Optimizing for JIT loading of some loader queries is a separate decision—when in doubt, keep related queries in the page loader so sibling UI can reuse the same data without prop-drilling query objects.)

Do not pass mutations through props either—the child calls TanStack mutation hooks (or a local composable) directly. Mutation in-flight / error UI for those actions (e.g. a submit button :loading or an inline alert on a failed create) stays in the component that owns the mutation; that is different from page-level query loading/errors above.

For everything else — multi-step flows or orchestration that is not “fire this mutation with props I already have” — the sub-component should call the relevant composable directly in its own <script setup>. Do not pass flow objects, refs, or mutation callbacks through props.

For example, if a NoteCard needs to edit and delete notes, it should call useDetailNotesFlow() itself rather than receiving an object with editingNoteId, editBody, startEditNote, saveEditNote, etc. as props. This avoids:

  • Ref-unwrapping issues: Vue does not auto-unwrap refs from props in templates, so you end up writing noteFlow.editingNoteId?.value and computed get/set wrappers for v-model — fragile and confusing.
  • Bloated prop interfaces: Flow objects with many refs and callbacks make the component harder to understand and reuse.
  • Tight coupling: The parent dictates orchestration that belongs to the child.

Since composables use TanStack Query under the hood, multiple components calling the same composable will share the same query cache — there's no duplicated fetching.

Forms

If a page renders any form elements, such as an input or select, they should always live as a separate component from the page. Their name should end in Form and use defineModel. Where possible, Form components should model schemas defined in packages using @saflib/openapi, for a portable way to edit common business models.

Form Vue components, like HTML form elements, are not responsible for updating data on the backend. Form components may call other APIs if they're needed by components (like autocompletes), but it's up to the page component to have some sort of submit button to take the modeled data and fire off a Tanstack mutation, as well network responses.

Form components are responsible for exposing whether the entered data is valid. They should use defineExpose to expose an isValid property which the page component can use to enable or disable the submit button.

Displays

A display component is simply a component which is not a form; it displays data. These tend to be classic "presentational" components, though similar to form components, they may fetch data or otherwise perform networking (such as components which load more data on demand).

Displays, like forms and pages, should declare their role by having Display at the end of their name, or the name of their top-level design-system or HTML component (card, table, etc.).

Best Practices

Avoid Custom CSS

All components should avoid custom CSS if they can. Instead, take advantage of Vuetify's utility classes and grid system, and defer to app-specific theming, and SASS variables.

When a look should apply product-wide, put it in the clients design-system files rather than a component <style> block:

FileUse for
clients/build/vuetify-settings.scssVuetify Sass variables (@use "vuetify/settings" with (…))
clients/build/vuetify-overrides.scssCSS that restyles .v-* components
clients/build/globals.scssApp utility / layout classes that are not Vuetify restyles

To see what Vuetify ships, open node_modules/vuetify/lib/components/<VComponent>/ (e.g. VBtn.sass, _variables.scss) and node_modules/vuetify/lib/styles/settings/.

If a component still needs one-off CSS, use a style block at the bottom of the component, scoped so it does not leak.

Run All Strings Through Vue I18n

Even if you don't plan on translating your application ever, it's useful for testing to structure your strings in a consistent way, especially if they involve interpolating values or HTML elements.

See more in i18n.