Outlets, slots, and lazy views
Exact component contracts for nested branch output, outlet context, named slots, slot-local error isolation, and preloadable lazy route views.
Outlets and slots render output that the core router already resolved.
React does not discover child routes, match slot routes, or decide interception. The router does that before rendering. The React layer selects the resolved output, installs context providers, and isolates render errors when asked.
The outlets subpath exports:
import {
Outlet,
Slot,
} from '@cookbook/router-react/outlets';
import type {
OutletProps,
SlotErrorFallback,
SlotErrorFallbackProps,
SlotProps,
} from '@cookbook/router-react/outlets';lazyRouteView() is grouped on this page because it is used by route views, but it is exported from the package root:
import {
lazyRouteView,
type LazyRouteViewComponent,
} from '@cookbook/router-react';Outlet
interface OutletProps<T = unknown> {
readonly context?: T;
readonly children?: ReactNode;
}
function Outlet<T = unknown>(
props: OutletProps<T>,
): ReactElement | null;Outlet renders the next child output supplied by the current route-render boundary.
Prop
Type
Basic layout outlet:
import { Outlet } from '@cookbook/router-react/outlets';
export function AccountLayout() {
return (
<section>
<h1>Account</h1>
<Outlet context={{ organizationId: 'acme' }} />
</section>
);
}Descendant routes read that context with useOutletContext():
import { useOutletContext } from '@cookbook/router-react/hooks';
interface AccountContext {
readonly organizationId: string;
}
export function BillingPage() {
const context = useOutletContext<AccountContext>({
strict: true,
});
return (
<h2>
Billing for {context.organizationId}
</h2>
);
}Outlet render selection
Outlet selects output in this order:
props.childrenwhen it is notnullorundefined;- router-provided outlet output from the current route-render context;
null.
Then it renders nothing when the selected output is:
null
undefined
falseThis distinction matters:
<Outlet>
<p>Custom outlet content</p>
</Outlet>Custom children replace the router-provided outlet.
<Outlet>{false}</Outlet>false replaces the router-provided outlet and renders nothing.
<Outlet>{null}</Outlet>null is treated as absent children, so the router-provided outlet is still used when one exists.
Leaf routes can render <Outlet />; it resolves to null instead of recursively rendering the same route.
Slot
interface SlotErrorFallbackProps {
readonly error: unknown;
readonly reset: () => void;
}
type SlotErrorFallback =
| ComponentType<SlotErrorFallbackProps>
| null;
interface SlotProps<T = unknown> {
readonly name: string;
readonly context?: T;
readonly errorFallback?: SlotErrorFallback;
}
function Slot<T = unknown>(
props: SlotProps<T>,
): ReactElement | null;Slot selects output already resolved for the named slot on the nearest active layout owner.
Prop
Type
Layout with named slots:
import {
Outlet,
Slot,
} from '@cookbook/router-react/outlets';
export function DashboardLayout() {
return (
<section>
<aside>
<Slot
name="sidebar"
context={{ source: 'dashboard-sidebar' }}
/>
</aside>
<main>
<Outlet />
</main>
<Slot name="modal" />
</section>
);
}Slot content reads the nearest outlet context:
import { useOutletContext } from '@cookbook/router-react/hooks';
interface SidebarContext {
readonly source: string;
}
export function SidebarPanel() {
const context = useOutletContext<SidebarContext>({
strict: true,
});
return <p>{context.source}</p>;
}Slot render selection
Slot returns null when:
- it is rendered outside a layout slot context;
- the named slot does not exist on the nearest layout owner;
- the named slot exists but has no rendered output;
- a declaration-only slot has no active slot route or fallback output.
It does not search the whole route tree for a matching name. Same-name nested slots are scoped to their layout owner.
Slot is not a global portal registry. Core route traversal owns slot matching, fallback selection, and interception. React only selects and renders the resolved slot output.
Slot context and intercepted context
Slot context is provided around the selected slot output.
Intercepted navigation can also provide context:
<Link
to="documents.details"
params={{ documentId }}
intercept={{
slot: 'modal',
view: DocumentPreview,
}}
context={{ source: 'documents-list' }}
>
Preview document
</Link>When intercepted route rendering provides its own context, that context can be closer to the intercepted view than the slot context. The intercepted navigation context wins for components rendered inside the intercepted view.
That is intentional. Call-site context belongs to the navigation that opened the intercepted route.
Slot error ownership
Slot error ownership is presence-based.
errorFallback prop | Behavior |
|---|---|
| omitted | Render errors bubble to the nearest route, layout, or provider boundary. |
| component | Render errors are isolated to the slot and rendered by the component. |
null | Render errors are isolated to the slot and render nothing. |
explicit undefined | Prop is present, so errors are isolated and render nothing. |
Inline fallback:
import { Slot } from '@cookbook/router-react/outlets';
export function DashboardLayout() {
return (
<Slot
name="modal"
errorFallback={({ error, reset }) => (
<section role="alert">
<h2>Modal failed</h2>
<pre>{String(error)}</pre>
<button onClick={reset}>
Try again
</button>
</section>
)}
/>
);
}Component fallback:
import type {
SlotErrorFallbackProps,
} from '@cookbook/router-react/outlets';
import { Slot } from '@cookbook/router-react/outlets';
function ModalErrorFallback({
error,
reset,
}: SlotErrorFallbackProps) {
return (
<section role="alert">
<h2>Modal failed</h2>
<pre>{String(error)}</pre>
<button onClick={reset}>Reset</button>
</section>
);
}
export function DashboardLayout() {
return (
<Slot
name="modal"
errorFallback={ModalErrorFallback}
/>
);
}Render nothing on slot failure:
<Slot name="modal" errorFallback={null} />Omit the prop to let errors bubble:
<Slot name="modal" />The local slot boundary resets when:
- its
reset()callback is called; - the rendered slot children change.
Slot error isolation intentionally beats route-owned slot error rendering. When <Slot errorFallback> is present, render errors inside that selected slot output belong to the slot boundary.
SlotErrorFallbackProps
interface SlotErrorFallbackProps {
readonly error: unknown;
readonly reset: () => void;
}Prop
Type
SlotErrorFallbackProps is intentionally smaller than route error fallback props. Slot fallbacks do not receive route.
Route boundary fallback:
interface RouteErrorFallbackProps {
readonly error: unknown;
readonly reset: () => void;
readonly route: MatchedRoute;
}Slot boundary fallback:
interface SlotErrorFallbackProps {
readonly error: unknown;
readonly reset: () => void;
}If the fallback needs route data, read it from route hooks while the route context is still available. Do not pretend the slot fallback contract includes it.
lazyRouteView()
interface LazyRouteViewComponent<
Component extends ComponentType<any> =
ComponentType<any>,
> extends LazyExoticComponent<Component> {
readonly preload: () => Promise<{
readonly default: Component;
}>;
}
function lazyRouteView<
Component extends ComponentType<any>,
>(
load: () => Promise<{
readonly default: Component;
}>,
): LazyRouteViewComponent<Component>;lazyRouteView() returns a React lazy component with a router-visible preload() method.
Prop
Type
Prop
Type
Use it for lazy route views:
import { defineRoute } from '@cookbook/router';
import { lazyRouteView } from '@cookbook/router-react';
const SettingsPage = lazyRouteView(() =>
import('./settings-page'),
);
export const settingsRoute = defineRoute({
id: 'settings',
path: '/settings',
view: SettingsPage,
} as const);For named exports:
import { defineRoute } from '@cookbook/router';
import { lazyRouteView } from '@cookbook/router-react';
const UsersPage = lazyRouteView(() =>
import('./users-page').then(({ UsersPage }) => ({
default: UsersPage,
})),
);
export const usersRoute = defineRoute({
id: 'users',
path: '/users',
view: UsersPage,
} as const);Do not add modulePreload: SettingsPage.preload just because the method exists. Route preloading already detects view.preload on lazy route views.
Wrong:
export const settingsRoute = defineRoute({
id: 'settings',
path: '/settings',
view: SettingsPage,
modulePreload: SettingsPage.preload,
} as const);Right:
export const settingsRoute = defineRoute({
id: 'settings',
path: '/settings',
view: SettingsPage,
} as const);Route preloading runs view preload hooks for matched layout and route views.
<Link to="settings" prefetch="interaction">
Settings
</Link>That can warm the lazy route view before navigation.
Lazy view memoization
The loader promise is memoized.
These share the same promise:
SettingsPage.preload();- route preloading;
- link prefetching;
- first React render through
React.lazy().
The loader is called once for the lifetime of the returned lazy component.
const SettingsPage = lazyRouteView(() =>
import('./settings-page'),
);
await SettingsPage.preload();
await SettingsPage.preload();Both calls use the same promise.
If that promise rejects, the rejected promise is also retained. Create a new lazy component or reload the module if you need a fresh import attempt.
Lazy view property shape
preload is attached with:
Object.defineProperty(Component, 'preload', {
enumerable: false,
configurable: false,
writable: false,
});That means:
- it does not appear during ordinary property enumeration;
- it cannot be reassigned;
- it cannot be reconfigured.
The module must resolve to:
{
readonly default: Component;
}That is the same shape expected by React.lazy().
Public context values
The public context values are exported from the provider surface, not the outlets subpath:
import {
OutletContext,
RouteRenderContext,
SlotRenderContext,
} from '@cookbook/router-react/provider';They are also re-exported by the package root.
These contexts exist for adapters and design-system integrations. Application routes should prefer:
Outlet
Slot
useOutletContext()
useParams()
useRouteMeta()Do not manually construct render contexts in application code. If a component needs to render child routes, render <Outlet />. If it needs named slot output, render <Slot name="..." />.
Export inventory
@cookbook/router-react/outlets exports these values:
Outlet
SlotIt exports these public types:
OutletProps
SlotErrorFallback
SlotErrorFallbackProps
SlotPropsThe package root additionally exports:
lazyRouteView
LazyRouteViewComponentThere is no public @cookbook/router-react/lazy subpath.
Where this bites
Outlet null children do not erase the router outlet
This does not suppress the router-provided outlet:
<Outlet>{null}</Outlet>null is treated as absent children. Use false when you intentionally want to suppress outlet output:
<Outlet>{false}</Outlet>Slot configuration fallback is not <Slot errorFallback>
A slot fallback in route configuration supplies route content when no slot route matches.
<Slot errorFallback> catches React render failures inside selected slot output.
They solve different problems.
errorFallback={undefined} still isolates the slot
This isolates errors and renders nothing:
<Slot name="modal" errorFallback={undefined} />The prop is present. Presence enables slot-local isolation.
Omit the prop when errors should bubble:
<Slot name="modal" />lazyRouteView() is root-exported
This import is wrong:
import { lazyRouteView } from '@cookbook/router-react/outlets';Use the package root:
import { lazyRouteView } from '@cookbook/router-react';modulePreload is not needed for lazyRouteView()
lazyRouteView() attaches preload to the view. The router discovers it during route preloading.
Adding the same method as modulePreload is redundant at best and misleading at worst. Use modulePreload for route-module preload integration, not for ordinary React lazy route views.