inertia-rails-pages

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Inertia Rails Pages

Inertia Rails 页面开发

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
    <Deferred>
    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
页面组件、布局、导航及客户端API使用指南。
在构建页面之前,请先思考:
  • 该页面是否需要布局? → 使用持久化布局(React:
    Page.layout = ...
    ;Vue:
    defineOptions({ layout })
    ;Svelte:模块脚本导出)—— 若在JSX/模板中直接包裹布局,每次导航都会重新挂载组件,导致滚动位置、音频播放状态及组件状态丢失
  • UI状态是否来自URL? → 同时修改控制器(读取
    params
    并作为props传递)和组件(从props派生状态,不使用
    useState
    /
    useEffect
    )—— 使用
    router.get
    更新URL
  • 无需导航即可刷新数据? → 使用
    router.reload({ only: [...] })
    —— 绝不要用
    useEffect
    +
    fetch
  • 无需服务器往返即可更新props? → 使用
    router.replaceProp
    —— 无需请求,无需重载
绝对禁止:
  • 解析
    window.location.search
    或使用
    useSearchParams
    —— 从控制器传递的props中派生URL状态
  • 使用
    useState
    /
    useEffect
    同步URL ↔ React状态 —— 控制器会将URL派生的数据作为props传递;组件只需直接读取即可
  • <Deferred>
    的渲染函数传递参数 ——
    {(data) => ...}
    无法生效;子组件需通过
    usePage()
    读取数据
  • 访问
    usePage().props.flash
    —— flash为顶层属性:
    usePage().flash
  • 为了持久化将布局包裹在JSX返回值中 —— 使用
    Page.layout = ...
    或在createInertiaApp的resolve回调中配置全局布局

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<T>()
, Svelte uses
let { ... } = $props()
.
tsx
type Props = {
  posts: Post[]
}

export default function Index({ posts }: Props) {
  return <PostList posts={posts} />
}
页面为默认导出的函数,接收控制器传递的props作为参数。 使用
type Props = { ... }
(不要用
interface
——会在React中引发TS2344错误)。Vue使用
defineProps<T>()
,Svelte使用
let { ... } = $props()
tsx
type Props = {
  posts: Post[]
}

export default function Index({ posts }: Props) {
  return <PostList posts={posts} />
}

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 <CourseContent course={course} />
}

// Single layout
Show.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>
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) => <AppLayout>{page}</AppLayout> // default if not set
  return page
}
布局会在导航过程中保持持久化——不会重新挂载,可保留滚动位置、音频状态等。
tsx
import { AppLayout } from '@/layouts/app-layout'

export default function Show({ course }: Props) {
  return <CourseContent course={course} />
}

// 单个布局
Show.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>
入口文件中的默认布局:
tsx
// app/frontend/entrypoints/inertia.tsx
resolve: async (name) => {
  const page = await pages[`../pages/${name}.tsx`]()
  page.default.layout ??= (page: React.ReactNode) => <AppLayout>{page}</AppLayout> // 未设置时使用默认布局
  return page
}

Navigation

导航

<Link>
and
router

<Link>
router

Use
<Link href="...">
for internal navigation (not
<a>
) and
router.get/post/patch/delete
for programmatic navigation. Key non-obvious features:
tsx
// Prefetching — preloads page data on hover
<Link href="/users" prefetch>Users</Link>
<Link href="/users" prefetch cacheFor="30s">Users</Link>

// Prefetch with cache tags — invalidate after mutations
<Link href="/users" prefetch cacheTags="users">Users</Link>

// 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.
内部导航使用
<Link href="...">
(不要用
<a>
),编程式导航使用
router.get/post/patch/delete
。 以下是一些容易被忽略的关键特性:
tsx
// 预加载 —— 鼠标悬停时预加载页面数据
<Link href="/users" prefetch>Users</Link>
<Link href="/users" prefetch cacheFor="30s">Users</Link>

// 带缓存标签的预加载 —— 数据变更后可失效缓存
<Link href="/users" prefetch cacheTags="users">Users</Link>

// 编程式预加载(例如,针对可能的下一个跳转目标)
router.prefetch('/settings', {}, { cacheFor: '1m' })

// 部分重载 —— 无需导航即可刷新指定props
router.reload({ only: ['users'] })
完整的
router
API、访问选项及事件回调请参考
references/navigation.md
——下方为加载触发相关内容。

Client-Side Prop Helpers

客户端Props辅助工具

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).
无需服务器往返即可更新props:
tsx
// 替换单个props(支持点符号)
router.replaceProp('show_modal', false)
router.replaceProp('user.name', 'Jane Smith')

// 结合回调函数(接收当前值及所有props)
router.replaceProp('count', (current) => current + 1)

// 向数组props中追加/前置元素
router.appendToProp('messages', { id: 4, text: 'New' })
router.prependToProp('notifications', (current, props) => ({
  id: Date.now(),
  message: `Hello ${props.auth.user.name}`,
}))
这些是
router.replace()
的快捷方式,会自动将
preserveScroll
preserveState
设为
true
router.replaceProp
vs
router.reload
仅客户端状态变更(如切换模态框、递增计数器)使用
router.replaceProp
——无需服务器往返。需要从服务器获取最新数据(如更新后的记录、重新计算的统计数据)时使用
router.reload

