Skip to content

Design patterns in SPAs

Assignment

Martina Hostettler wants to show the app at the general meeting.

As a board member, I want to demonstrate the app at the general meeting, even if no server of the association runs there, so that the members can try it out.

The volunteer would also like to sort by availability, and the new volunteer asks why the card of a thing knows no store. The acceptance criteria are:

  1. The stores get the API from outside and run with the course API and with a stand-in, without their code changing.
  2. A new sort order needs no change to the template.
  3. The card of a thing works on every page without knowing a store.

Learning goals

After this chapter you can

  • use design patterns as a shared language,
  • recognize the observer in Vue's reactivity,
  • separate presentational and container components,
  • pass dependencies with provide and inject,
  • write an adapter and strategies and use a store as a facade.

A design pattern describes a proven solution to a problem that keeps coming up when designing software. Many of them became known through the book "Design Patterns" by Gamma, Helm, Johnson and Vlissides from 1994, which describes 23 patterns for object-oriented software. Their names make it easier to talk about code. "There's an adapter behind the stores" says in one sentence what would otherwise take a paragraph. In an SPA with Vue, the patterns rarely show up as classes. They appear as functions, modules and components.

The observer pattern makes sure that all dependent objects find out automatically when an object changes. That's exactly what Vue's reactivity does. According to the Vue documentation, Vue keeps track of which effects read a reactive value and runs them again after a change. Every component renders inside such an effect, and a watch is an observer you write yourself. The deep dive 1.5 built a small system of this kind with a proxy.

A presentational component only shows what it receives as props and reports clicks as events. A container knows stores and the API and passes the data on.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import ItemCard from './ItemCard.vue'
import { things } from './things.js'
// The container holds the state and decides what a click means
const favorites = ref([])
function toggle(id) {
favorites.value = favorites.value.includes(id) ? favorites.value.filter((entry) => entry !== id) : [...favorites.value, id]
}
</script>
<template>
<p>Favorites: {{ favorites.length }}</p>
<ul>
<ItemCard
v-for="thing in things"
:id="thing.id"
:key="thing.id"
:name="thing.name"
:category="thing.category"
:favorite="favorites.includes(thing.id)"
@toggle-favorite="toggle"
/>
</ul>
</template>

ItemCard.vue

ItemCard.vue
<script setup>
// Presentational, the card only shows what it gets and reports the click
defineProps({
id: { type: Number, required: true },
name: { type: String, required: true },
category: { type: String, required: true },
favorite: { type: Boolean, default: false },
})
const emit = defineEmits(['toggle-favorite'])
</script>
<template>
<li class="card">
<strong>{{ name }}</strong>
<span>{{ category }}</span>
<button type="button" :aria-pressed="favorite" @click="emit('toggle-favorite', id)">Favorite</button>
</li>
</template>
<style scoped>
.card {
display: flex;
gap: 1rem;
align-items: baseline;
}
</style>

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', category: 'Parties', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', category: 'Tools', deposit: 40, availableFrom: '2026-11-05' },
]

Dan Abramov described this split for React in 2015. In 2019 he added that he no longer recommends it, because Hooks move stateful logic out without such a split, which composables do in Vue. The idea stays useful wherever a component appears in several places. In the running case app, the catalog and the favorites show the same ItemCard, which knows no store, and the pages decide what a click on "Favorite" means. Not every component has to be one or the other.

A dependency is something a piece of code needs to do its job, such as the API for a store. With dependency injection, a piece of code doesn't fetch its dependency itself, it receives it from outside. The Vue documentation offers provide and inject for this, and with app.provide a value applies to the whole app.

App.vue

App.vue
<script setup>
import { onMounted, ref } from 'vue'
import { useApi } from './api.js'
const api = useApi()
const items = ref([])
const error = ref('')
onMounted(async () => {
const { ok, data } = await api.loadItems()
if (ok) items.value = data
else error.value = data.error
})
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<ul v-else>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>

api.js

api.js
import { inject } from 'vue'
import { things } from './things.js'
// The key under which main.js provides the API
export const apiKey = Symbol('api')
// Two adapters with the same function. The page only knows loadItems, not where the data come from.
export function createMemoryApi() {
return {
loadItems: async () => ({ ok: true, data: things }),
}
}
export function createBrokenApi() {
return {
loadItems: async () => ({ ok: false, data: { error: 'The server cannot be reached.' } }),
}
}
export function useApi() {
return inject(apiKey)
}

main.js

main.js
import { createApp } from 'vue'
import App from './App.vue'
import { apiKey, createMemoryApi } from './api.js'
// main.js decides which API the app gets, App.vue stays the same
createApp(App).provide(apiKey, createMemoryApi()).mount('#app')

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', category: 'Parties', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', category: 'Tools', deposit: 40, availableFrom: '2026-11-05' },
]

App.vue only calls useApi() and doesn't know which implementation main.js provided. In the running case app, main.js does the same for the stores.

