Composables and server-side rendering — what breaks under SSR
In a setup that also renders on the server, such as Nuxt, the top level of script setup and the body of a composable run on the server too. A composable written on the assumption of a browser breaks here.
Using the composables from the previous chapter as material, we look at three ways they break. All three come from misreading when something runs.
What you'll learn in this chapter
- How far the read of
windowhas to be deferred - Distinguish what stops being registered on the server from what does not
- Understand the mechanism by which state in module scope leaks between users
- Decide whether to let a composable hold shared state
Reading window at call time
// falls over on the server, where there is no window
export function useWindowWidth() {
const width = ref(window.innerWidth)
// ...
}
Node.js has no window, so window is not defined is thrown at renderToString.
Designing it to take the target as an argument makes no difference, because it is evaluated at the point you pass it.
// suppose the target is taken as the first argument
useEventListener(window, 'resize', sync)
// ^^^^^^ window is read here
The reason useEventListener in chapter 8 does not take a target and reads window internally is to confine that evaluation inside onMounted.
The way around it is to defer the read to a post-mount hook. The documentation likewise offers the form of "lazily accessing browser-only APIs inside client-only hooks such as onMounted".
import { computed, onMounted, ref } from 'vue'
import { useEventListener } from './useEventListener'
export function useWindowWidth(fallback = 1024) {
const width = ref(fallback)
const isMobile = computed(() => width.value < 768)
const sync = () => {
width.value = window.innerWidth
}
onMounted(sync)
useEventListener('resize', sync)
return { width, isMobile }
}
The initial value is taken as an argument because that value goes into the HTML the server renders. It is replaced with the real width once onMounted runs on the client.
What stops being registered on the server
On the server, the bodies of onMounted do not run. More precisely, hooks are not registered at all during a component's setup under SSR. It is not that they fail to run; they are stopped at the registration stage.
The same thing happens to watchers. A watch(src, cb) without immediate is not created on the server, so a change to the source after rendering never calls it. watchEffect runs once by default, because it is the kind that always runs first, but adding flush: 'post' means it is likewise not created.
The suppression of hooks applies to those that go through a shared creation function: the mount, update, and teardown series (onBeforeMount through onUnmounted), plus the debug hooks onRenderTracked / onRenderTriggered. onServerPrefetch is an exception that gets through so it can run on the server, and onErrorCaptured / onActivated / onDeactivated do not go through that creation function, so they are outside the suppression.
Side effects not confined to onMounted
The suppression above only covers hook and watcher registration. Side effects written outside it start up on the server as they are.
useTicker in chapter 8 is one of these. It starts setInterval at call time, so the timer keeps running after server-side rendering finishes, and onScopeDispose is never called (the scope is not stopped). If you accept it as component-only, move the start into onMounted.
import { onMounted, onScopeDispose } from 'vue'
export function useTickerClient(onTick: () => void, interval = 1000) {
let id: ReturnType<typeof setInterval> | undefined
onMounted(() => {
id = setInterval(onTick, interval)
})
onScopeDispose(() => {
if (id !== undefined) clearInterval(id)
})
}
This version does not start inside effectScope(). It is a choice between wanting it to work outside components and not wanting it to run on the server. If you need both, you end up branching on the runtime environment, and that form is covered in chapter 19.
Putting state in module scope
Put a ref at a module's top level and every component that calls it points at the same state.
import { computed, ref } from 'vue'
// module top level
const user = ref<{ name: string } | null>(null)
export function useAuth() {
const userName = computed(() => user.value?.name ?? 'Guest')
const signIn = (name: string) => {
user.value = { name }
}
return { user, userName, signIn }
}
Running only in the browser, this is exactly what you want. The module is initialised each time the page is opened afresh, so the lifetime of the state matches the tab.
On the server it is different. The module is initialised once when the server starts, and the same instance is reused for every subsequent request. Overwrite it with one user's data and it leaks into another user's request. The documentation calls this cross-request state pollution.
Render twice in the same process and you reproduce it. Put signIn('Tanaka') through on the first pass and the second render, with nobody signed in, shows "Tanaka" instead of "Guest".
The workaround the documentation gives is to create the app and the store per request and pass them through an app-level provide, rather than importing them directly in components. The concrete forms are covered in chapter 12 and chapter 17.
If you need shared state, Pinia is the practical answer. Putting state in a composable's module scope is only for cases where you accept it as client-only.
When the composable holds the data fetching
Place useFetch from chapter 8 in a setup that also renders on the server and the watchEffect runs once on the server and has its cleanup called immediately afterwards. renderToString does not wait for asynchronous work that has started, so the HTML the server returns contains the loading state.
That behaviour is for the case where flush is left at its default. With flush: 'post', or with watchPostEffect, it never runs on the server at all. It is specified to run after DOM updates, so on a server with no DOM it drops out of consideration.
Filling in data on the server requires different machinery, covered in chapter 18 and chapter 19.
Summary
- In a setup that also renders on the server, the top level of
script setupand the body of a composable run on the server too - Do not read
windowat call time; defer it toonMounted. Passing it as an argument still means the passing side read it - Taking an initial value as an argument puts that value into the HTML the server renders. It is replaced with the real value once
onMountedruns on the client - What stops being registered under SSR is hooks and watchers. A
watch(src, cb)withoutimmediateis not created, andwatchEffectruns once by default (flush: 'post'is not created) - The hook suppression has exceptions.
onServerPrefetchgets through, andonErrorCaptured/onActivated/onDeactivatedare outside the suppression - Side effects written outside that suppression pass straight through. A timer started at call time keeps running after server-side rendering
- State placed in module scope leaks between requests (cross-request state pollution). The official workaround is to create the app and the store per request and pass them through an app-level
provide - If you need shared state, use Pinia. Putting it in a composable's module scope is only for cases where you accept it as client-only
- Vue — Server-Side Rendering — cross-request state pollution and platform-specific APIs
- Vue — Composables — the guidance on putting side effects in
onMounted - Vue — SSR API —
renderToStringanddata-allow-mismatch
What to read next
- v-model between components — declaring parent-child two-way binding with
defineModel - Nuxt — data fetching and directory structure built around server-side rendering