Inertia: Partial Initial Pages
Use Case
After using Inertia for a while I've run into a limitation; partial reloads. Particularly, the fact that partial reloads only work for visits made to the same page component.
In my case, many pages might use the same data, and to refetch & send that data on a subsequent page load when it's already cached in the browser would be a waste of time & resources. This made me wonder how other places are handling & caching page props, since this was not a unique problem to have.
The intranet Laravel application I was working on was originally written in 100% Blade pages, and I was tasked getting started with rebuilding everything in Vue. I found myself utilizing Pinia to store persistent app-specific data for multi-page sub-apps. With this design structure I could load the data I needed to bootstrap the app in the first request, and then omit them in subsequent requests.
The only problem was that this design pattern wasn't really supported with Inertia unless the entire sub-app was contained within a single page component. If the sub-app had multiple pages, the end user should be able to initiate a page load from any page, load the data needed to bootstrap the Pinia store, and then have all subsequent page requests omit any unnecessary page props to speed everything up.
For a little while, I got around this by adding a wrapper component that was shared between all of a given sub-app's pages which itself checked in the Pinia store was instantiated and make additional API calls to fetch the necessary Pinia store data if not. However, this was not ideal for the following reasons:
- A given page will finish loading irrespective of whether or not the Pinia store, which the page relies on, is ready. This forced me to have to use placeholder skeletons which can cause frantic rendering & rerendering of the page for the end user, especially if the bootstrapping data is fetched quickly.
- It results in another HTTP request fired off immediately after the page loads on the initial visit; it sounds trivial, but in practice the overhead of fetching the data I wanted with an additional API call caused me to have to setup more boilerplate code and added an unnatural flow to both the UX and DX that I just wasn't satisfied with.
I kept thinking it would be a lot simpler if I could define the bootstrapping props I needed for a given sub-app in one place that would be automatically included on initial page loads and then dynamically omitted.
The Problem
In Inertia, partial reloads work in conjunction with the following three types of items in the component's props array (as seen here):
return Inertia::render('Users/Index', [
// ALWAYS included on first visit...
// OPTIONALLY included on partial reloads...
// ALWAYS evaluated...
'users' => User::get(),
// ALWAYS included on first visit...
// OPTIONALLY included on partial reloads...
// ONLY evaluated when needed...
'users' => fn () => User::get(),
// NEVER included on first visit...
// OPTIONALLY included on partial reloads...
// ONLY evaluated when needed...
'users' => Inertia::lazy(fn () => User::get()),
]);
As you can see, if simply wrap our bootstrapping props in an anonymous closure, that would work - but only for situations in which the bootstrap data is only needed for a single page component. Since closures are always included on first visit, that means they will be included on every sub-app page navigation.
Alternatively, we could wrap the bootstrapping props in an Inertia::lazy() statement; with this, the props will never be included on initial page loads, so that solves the problem that closures have. However, the only way to include a prop that's wrapped in an Inertia::lazy() statement is to do a partial page reload; i.e. we'd have to do the initial page load, check if we need the bootstrapping props, then do a reload if necessary. This is the same process I was using previously, albeit with standalone API calls, and it brings us right back to square one.
The Solution
Seeing as I absolutely hate drudging up the same boilerplate code over and over again, especially when I think there's an alternative approach surely within reach, I decided to write a helper service to handle this logic for me.
Part of this solution was inspired by my realization that I was starting to reinvent the wheel - that wheel being TanStack Query (sort of, in the sense that ). Once I started using TanStack Query I realized that by using query keys on the frontend, I was essentially defining any given page's required props there.
In conjunction with TanStack, if we define a given page's required props on the backend, we can add all our cached query keys onto our subsequent page requests, resulting in server responses that always contain the data we need, and never data we already have.
Backend: Props Structure
The first part will be to define the props needed for your page. These are props that are required for the page to function, but that we don't want to waste server compute to fetch them if the frontend already has them.
In order to define these properly, we need to define each prop within a closure so as to avoid actually fetching them. We then need to add each closure definition inside an Inertia::lazy() closure, which we can place inside a class variable in the Controller.
Here is an example:
INFO
Keep in mind that each prop name should match the stringified query key for this same data that TanStack will use.
use Inertia\Inertia;
class SomeAppController extends Controller
{
public $initProps;
function __construct()
{
$bootstrapProps1 = fn() => SomeService::fetchData();
$bootstrapProps2 = fn() => SomeOtherService::fetchData();
$this->initProps = [
'init' => Inertia::lazy(fn() => [
'bootstrapProps1' => $bootstrapProps1,
'bootstrapProps2' => $bootstrapProps2,
]),
];
}
public function index(Request $request)
{
return Inertia::render('SomeApp/SomSubApp/Page', InertiaDataService::parseProps([
'nonBootstrapProps1' => SomeOtherService::fetchData(),
'nonBootstrapProps2' => 'Hello There',
...$this->initProps # <-- Bootstrap props easily included via spread operator
]));
}
}
INFO
The bootstrapping props must be in an array wrapped in Inertia::lazy(), and each item within that array must be keyed with a unique key name that won't clash with any page component's props they would be merged with
Frontend: TanStack Queries
There are two things we need to do on the frontend:
- Utilize any query data that the server sends over on a page load
- Include the loaded query keys in the HTTP request headers for page requests
The first one is pretty simple; I like to setup composable functions that look like the following:
import { usePage } from '@inertiajs/vue3'
import { useQuery} from '@tanstack/vue-query'
export const exampleQuery = () => {
const queryKey = 'bootstrapProps1'
const example = useQuery({
queryKey: [queryKey],
queryFn: (): Promise => {
return new Promise(async function(resolve, reject)
{
someFetcherFunction()
.then(res => resolve(res))
.catch(err => reject(err))
})
},
...(usePage().props[queryKey] && {
initialData: usePage().props[queryKey],
})
})
return { example }
}
Within the query definition we are checking if the page's props already have this query key, and if so we are instantiating the query with that prop data.
In order to include the existing query keys that TanStack already has data cached for, we need to check the existing keys before every request and inject them into the request headers. We want to do this only on page requests. The best way to do that is to add the following snippet somewhere high up in your page's codebase:
import { Inertia } from '@inertiajs/inertia'
import { useQueryClient } from '@tanstack/vue-query'
Inertia.on('before', (event) => {
const queryCache = queryClient.getQueryCache()
const keys = queryCache.getAll().map(cache => cache.queryKey.join('_')).join('|')
event.detail.visit.headers['X-Tanstack-Query-Keys'] = keys
})
Here we are simply checking the general TanStack query cache, mapping each cached query key to it's own string (since TanStack stores each key as an array), and finally joining each stringified key together to make it all one string we can stuff into a request header.
Backend: Parse & Prepare
class InertiaDataService
{
public static function parseProps(array $props)
{
if (Arr::has($props, 'init')) {
// If any existing query keys were passed in the request, we'll ommit anything preset here and also
// present in the "init" array
$existingQueryKeys = request()->hasHeader('X-Tanstack-Query-Keys')
? str(request()->header('X-Tanstack-Query-Keys'))->split('/[\s|]+/')
: [];
// As long as the array values in the 'init' array are closures, executing App::call on the 'init' array
// will not evaluate the individual closures themselves, which is what we want since we are only interested
// in the 'init' array's keys.
$neededQueryKeys = collect(App::call($props['init']))->except($existingQueryKeys)->toArray();
// We'll loop through the needed query keys and execute the lazy prop and merge it into the props array
$executedNeededProps = [];
foreach ($neededQueryKeys as $key => $value) {
// Now we will execute the closures and merge them into the props array
if ($value instanceof \Closure) $executedNeededProps[$key] = $value();
}
$props = array_merge($props, $executedNeededProps);
$props = Arr::except($props, 'init');
}
return $props;
}
}
We're doing a few things here:
- First we check if there is a TanStack Query keys header, and if there is then we get string value that was passed and split it into an array of keys
- Next we check which init props we actually need by filtering out any of the props (query keys) defined in the Controller
- We then loop through only the needed props and execute the closure to fetch the data
- Finally, we merge the init props with the rest of the passed props and return a flat props array
Something to keep in mind is that each prop must have unique names (if you are already using TanStack Query you are familiar with this).
So this will work:
$props = [
'foo' => SomeService::getStuff(),
'bar' => SomeOtherService::getSomeOtherStuff(),
'init' => Inertia::lazy(fn() => [
'biz' => SomeBootstrapService::bootstrapTheThings()
'bat' = 'General Kenobi',
]),
];
However this will not work because "foo" would be included twice:
$props = [
'foo' => SomeService::getStuff(),
'bar' => SomeOtherService::getSomeOtherStuff(),
'init' => Inertia::lazy(fn() => [
'foo' => SomeBootstrapService::bootstrapTheThings()
'bat' = "You've failed me for the last time",
]),
];
So for example, if we passed the following array to this service:
$props = [
'foo' => SomeService::getStuff(),
'bar' => SomeOtherService::getSomeOtherStuff(),
'init' => Inertia::lazy(fn() => [
'biz' => SomeBootstrapService::bootstrapTheThings()
'bat' = 'General Kenobi',
]),
];
return InertiaDataService::parseProps($props);
It would return the following:
[
'foo' => SomeService::getStuff(),
'bar' => SomeOtherService::getSomeOtherStuff(),
'biz' => SomeBootstrapService::bootstrapTheThings()
'bat' = 'General Kenobi',
]
Conclusion
I've been using this pattern in production for several months now with great results. With each page's props well defined within the controller, it allows for optimal data efficiency by only including data that's actually needed on each page navigation while still allowing a lot of flexibility on the frontend.
PS: A note about query key nomenclature
For naming my query keys I generally like to use the data model's namespace following by a unique scope.
For example, if my query was an array of all the records of a particular model and another query to house a single eager-loaded model, my query keys might look something like this:
const namespace = String.raw`App\Models\ExampleModel`
const queryKeyAll = [namespace, 'all'] // Stringified as `App\\Models\\ExampleModel,all`
const queryKeyEagerLoaded = [namespace, 2 /*ID*/] // `App\\Models\\ExampleModel,2`
However, Inertia likes to embed page props inside HTML attributes (as part of it's hydration method from what I can tell), and this pattern involves invalid HTML attribute characters ("" and ","). After some playing around I've settled on the following design pattern:
In your model's TypeScript interface, define both the namespace and the TanStack namespace like the following:
export const AssetNamespace = String.raw`App\Models\ExampleModel`
export const AssetTanstackNamespace = 'App-Models-ExampleModel'
export interface ExampleModel {
id: number,
field1: string,
field2: number,
field3: boolean,
created_at: string,
updated_at?: string,
deleted_at?: string | null
}
All we're doing here is replacing "" with "-" so that we're using valid HTML characters. We can then define the page's props in the Controller variable like the following:
use Inertia\Inertia;
class SomeAppController extends Controller
{
public $initProps;
function __construct()
{
$bootstrapProps1 = fn() => SomeService::fetchData();
$bootstrapProps2 = fn() => SomeOtherService::fetchData();
$this->initProps = [
'init' => Inertia::lazy(fn() => [
'App-Models-ExampleModel_all' => $bootstrapProps1,
'App-Models-ExampleModel_2' => $bootstrapProps2,
]),
];
}
public function index(Request $request)
{
return Inertia::render('SomeApp/SomSubApp/Page', InertiaDataService::parseProps([
'nonBootstrapProps1' => SomeOtherService::fetchData(),
'nonBootstrapProps2' => 'Hello There',
...$this->initProps # <-- Bootstrap props easily included via spread operator
]));
}
}