Building It

Wiring the router, the API client, a Pinia store and the component tree, with the folder layout and the reason for each folder.

SyntaxdefineStore(id, setupFunction) returns a store composable you call as useStore()

With the routes decided, the application is four layers. Each one knows only about the layer below it, which is what keeps a screen testable.

The four layers

The API client is one function that talks to the network and throws a typed error. Nothing else in the application calls fetch. The store is a Pinia store: a module holding state that any component can read, created with defineStore. It caches the tasks and exposes actions that change them. Views are the components a route mounts. Components are the pieces views are made of, and they take props and emit events rather than reaching into the store.

A setup store is the form used here: the second argument to defineStore is a function that creates refs and returns them. It reads like a composable, which is a plain function that uses Vue's reactivity APIs, and the difference is that a store is created once for the whole application.

Request state in one place

The store holds a status of 'idle', 'loading', 'ready' or 'error'. The view renders one branch per status. Because load returns early when the status is already 'ready', revisiting the list does not refetch.

What trips people

Letting a component import the API client directly. The first time two components need the same list, you have two caches and two spinners.

Example

Example · vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useTasksStore } from '@/stores/tasks'

const tasks = useTasksStore()

onMounted(() => {
  void tasks.load()
})
</script>

<template>
  <section>
    <h1>Tasks</h1>

    <p v-if="tasks.status === 'loading'">Loading tasks</p>

    <p v-else-if="tasks.status === 'error'">
      {{ tasks.error }}
      <button type="button" @click="tasks.load()">Retry</button>
    </p>

    <ul v-else>
      <li v-for="task in tasks.open" :key="task.id">
        <router-link :to="{ name: 'task-detail', params: { id: task.id } }">
          {{ task.title }}
        </router-link>
      </li>
    </ul>
  </section>
</template>

<style scoped>
ul {
  list-style: none;
  padding: 0;
}
</style>

When to use it

  • Keeping one cached list that two routes can read without refetching.
  • Changing an auth header or a base URL in exactly one file.
  • Rendering a loading, error and ready branch from a single status value.
  • Giving a reviewer a folder layout where the direction of every import is obvious.

More examples

The API client: one place that calls fetch

A single generic request function, plus an ApiError that carries the HTTP status and any per-field messages the server sent. Because every call goes through here, adding a header or a retry is a one-line change. The fields object is what the forms lesson maps onto inputs.

Example · ts
// src/api/client.ts
export class ApiError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly fields: Record<string, string> = {},
  ) {
    super(message)
    this.name = 'ApiError'
  }
}

const base = import.meta.env.VITE_API_URL

export async function request<T>(
  path: string,
  init: RequestInit = {},
): Promise<T> {
  const response = await fetch(`${base}${path}`, {
    ...init,
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', ...init.headers },
  })

  const body = await response.json().catch(() => null)

  if (!response.ok) {
    throw new ApiError(
      body?.message ?? response.statusText,
      response.status,
      body?.errors ?? {},
    )
  }

  return body as T
}

// request<Task[]>('/tasks') resolves with the parsed array,
// or throws ApiError with .status set to the response status.

The tasks store as a setup store

Refs hold the state, a computed derives the filtered list, and actions own every transition of status. Note that items.value is assigned, not pushed into, and that the whole store is returned explicitly. Reach for a store rather than a composable when more than one route needs the same data and the same cache.

Example · ts
// src/stores/tasks.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { request } from '@/api/client'
import type { Task } from '@/types/task'

export const useTasksStore = defineStore('tasks', () => {
  const items = ref<Task[]>([])
  const status = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
  const error = ref('')

  const open = computed(() => items.value.filter((task) => !task.done))

  async function load(): Promise<void> {
    if (status.value === 'ready') return
    status.value = 'loading'
    try {
      items.value = await request<Task[]>('/tasks')
      status.value = 'ready'
    } catch (cause) {
      error.value = cause instanceof Error ? cause.message : 'Request failed'
      status.value = 'error'
    }
  }

  return { items, status, error, open, load }
})

// With one unfinished task and one finished task loaded:
// useTasksStore().open.length  // Output: 1

The folder layout

Folders by role, not by feature, because the application has one resource. api holds the network, stores holds shared state, views are mounted by routes and components are reused inside them. The rule that makes it work: imports point downwards, so a component never imports a view and nothing but a store imports the API client.

Example · text
src/
  api/
    client.ts          one fetch wrapper, one ApiError
  components/
    TaskForm.vue       reusable pieces, props in, events out
    TaskRow.vue
  router/
    index.ts           createRouter and the auth guard
    routes.ts          the route table
  stores/
    auth.ts            signed-in user and the token
    tasks.ts           the cached list and its actions
  types/
    task.ts            Task and TaskDraft
  views/
    SignInView.vue     one file per route
    TasksView.vue
    TaskDetailView.vue
  App.vue              the shell with router-view
  main.ts              createApp, plugins, mount

Discussion

  • Be the first to comment on this lesson.