T
TanStack•4w ago
fair-rose

beforeLoad - i guess we need beforeLayoutLoad

Hey folks 👋 I really enjoy how beforeLoad works in TanStack Router — especially how its return values are merged into the match context. However, I sometimes wish there was something like a beforeLayoutLoad hook. It would run under the same conditions as loader (meaning parent layout’s beforeLayoutLoad wouldn’t re-run when traveling under the same layout), but it would behave just like beforeLoad — i.e., its return values would merge into the context and be accessible by children. Here’s my use case: I’m using matches = useMatches() and building breadcrumbs like this:
const crumbs = matches.flatMap((match) => getCrumbs(match, data))
const crumbs = matches.flatMap((match) => getCrumbs(match, data))
We have nested routes like rootScope23/midScopeA/subScope6, and those mid-scope names are computed via beforeLoad. But visiting sub-scopes under the same mid-scope still re-triggers the parent beforeLoad, even though nothing logically changed at that level. I’ve read several related threads here on Discord — most suggestions revolve around tanstack-query and ensureQueryData. While that technically works, it increases complexity and breaks the natural DX: - When router data is invalidated, query data must also be manually invalidated. - ensureQueryData can return stale results even when the route invalidation should cause a refetch. beforeLoad itself has been great — not just for breadcrumbs but for many parent-level data dependencies. Sure, I could move all of this logic into loader and find another pattern for breadcrumbs, but a lifecycle hook like beforeLayoutLoad would make a lot of sense and simplify things greatly. Has anyone else run into this situation or found a clean solution? And does the TSR team have any future plans or thoughts regarding a hook like this? (I’d be happy to help prototype or PR something if the idea aligns with the roadmap.)
4 Replies
fair-rose
fair-roseOP•4w ago
That’s a great point — I was digging through the TanStack Router (and Query) source as well, thinking about how such a feature could be implemented. Interestingly, the existing cause parameter on beforeLoad already helps a lot here. By checking for cause === "enter", we can basically treat that as a layout-level lifecycle — fetching or computing data only when entering that layout, not when navigating between its children. Here’s a small example:
{ beforeLoad: async ({ cause, params, context }) => {
// Only run this logic when the layout is actually entered
if (cause === 'enter') {
const data = await fetchMidScope(params.id)
return { midScope: data }
}

// Otherwise, do nothing — keep previous context
return context
} }
{ beforeLoad: async ({ cause, params, context }) => {
// Only run this logic when the layout is actually entered
if (cause === 'enter') {
const data = await fetchMidScope(params.id)
return { midScope: data }
}

// Otherwise, do nothing — keep previous context
return context
} }
It’s funny how often people on Reddit and Discord have mentioned beforeLoad running too frequently, yet no one seems to have pointed out that cause can already distinguish this behavior. Hopefully this thread becomes a useful reference for others running into the same issue later on. (Still, having a dedicated beforeLayoutLoad could make this pattern clearer and more discoverable.)
dependent-tan
dependent-tan•4w ago
can you go into explicit details please, with an example of multiple routes / parents / children etc when beforeLayoutLoad etc would run and when it would not? we are planning more lifecycle methods, so maybe this will just be covered by those then. lets see
fair-rose
fair-roseOP•4w ago
Sure, let me break this down with a concrete example. Real-world scenario: Database Management System Imagine a database management system with this hierarchy: Databases → Schemas → Folders → Tables → Columns Each entity type has its own detail page. The route structure looks like this:
match0: app // welcome page
match1: app/databases/$databaseId // database details + beforeLoad (for breadcrumb + context)
match2: app/databases/$databaseId/index // database overview, metrics
match3: app/databases/$databaseId/schemas // layout for schema sub-routes + sidebar (tree view)
match4: app/databases/$databaseId/schemas/$schemaId // preloads schema name for breadcrumb + context
match5: app/databases/$databaseId/schemas/$schemaId/folders/$folderId/tables/$tableId // preloads table name
match6: app/databases/$databaseId/schemas/$schemaId/folders/$folderId/tables/$tableId/index // browse rows
match7: app/databases/$databaseId/schemas/$schemaId/folders/$folderId/tables/$tableId/structure // edit structure
match0: app // welcome page
match1: app/databases/$databaseId // database details + beforeLoad (for breadcrumb + context)
match2: app/databases/$databaseId/index // database overview, metrics
match3: app/databases/$databaseId/schemas // layout for schema sub-routes + sidebar (tree view)
match4: app/databases/$databaseId/schemas/$schemaId // preloads schema name for breadcrumb + context
match5: app/databases/$databaseId/schemas/$schemaId/folders/$folderId/tables/$tableId // preloads table name
match6: app/databases/$databaseId/schemas/$schemaId/folders/$folderId/tables/$tableId/index // browse rows
match7: app/databases/$databaseId/schemas/$schemaId/folders/$folderId/tables/$tableId/structure // edit structure
The Problem When I'm at match7 (structure.tsx) and navigate to: Another table's structure (another match7) Same table's index (match6) Or even a hypothetical match8/9 (e.g., column details) ...the parent beforeLoad hooks should NOT re-run, because: I'm still under the same layout (match3: SchemaLayout) The sidebar remains visible and functional I'm still operating within the schemas/ route context Why beforeLayoutLoad Would Be Perfect If beforeLayoutLoad existed, its return value could be stored in something like beforeLayoutLoadContext. Then, during the context merge at the end of beforeLoad, the final context would be: {...beforeLayoutLoadContext,...beforeLoadContext } 1. Runs synchronously (can be awaited) before beforeLoad 2. Follows the same execution pattern as loader: - loader already behaves correctly here—it doesn't re-run when navigating between sub-paths under the same layout 3. But unlike loader: - It merges its return value into the route context (like beforeLoad does) Why loader Doesn't Solve This While loader has the right execution behavior (doesn't re-trigger under the same layout), it has two problems for my use case: 1. No sequential execution guarantee between loaders 2. Doesn't block rendering In my scenario: - The table instance object needs to be accessible in column routes - The database instance object needs to be accessible everywhere under the database path This forces me to use beforeLoad + context, but then I lose the layout-aware execution behavior. The cause === "enter" Workaround Falls Short After exploring the cause === "enter" approach ii mentioned, I discovered an issue: When navigating directly to a deep sub-route like:
app/databases/:databaseId/schemas/:schemaId/folders/:folderId/tables/$tableId/structure
app/databases/:databaseId/schemas/:schemaId/folders/:folderId/tables/$tableId/structure
Every parent beforeLoad runs (which is expected), BUT: All of them report cause="enter" Even though logically, only the first one truly "entered"—the rest are just "along the path" A potential improvement could be to return something like "stale-enter" for subsequent parent loads after the initial enter. But even then, this workaround increases complexity compared to a dedicated lifecycle method. Summary beforeLayoutLoad would: -Run only when entering a layout (not when navigating between its children) -Merge its return value into context (accessible by all children) -Execute synchronously, before beforeLoad -Simplify breadcrumb generation, parent data dependencies, and layout-scoped state I believe this would be an extremely valuable addition to TanStack Router's lifecycle methods. If it aligns with your roadmap, I'd be happy to help prototype or contribute a PR. @Manuel Schiller i hope i didnt make it so boring After re-reading my message, I realized something: cause="enter" is actually working correctly — it truly is an "enter" event. However, I'm confused about cause="stay". When I'm in the $tableId.tsx route and navigate to another table via the sidebar, it still says cause="stay" — even though the params have changed. I think what would make everything more consistent is if we could access what changed when params/search change. For example, having access to:
beforeLoad: ({ cause, params, search, previous }) => {
// previous: { params, search }
if (previous?.params.tableId !== params.tableId) {
// Table changed, need to refetch
}
}
beforeLoad: ({ cause, params, search, previous }) => {
// previous: { params, search }
if (previous?.params.tableId !== params.tableId) {
// Table changed, need to refetch
}
}
This would make it much clearer when we need to refetch data versus when we can safely skip. i noticed that : loader lifecycle is also being retriggered under same layout and this is completely unnecessary .
dependent-tan
dependent-tan•3w ago
what does this mean? a full reproducer project would help

Did you find this page helpful?