My first Inertia PR: making usePage() a singleton
usePage() in Inertia built a new reactive object with 13 computed properties on every call. Turning it into a lazily created singleton removed the waste, and it’s part of Inertia’s 3.x branch.
On this page
I use Inertia with Vue every day at work, and usePage() is one of those functions you call without thinking: the current URL, the shared props, the flash messages. It’s everywhere. Then I looked at what it actually did on every single call.
That turned into my first pull request to Inertia, merged into the 3.x branch in February 2026.
What usePage() did
This was the Vue adapter:
export function usePage<TPageProps extends PageProps = PageProps>(): Page<TPageProps & SharedPageProps> {
return reactive({
props: computed(() => page.value?.props),
url: computed(() => page.value?.url),
component: computed(() => page.value?.component),
// … ten more computed properties, one per field of the page
flash: computed(() => page.value?.flash),
}) as Page<TPageProps>
}
Every call built a new reactive() object with 13 computed() properties inside. And every one of those computed properties read from the same thing: a single page ref, declared once at the top of the module.
So two components calling usePage() got two different objects that could only ever hold the same values.
Why that matters
On its own, one extra reactive object is nothing. The problem is how often usePage() gets called in a real app:
- In lists. A card component that calls
usePage(), rendered 30 times, meant 30 reactive objects and 390 computed properties, all watching the same ref. - In virtual scrollers, where components mount and unmount as you scroll. Each mount built a fresh wrapper, and each unmount threw it away.
- Through wrapper composables. It’s common to wrap
usePage()in small composables, likeuseCurrentUser()oruseFeatureFlags(). A component that uses three or four of them callsusePage()three or four times, and inside a loop that multiplies again.
None of this breaks anything, but it’s work that buys nothing. And a library can’t know what its users’ apps look like, so a function this central should be cheap by default.
The fix: a singleton
Since every instance was identical, there only needed to be one. The fix makes usePage() return a singleton: the object is built on the first call, kept in a variable, and that same instance is returned on every call after it.
let pageAccessor: Page | null = null
export function usePage<TPageProps extends PageProps = PageProps>(): Page<TPageProps & SharedPageProps> {
if (!pageAccessor) {
pageAccessor = reactive({
props: computed(() => page.value?.props),
url: computed(() => page.value?.url),
// … the same 13 computed properties
}) as Page
}
return pageAccessor as Page<TPageProps>
}
What makes it a singleton is where pageAccessor lives: at module level, outside the function. An ES module is evaluated once and cached, so every import { usePage } in the app points at the same module, and so at the same variable. The first call finds it empty and creates the instance (lazy initialisation); every later call finds it filled and returns it. It’s the JavaScript way of writing a singleton: no class with a private constructor and a static getInstance(), just state held by the module.
It’s still reactive. The computed properties read page.value every time, so after a navigation they return the new page, exactly as before. The only difference is that everyone shares one wrapper instead of each getting their own.
The Svelte adapter had the same pattern with a store: every call created a new derived() store. The same change applied there: one shared store, held the same way. React didn’t need anything, because its usePage() already reads from a context provider without allocating.
A small type discussion
Bruno Francisco, who stopped by to review, suggested casting to Page<TPageProps> where the object is created. I kept Page there on purpose. pageAccessor lives at module level and is shared by every caller, each with their own TPageProps, so at that point it can only honestly be a Page. The narrowing to the caller’s props type belongs at the return, where the caller is known.
Proving it didn’t change behaviour
Inertia’s test suite runs Playwright against a test app for each adapter, so I added two pages and a child component to it, and a spec that runs for Vue and Svelte. It checks that:
- two
usePage()calls in the same component return the same instance; - a parent and a child component get the same instance;
- props and the URL are still reactive;
- after an SPA navigation to another page, everything updates: props, URL, component name, in the parent and the child.
That last one mattered most. A shared object that stopped updating after navigation would have been a real bug, and it’s exactly the kind of thing a singleton can get wrong.
Why 3.x and not the current version
Pascal Baljet, one of the maintainers, merged it into the 3.x branch instead of the current release, because he wasn’t completely sure of the side effects in existing apps. I think that was the right call. Code that compared two usePage() results, or relied on getting a fresh object each time, would behave differently, and a major version is where that kind of change belongs.
It also doesn’t add new shared state on the server. The page ref was already module-level, so with server-side rendering the singleton wraps state that was already shared; it doesn’t create any.
The trade-offs
- What it improved: performance, in allocations and in work for the reactivity system, especially in lists and virtual scrollers.
- What it risked: compatibility. Code that compared two
usePage()results, or expected a fresh object each time, would behave differently. That risk is exactly why the change waited for a major version. - What it didn’t change: the API. Callers write the same code and get the same values, so there’s nothing for anyone to migrate.
Spot it in your own app
- Search your codebase for
usePage()and look at where it’s called: components rendered in lists, and small composables that wrap it. Each call used to build its own reactive object. - On Inertia 2 and earlier, if a list component only needs one prop from the page, read that prop once higher up and pass it down.
What I’m taking from this
- Reading the source of the libraries you use every day pays off. This was a two-minute read.
- In a library, small costs multiply by every app and every render. “It’s just one object” stops being true at scale.
- A performance change is only as good as the proof that behaviour didn’t change. The navigation test was what made this safe to merge.
- Maintainers weigh risk differently from contributors, and a major version is a fair place for a change like this.
Thank you
To Pascal Baljet, for the quick review and the merge, and for being careful about where a change like this should land. And to Bruno Francisco, for taking the time to review a repository he doesn’t usually contribute to, and for asking the question I’d been asking myself: why hadn’t this been done before?