main.js
const app = createApp(App)
// The stores get the API from here. Development and build talk to the course API, the demo gets
// a stand-in in the browser. The API comes first, a guard of the first navigation already needs it.
app.provide(apiKey, createApi())
app.use(createPinia())
app.use(router)
app.mount('#app')

Pinia runs a setup store in the context of the app, so inject works inside a store too. The running case app wraps the call in a function that complains when someone forgot to provide the API.

index.js
// main.js provides the API under this key. A Symbol cannot clash with another key of the app.
export const apiKey = Symbol('api')
// The demo runs without a server, its mode sets VITE_API to memory in .env.demo
export function createApi() {
return import.meta.env.VITE_API === 'memory' ? createMemoryApi() : createHttpApi(API_URL)
}
// The stores ask for the API instead of importing one, so main.js and the tests decide which one
// the app gets. Both adapters answer every call with ok, status and data.
export function useApi() {
const api = inject(apiKey, null)
if (api === null) throw new Error('No API provided. main.js provides one with app.provide(apiKey, createApi()).')
return api
}

The key is a Symbol that other files import, as the Vue documentation advises for larger apps. Tests provide the same API or a stand-in, and that way they call the real stores.

An adapter translates one interface into the one another piece of code expects. The stores expect functions like loadItems() that answer with ok, status and data. createHttpApi translates the addresses, methods, status codes and JSON of the course API for them, as chapter 8.1 showed. createMemoryApi gives the same answers from the browser's memory, without a server. createApi picks one of the two, and no store notices the difference. Chapter 8.6 shows how the demo for the general meeting makes use of it.

A facade gives a whole subsystem one simple entry point. The catalog store offers the things, a status and the actions load and markReturned. How it loads, how the API answers and which messages come up on an error stay hidden from the pages.

catalogStore.js
export const useCatalogStore = defineStore('catalog', () => {
// The store hides where the things come from, the pages only know items, status and actions
const api = useApi()
const items = ref([])
// loading, success or error, App shows something for each of them
const status = ref('loading')
const error = ref('')
const availableCount = computed(() => items.value.filter((item) => item.availableFrom === null).length)
async function load() {
status.value = 'loading'
const { ok, data } = await api.loadItems()
if (ok) {
items.value = data
status.value = 'success'
} else {
error.value = data.error
status.value = 'error'
}
}

Pinia doesn't call its stores facades. But a store that loads its own data and only exposes finished values and actions does exactly that job.

A strategy is an interchangeable procedure for the same task. Each sort order is a function that compares two things, and the page picks one of them instead of knowing every sort order itself.

App.vue

App.vue
<script setup>
import { computed, ref } from 'vue'
import { sorters } from './sorters.js'
import { things } from './things.js'
const sort = ref('name')
// The page asks the chosen strategy and knows no order itself
const sorted = computed(() => things.toSorted(sorters[sort.value].compare))
</script>
<template>
<label for="sort">Sort by</label>
<select id="sort" v-model="sort">
<option v-for="(sorter, key) in sorters" :key="key" :value="key">{{ sorter.label }}</option>
</select>
<ol>
<li v-for="thing in sorted" :key="thing.id">{{ thing.name }}, CHF {{ thing.deposit }}, {{ thing.availableFrom ?? 'in' }}</li>
</ol>
</template>

sorters.js

sorters.js
const byName = (a, b) => a.name.localeCompare(b.name, 'en')
// Every sort order is a strategy with a label and a function that compares two things
export const sorters = {
name: { label: 'Name', compare: byName },
deposit: { label: 'Deposit', compare: (a, b) => a.deposit - b.deposit || byName(a, b) },
availability: {
label: 'Availability',
compare: (a, b) => (a.availableFrom ?? '').localeCompare(b.availableFrom ?? '') || byName(a, b),
},
}

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', category: 'Parties', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', category: 'Tools', deposit: 40, availableFrom: '2026-11-05' },
]

The running case app keeps its sort orders in src/features/catalog/sorters.js and builds the menu from them. Sorting by availability is one more entry, and the template of the catalog stayed the same.

Use a pattern where you actually have its problem, not as decoration. If you have an API that you want to swap later or replace in tests, an adapter pays off. Name the pattern in your README or in an architecture decision, so your teacher knows what to look for in the screencast.

Work with the examples of this chapter. Read the code and write down what you expect before you change anything.

  1. In the first example, give every ItemCard its own list of favorites and remove the prop favorite. What does the number at the top show?
  2. In the example with the adapter, replace the call createMemoryApi() in main.js with createBrokenApi(). What changes in App.vue, and what on the page?
  3. In the example with the strategies, add a sort order "Deposit, highest first". Which files do you write in?
  4. The course API delivers the things in another format. Which files of the running case app change?

The exercises build a catalog with presentational and container components, an adapter and strategies. You'll find the project in the folder exercises/08-architecture/patterns/en of the course repo. Copy the folder, install the packages and start the tests.