URL-Driven State (Dialogs, Tabs, Filters)

URL驱动状态(对话框、标签页、筛选器)

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
undefined
URL状态 = 服务器状态 = props。必须同时实现两端逻辑:
  1. 控制器 —— 读取
    params
    并作为props传递
  2. 组件 —— 从props派生UI状态(不使用
    useState
    ,不使用
    useEffect
  3. 更新 —— 使用
    router.get
    携带查询参数修改URL(触发服务器往返,获取新的props)
绝对禁止使用
useState
+
useEffect
同步URL ↔ 对话框/标签页/筛选器状态。 服务器为单一数据源——组件只需读取props即可。
ruby
undefined

Step 1: Controller reads params, passes as prop

步骤1:控制器读取params并作为props传递

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 (
    <Dialog open={!!selectedUser} onOpenChange={(open) => !open && closeDialog()}>
      <DialogContent>{/* ... */}</DialogContent>
    </Dialog>
  )
}
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.
def index render inertia: { users: User.all, selected_user_id: params[:user_id]&.to_i } end

```tsx
// 步骤2+3:从props派生状态,使用router.get更新URL

type Props = {
  users: User[]
  selected_user_id: number | null  // 来自控制器
}

export default function Index({ users, selected_user_id }: Props) {
  // 派生状态 —— 不使用useState,不使用useEffect,不解析window.location
  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 (
    <Dialog open={!!selectedUser} onOpenChange={(open) => !open && closeDialog()}>
      <DialogContent>{/* ... */}</DialogContent>
    </Dialog>
  )
}
为什么不使用useEffect?
router.get('/users', { user_id: 5 })
触发时,Inertia会向服务器发送请求 → 控制器使用
params[:user_id] = 5
执行 → 返回携带
selected_user_id: 5
的新props → 组件重新渲染并打开对话框。流程为:URL → 服务器 → props → 渲染。在客户端解析
window.location
会重复服务器已完成的工作。

Shared Props

共享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 <UserList users={users} />
}
共享props(auth、flash)通过InertiaConfig进行全局类型定义(参考
inertia-rails-typescript
技能)——页面组件只需定义自身的props:
tsx
type Props = {
  users: User[]         // 仅页面专属props
  // auth无需在此定义——通过InertiaConfig全局类型定义
}

export default function Index({ users }: Props) {
  const { props, flash } = usePage()
  // props.auth通过InertiaConfig全局类型定义,flash.notice通过InertiaConfig全局类型定义
  return <UserList users={users} />
}

Flash Access

Flash访问方式

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
Flash是page对象的顶层属性,不在props内部——这是使用flash时最常见的错误。Flash配置请参考
inertia-rails-controllers
;Toast UI请参考
shadcn-inertia
tsx
// 错误写法:usePage().props.flash   ← 错误,flash不在props中
// 正确写法:usePage().flash         ← flash.notice、flash.alert

<Deferred>
Component

<Deferred>
组件

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 (
    <>
      <QuickStats data={basic_stats} />
      <Deferred data="detailed_stats" fallback={<Spinner />}>
        <DetailedStats />
      </Deferred>
    </>
  )
}

// Also valid — render function (no args, child still reads from usePage):
// <Deferred data="stats" fallback={<Spinner />}>
//   {() => <Stats />}
// </Deferred>

// BAD — render function does NOT receive data as argument:
// <Deferred data="stats">{(data) => <Stats data={data} />}</Deferred>
在延迟props加载完成前渲染 fallback 内容。子组件可以是普通
ReactNode
() => ReactNode
渲染函数。无论哪种方式,子组件都需通过
usePage()
从页面props中读取延迟加载的props——渲染函数不接收任何参数
tsx
import { Deferred } from '@inertiajs/react'

export default function Dashboard({ basic_stats }: Props) {
  return (
    <>
      <QuickStats data={basic_stats} />
      <Deferred data="detailed_stats" fallback={<Spinner />}>
        <DetailedStats />
      </Deferred>
    </>
  )
}

// 同样有效——渲染函数(无参数,子组件仍从usePage()读取数据):
// <Deferred data="stats" fallback={<Spinner />}>
//   {() => <Stats />}
// </Deferred>

// 错误写法——渲染函数不接收data作为参数:
// <Deferred data="stats">{(data) => <Stats data={data} />}</Deferred>

<InfiniteScroll>
Component

<InfiniteScroll>
组件

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 (
    <InfiniteScroll data="posts" loading={() => <Spinner />}>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </InfiniteScroll>
  )
}
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).
自动实现无限滚动——用户向下滚动时加载下一页。需与服务器端的
InertiaRails.scroll
配合使用(参考
inertia-rails-controllers
):
tsx
import { InfiniteScroll } from '@inertiajs/react'

export default function Index({ posts }: Props) {
  return (
    <InfiniteScroll data="posts" loading={() => <Spinner />}>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </InfiniteScroll>
  )
}
Props参数:
data
(props名称)、
loading
(fallback内容)、
manual
(使用按钮触发而非自动加载)、
manualAfter={3}
(前3页自动加载,之后使用按钮)、
preserveUrl
(不更新URL)。

<WhenVisible>
Component

<WhenVisible>
组件

Loads data when element enters viewport. Use for lazy sections (comments, related items), NOT for infinite scroll (use
<InfiniteScroll>
above):
tsx
import { WhenVisible } from '@inertiajs/react'

<WhenVisible data="comments" fallback={<Spinner />}>
  <CommentsList />
</WhenVisible>
当元素进入视口时加载数据。适用于懒加载区块(评论、相关内容),不适用于无限滚动(请使用上述
<InfiniteScroll>
):
tsx
import { WhenVisible } from '@inertiajs/react'

<WhenVisible data="comments" fallback={<Spinner />}>
  <CommentsList />
</WhenVisible>

Troubleshooting

故障排查

SymptomCauseFix
Layout remounts on every navigationWrapping 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
{() => <Child />}
or plain
<Child />
. 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 navigationParsing
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 submitMissing
preserveScroll
Add
preserveScroll: true
to the visit or form options
症状原因修复方案
每次导航时布局都会重新挂载将布局包裹在JSX返回值中而非使用
Page.layout
使用持久化布局
Deferred
子组件始终不渲染
渲染函数期望接收参数
{(data) => ...}
渲染函数不接收任何参数——使用
{() => <Child />}
或普通
<Child />
。子组件通过
usePage()
读取props
Flash为
undefined
访问
usePage().props.flash
Flash为顶层属性:
usePage().flash
,而非在
props
内部
导航时URL状态丢失在useEffect中解析
window.location
从props派生状态——控制器读取
params
并作为props传递
WhenVisible
始终不触发
元素未进入视口或props名称错误
data
必须与控制器在部分重载时提供的props名称匹配
router.get
触发后组件状态重置
缺少
preserveState: true
在筛选/排序/标签页切换的访问选项中添加
preserveState: true
表单提交后滚动位置跳至顶部缺少
preserveScroll
在访问或表单选项中添加
preserveScroll: true

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)
  • Flash配置
    inertia-rails-controllers
    (flash_keys)
  • Flash提示UI
    shadcn-inertia
    (Sonner + useFlash)
  • 共享Props类型定义
    inertia-rails-typescript
    (InertiaConfig)
  • 服务端延迟加载
    inertia-rails-controllers
    (InertiaRails.defer)
  • URL驱动对话框
    shadcn-inertia
    (Dialog组件)

Vue / Svelte

Vue / Svelte

All examples above use React syntax. For Vue 3 or Svelte equivalents:
  • Vue 3:
    references/vue.md
    defineProps
    ,
    usePage()
    composable, scoped slots for
    <Deferred>
    /
    <WhenVisible>
    /
    <InfiniteScroll>
    ,
    defineOptions({ layout })
    for persistent layouts
  • Svelte:
    references/svelte.md
    $props()
    ,
    $page
    store,
    {#snippet}
    syntax for
    <Deferred>
    /
    <WhenVisible>
    /
    <InfiniteScroll>
    ,
    <svelte:head>
    instead of
    <Head>
    , 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.
上述示例均使用React语法。如需Vue 3或Svelte的等效实现:
  • Vue 3
    references/vue.md
    ——
    defineProps
    usePage()
    组合式API、
    <Deferred>
    /
    <WhenVisible>
    /
    <InfiniteScroll>
    的作用域插槽、
    defineOptions({ layout })
    定义持久化布局
  • Svelte
    references/svelte.md
    ——
    $props()
    $page
    存储、
    <Deferred>
    /
    <WhenVisible>
    /
    <InfiniteScroll>
    {#snippet}
    语法、使用
    <svelte:head>
    替代
    <Head>
    、模块脚本导出布局
强制要求——若项目使用Vue或Svelte,请阅读对应文档。上述概念和禁止规则适用于所有框架,但代码语法有所不同。

References

参考文档

MANDATORY — READ ENTIRE FILE when implementing event callbacks (
onBefore
,
onStart
,
onProgress
,
onFinish
,
onCancel
), client-side flash, or scroll management:
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
(~180 lines) — nested layout patterns, layout props, and default layout configuration.
Do NOT load references for basic
<Link>
,
router.visit
, or single-level layout usage — the examples above are sufficient.
强制要求——实现事件回调(
onBefore
onStart
onProgress
onFinish
onCancel
)、客户端flash或滚动管理时,请完整阅读该文档:
references/navigation.md
(约200行)——完整的回调API、
router.flash()
、滚动区域及历史记录加密。
强制要求——实现嵌套布局、条件布局或布局级数据共享时,请完整阅读该文档:
references/layouts.md
(约180行)——嵌套布局模式、布局props及默认布局配置。
基础使用场景(如
<Link>
router.visit
、单层布局)无需阅读参考文档
——上述示例已足够覆盖。