--- name: inertia-rails-pages description: >- Page components, persistent layouts, Link/router navigation, Head, Deferred, WhenVisible, InfiniteScroll, and URL-driven state for Inertia Rails. React examples inline; Vue and Svelte equivalents in references. Use when building pages, adding navigation, implementing persistent layouts, infinite scroll, lazy-loaded sections, or working with client-side Inertia APIs (router.reload, router.replaceProp, prefetching). --- # Inertia Rails Pages Page components, layouts, navigation, and client-side APIs. **Before building a page, ask:** - **Does this page need a layout?** → Use persistent layout (React: `Page.layout = ...`; Vue: `defineOptions({ layout })`; Svelte: module script export) — wrapping in JSX/template remounts on every navigation, losing scroll position, audio playback, and component state - **Does UI state come from the URL?** → Change BOTH controller (read `params`, pass as prop) AND component (derive from prop, no `useState`/`useEffect`) — use `router.get` to update URL - **Need to refresh data without navigation?** → `router.reload({ only: [...] })` — never `useEffect` + `fetch` - **Need to update a prop without server round-trip?** → `router.replaceProp` — no fetch, no reload **NEVER:** - Parse `window.location.search` or use `useSearchParams` — derive URL state from controller props - Use `useState`/`useEffect` to sync URL ↔ React state — the controller passes URL-derived data as props; the component just reads them - Pass arguments to `` render function — `{(data) => ...}` does NOT work; child reads via `usePage()` - Access `usePage().props.flash` — flash is top-level: `usePage().flash` - Wrap layout in JSX return for persistence — use `Page.layout = ...` or global layout inside createInertiaApp's resolve callback ## Page Component Structure Pages are default exports receiving controller props as function arguments. Use `type Props = { ... }` (not `interface` — causes TS2344 in React). Vue uses `defineProps()`, Svelte uses `let { ... } = $props()`. ```tsx type Props = { posts: Post[] } export default function Index({ posts }: Props) { return } ``` ## Persistent Layouts Layouts persist across navigations — no remounting, preserving scroll, audio, etc. ```tsx import { AppLayout } from '@/layouts/app-layout' export default function Show({ course }: Props) { return } // Single layout Show.layout = (page: React.ReactNode) => {page} ``` Default layout in entrypoint: ```tsx // app/frontend/entrypoints/inertia.tsx resolve: async (name) => { const page = await pages[`../pages/${name}.tsx`]() page.default.layout ??= (page: React.ReactNode) => {page} // default if not set return page } ``` ## Navigation ### `` and `router` Use `` for internal navigation (not ``) and `router.get/post/patch/delete` for programmatic navigation. Key non-obvious features: ```tsx // Prefetching — preloads page data on hover Users Users // Prefetch with cache tags — invalidate after mutations Users // Programmatic prefetch (e.g., likely next destination) router.prefetch('/settings', {}, { cacheFor: '1m' }) // Partial reload — refresh specific props without navigation router.reload({ only: ['users'] }) ``` Full `router` API, visit options, and event callbacks are in `references/navigation.md` — see loading trigger below. ### Client-Side Prop Helpers Update props without a server round-trip: ```tsx // Replace a single prop (dot notation supported) router.replaceProp('show_modal', false) router.replaceProp('user.name', 'Jane Smith') // With callback (receives current value + all props) router.replaceProp('count', (current) => current + 1) // Append/prepend to array props router.appendToProp('messages', { id: 4, text: 'New' }) router.prependToProp('notifications', (current, props) => ({ id: Date.now(), message: `Hello ${props.auth.user.name}`, })) ``` These are shortcuts to `router.replace()` with `preserveScroll` and `preserveState` automatically set to `true`. **`router.replaceProp` vs `router.reload`:** Use `router.replaceProp` for client-only state changes (toggling a modal, incrementing a counter) — no server round-trip. Use `router.reload` when you need fresh data from the server (updated records, recalculated stats). ## URL-Driven State (Dialogs, Tabs, Filters) URL state = server state = props. **ALWAYS implement both sides:** 1. **Controller** — read `params` and pass as a prop 2. **Component** — derive UI state from that prop (no `useState`, no `useEffect`) 3. **Update** — `router.get` with query params to change URL (triggers server round-trip, new props arrive) **NEVER** use `useState` + `useEffect` to sync URL ↔ dialog/tab/filter state. The server is the single source of truth — the component just reads props. ```ruby # Step 1: Controller reads params, passes as prop def index render inertia: { users: User.all, selected_user_id: params[:user_id]&.to_i } end ``` ```tsx // Step 2+3: Derive state from props, router.get to update URL type Props = { users: User[] selected_user_id: number | null // from controller } export default function Index({ users, selected_user_id }: Props) { // Derive — no useState, no useEffect, no window.location parsing const selectedUser = selected_user_id ? users.find(u => u.id === selected_user_id) : null const openDialog = (id: number) => router.get('/users', { user_id: id }, { preserveState: true, preserveScroll: true, }) const closeDialog = () => router.get('/users', {}, { preserveState: true, preserveScroll: true, }) return ( !open && closeDialog()}> {/* ... */} ) } ``` **Why not useEffect?** When `router.get('/users', { user_id: 5 })` fires, Inertia makes a request to the server → controller runs with `params[:user_id] = 5` → returns new props with `selected_user_id: 5` → component re-renders with the dialog open. The cycle is: URL → server → props → render. Parsing `window.location` client-side duplicates what the server already does. ## Shared Props Shared props (auth, flash) are typed globally via InertiaConfig (see `inertia-rails-typescript` skill) — page components only type their OWN props: ```tsx type Props = { users: User[] // page-specific only // auth is NOT here — typed globally via InertiaConfig } export default function Index({ users }: Props) { const { props, flash } = usePage() // props.auth typed via InertiaConfig, flash.notice typed via InertiaConfig return } ``` ## Flash Access **Flash is top-level on the page object, NOT inside props** — this is the #1 flash mistake. Flash config is in `inertia-rails-controllers`; toast UI is in `shadcn-inertia`. ```tsx // BAD: usePage().props.flash ← WRONG, flash is not in props // GOOD: usePage().flash ← flash.notice, flash.alert ``` ## `` Component Renders fallback until deferred props arrive. Children can be plain `ReactNode` or `() => ReactNode` render function. Either way, the child reads the deferred prop from page props via `usePage()` — the render function receives **no arguments**. ```tsx import { Deferred } from '@inertiajs/react' export default function Dashboard({ basic_stats }: Props) { return ( <> }> ) } // Also valid — render function (no args, child still reads from usePage): // }> // {() => } // // BAD — render function does NOT receive data as argument: // {(data) => } ``` ## `` Component Automatic infinite scroll — loads next pages as user scrolls down. Pairs with `InertiaRails.scroll` on the server (see `inertia-rails-controllers`): ```tsx import { InfiniteScroll } from '@inertiajs/react' export default function Index({ posts }: Props) { return ( }> {posts.map(post => )} ) } ``` Props: `data` (prop name), `loading` (fallback), `manual` (button instead of auto), `manualAfter={3}` (auto for first 3 pages, then button), `preserveUrl` (don't update URL). ## `` Component Loads data when element enters viewport. Use for **lazy sections** (comments, related items), NOT for infinite scroll (use `` above): ```tsx import { WhenVisible } from '@inertiajs/react' }> ``` ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| | Layout remounts on every navigation | Wrapping layout in JSX return instead of `Page.layout` | Use persistent layout | | `Deferred` children never render | Render function expects args `{(data) => ...}` | Render function receives NO arguments — use `{() => }` or plain ``. Child reads prop via `usePage()` | | Flash is `undefined` | Accessing `usePage().props.flash` | Flash is top-level: `usePage().flash`, not inside `props` | | URL state lost on navigation | Parsing `window.location` in useEffect | Derive from props — controller reads `params` and passes as prop | | `WhenVisible` never triggers | Element not in viewport or prop name wrong | `data` must match a prop name the controller provides on partial reload | | Component state resets on `router.get` | Missing `preserveState: true` | Add `preserveState: true` to visit options for filter/sort/tab changes | | Scroll jumps to top after form submit | Missing `preserveScroll` | Add `preserveScroll: true` to the visit or form options | ## Related Skills - **Flash config** → `inertia-rails-controllers` (flash_keys) - **Flash toast UI** → `shadcn-inertia` (Sonner + useFlash) - **Shared props typing** → `inertia-rails-typescript` (InertiaConfig) - **Deferred server-side** → `inertia-rails-controllers` (InertiaRails.defer) - **URL-driven dialogs** → `shadcn-inertia` (Dialog component) ## Vue / Svelte All examples above use React syntax. For Vue 3 or Svelte equivalents: - **Vue 3**: [`references/vue.md`](references/vue.md) — `defineProps`, `usePage()` composable, scoped slots for ``/``/``, `defineOptions({ layout })` for persistent layouts - **Svelte**: [`references/svelte.md`](references/svelte.md) — `$props()`, `$page` store, `{#snippet}` syntax for ``/``/``, `` instead of ``, module script layout export **MANDATORY — READ THE MATCHING FILE** when the project uses Vue or Svelte. The concepts and NEVER rules above apply to all frameworks, but code syntax differs. ## References **MANDATORY — READ ENTIRE FILE** when implementing event callbacks (`onBefore`, `onStart`, `onProgress`, `onFinish`, `onCancel`), client-side flash, or scroll management: [`references/navigation.md`](references/navigation.md) (~200 lines) — full callback API, `router.flash()`, scroll regions, and history encryption. **MANDATORY — READ ENTIRE FILE** when implementing nested layouts, conditional layouts, or layout-level data sharing: [`references/layouts.md`](references/layouts.md) (~180 lines) — nested layout patterns, layout props, and default layout configuration. **Do NOT load** references for basic ``, `router.visit`, or single-level layout usage — the examples above are sufficient.