Terminal window
npm install
npm test

Exercise Getting started

ItemCard.vue gets the favorites store itself and can't be shown without Pinia. Turn it into a presentational component with the props id, name, category, deposit and favorite that reports a click on "Favorite" as the event toggle-favorite with the number. The catalog page becomes the container that knows the store.

Solution
ItemCard.vue
<script setup>
import { formatDeposit } from '@/format.js'
// A presentational component, it gets everything through props and reports clicks as events
defineProps({
id: { type: Number, required: true },
name: { type: String, required: true },
category: { type: String, required: true },
deposit: { type: Number, required: true },
favorite: { type: Boolean, default: false },
})
const emit = defineEmits(['toggle-favorite'])
</script>
<template>
<li class="card">
<strong>{{ name }}</strong>
<span class="category">{{ category }}</span>
<span class="deposit">{{ formatDeposit(deposit) }}</span>
<button type="button" :aria-pressed="favorite" @click="emit('toggle-favorite', id)">Favorite</button>
</li>
</template>

The page passes the state with :favorite="favorites.isFavorite(item.id)" and calls the store with @toggle-favorite="favorites.toggle".

Exercise Going further

The association is trying out a lending software. It delivers its things in public/things.json in a format of its own, with itemId, title, group, the deposit in cents as depositCents and the return date as returnDate. Write a function toItem(thing) in src/api/adapter.js that turns such a thing into an item in the app's format, and let fetchItems() deliver only items like that. Afterwards, no component knows the other format.

Solution
adapter.js
// The lending software calls a thing differently. The adapter turns one of its things into an item
// as the app knows it, so no component has to know the other format.
export function toItem(thing) {
return {
id: thing.itemId,
name: thing.title,
category: thing.group,
deposit: thing.depositCents / 100,
availableFrom: thing.returnDate,
}
}
items.js
import { toItem } from './adapter.js'
// The lending software delivers its things as things.json, the adapter turns them into items
export async function fetchItems() {
const response = await fetch(`${import.meta.env.BASE_URL}things.json`)
if (!response.ok) throw new Error(`The lending software answers with the status ${response.status}.`)
const things = await response.json()
return things.map(toItem)
}

If the lending software changes its format, only the adapter changes.

Exercise Challenge

The page sorts with one if per sort order, and the menu is fixed in the template. Create the strategies name, deposit and availability in src/sorters.js, each with a label and a compare, and build the menu from these entries. Sorted by availability, the things that are in come first, then the rest by their return date.

Solution
sorters.js
const byName = (a, b) => a.name.localeCompare(b.name, 'en')
// Every sort order is a strategy, a function that compares two items. The page builds its menu from
// these entries, so a new order is a new entry and nothing else changes.
export const sorters = {
name: { label: 'Name', compare: byName },
deposit: { label: 'Deposit', compare: (a, b) => a.deposit - b.deposit || byName(a, b) },
// Things that are in come first, then the others by the day they come back
availability: {
label: 'Availability',
compare: (a, b) => (a.availableFrom ?? '').localeCompare(b.availableFrom ?? '') || byName(a, b),
},
}
CatalogView.vue
const sorted = computed(() => items.value.toSorted(sorters[sort.value].compare))
</script>
<template>
<h1>Catalog</h1>
<label for="sort">Sort by</label>
<select id="sort" v-model="sort">
<option v-for="(sorter, key) in sorters" :key="key" :value="key">{{ sorter.label }}</option>
</select>

A thing that's in has no date. ?? '' turns that into an empty text, which comes before every date.

MistakeWhat helps
A pattern without a fitting problem makes the code longer, not simpler.Only use a pattern once the problem it solves shows up.
A presentational component calls a store and can no longer be shown or tested on its own.Pass data as props and report clicks as events.
inject returns undefined.provide is missing or uses another key. Import the key as a Symbol from one file.
Fields of the other API show up in components.Only the adapter knows the other format.
A new sort order brings another if into the page.Put each sort order into a table as a strategy.
An AI assistant suggests a global event bus with $on.According to the migration guide, Vue 3 removed $on. Use props and events, provide and inject or a store.

Self-check

  1. What does a design pattern describe, and what are its names good for?
  2. Where is the observer in Vue?
  3. How does a presentational component differ from a container?
  4. Why don't the stores change when the app gets a stand-in for the API?
Answers
  1. A proven solution to a recurring problem. With its name, a solution can be named in one word.
  2. In reactivity. Effects like rendering a component or a watch read reactive values and run again after a change.
  3. The presentational component gets everything through props and reports events. The container knows stores and the API.
  4. They only call functions of the API that both adapters offer in the same way, and they get their API through inject.

Assignment

The stores no longer know where the data come from, and the card of a thing fits on every page. Now the new volunteer asks why the app uses Pinia, why the API goes through a proxy and why the form no longer asks before leaving. Nobody remembers all the reasons. The next chapter records decisions like these.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.