Skip to content

Merge Props & Infinite Scroll

Merge props allow you to append new data to existing page props instead of replacing them. This is essential for building infinite scroll, load more buttons, and other pagination patterns that accumulate data.

By default, Inertia replaces all props on navigation:

// Page 1: cats = [cat1, cat2, cat3]
// Click "Load More" → Page 2
// Page 2: cats = [cat4, cat5, cat6] // Previous cats are gone!

Users expect “Load More” to add items while keeping existing ones visible.

With merge props, new data is appended to existing arrays:

// Page 1: cats = [cat1, cat2, cat3]
// Click "Load More" → Page 2
// Page 2: cats = [cat1, cat2, cat3, cat4, cat5, cat6] // All cats preserved!

Use the merge_props parameter to specify which props should be merged:

@app.get("/browse")
async def browse_items(
inertia: InertiaDep,
page: int = Query(1, ge=1),
):
items = get_items(page=page, per_page=10)
return inertia.render(
"Browse",
{
"items": items,
"page": page,
"has_more": page < total_pages,
},
merge_props=["items"], # Merge items array instead of replacing
)

For nested data structures, use dot notation:

return inertia.render(
"Browse",
{
"data": {
"items": paginated_items,
"metadata": {...}
},
"page": page,
},
merge_props=["data.items"], # Merge nested array
)

Use match_props_on to prevent duplicate items when the same page is loaded twice:

return inertia.render(
"Browse",
{
"cats": {
"data": paginated_cats,
},
"page": page,
"has_more": has_more,
},
merge_props=["cats.data"],
match_props_on=["cats.data.id"], # Match on ID to prevent duplicates
)

Use scroll_props to configure pagination behavior for Inertia’s scroll restoration:

@app.get("/browse")
async def browse_cats(
inertia: InertiaDep,
page: int = Query(1, ge=1),
):
paginated = paginate_cats(page=page, per_page=6)
previous_page = page - 1 if page > 1 else None
next_page = page + 1 if page < paginated["total_pages"] else None
return inertia.render(
"Browse",
{
"cats": {
"data": paginated["cats"],
},
"page": page,
"has_more": page < paginated["total_pages"],
},
merge_props=["cats.data"],
match_props_on=["cats.data.id"],
scroll_props={
"cats": {
"pageName": "page",
"previousPage": previous_page,
"nextPage": next_page,
"currentPage": page,
}
},
)
import { router } from '@inertiajs/react'
interface BrowseProps {
cats: { data: Cat[] }
page: number
has_more: boolean
}
export default function Browse({ cats, page, has_more }: BrowseProps) {
const handleLoadMore = () => {
router.visit(`/browse?page=${page + 1}`, {
preserveScroll: true,
preserveState: true,
only: ['cats', 'page', 'has_more'], // Only fetch pagination props
})
}
return (
<div>
<div className="grid grid-cols-3 gap-4">
{cats.data.map((cat) => (
<CatCard key={cat.id} cat={cat} />
))}
</div>
{has_more && (
<button onClick={handleLoadMore}>
Load More
</button>
)}
</div>
)
}
  • preserveScroll: true - Keeps the user’s scroll position
  • preserveState: true - Preserves component state (form inputs, etc.)
  • only: [...] - Only fetch the props needed for pagination (performance optimization)

Here’s a full implementation from our demo app:

from fastapi import FastAPI, Query
from inertia.fastapi import InertiaDep
@app.get("/browse")
async def browse_cats(
inertia: InertiaDep,
page: int = Query(1, ge=1),
breed: str | None = None,
):
# Apply filters and pagination
filtered_cats = filter_cats(breed=breed)
paginated = paginate_cats(filtered_cats, page=page, per_page=6)
# Calculate pagination info
previous_page = page - 1 if page > 1 else None
next_page = page + 1 if page < paginated["total_pages"] else None
return inertia.render(
"Browse",
{
"title": "Browse Cats",
"cats": {
"data": paginated["cats"],
},
"total": paginated["total"],
"page": paginated["page"],
"has_more": page < paginated["total_pages"],
"filters": {"breed": breed},
},
# Merge props configuration
merge_props=["cats.data"],
match_props_on=["cats.data.id"],
scroll_props={
"cats": {
"pageName": "page",
"previousPage": previous_page,
"nextPage": next_page,
"currentPage": page,
}
},
)
import { router } from '@inertiajs/react'
interface Cat {
id: number
name: string
breed: string
photo: string
}
interface BrowseProps {
title: string
cats: { data: Cat[] }
total: number
page: number
has_more: boolean
filters: { breed: string | null }
}
export default function Browse({ title, cats, total, page, has_more, filters }: BrowseProps) {
const handleLoadMore = () => {
const params = new URLSearchParams()
params.set('page', (page + 1).toString())
if (filters.breed) params.set('breed', filters.breed)
router.visit(`/browse?${params.toString()}`, {
preserveScroll: true,
preserveState: true,
only: ['cats', 'page', 'has_more'],
})
}
return (
<div>
<h1>{title}</h1>
<p>Showing {cats.data.length} of {total} cats</p>
<div className="grid grid-cols-3 gap-6">
{cats.data.map((cat) => (
<div key={cat.id} className="card">
<img src={cat.photo} alt={cat.name} />
<h3>{cat.name}</h3>
<p>{cat.breed}</p>
</div>
))}
</div>
{has_more && (
<div className="text-center mt-8">
<button
onClick={handleLoadMore}
className="btn btn-primary"
>
Load More Cats
</button>
</div>
)}
</div>
)
}

When filters change, you typically want to replace the data rather than merge it. Use the reset option on the frontend to clear existing data before loading new results.

Without reset, changing filters causes new results to be appended to existing data:

// User has loaded 12 cats (2 pages)
// User changes breed filter to "Maine Coon"
// Without reset: 12 old cats + 1 Maine Coon = 13 cats shown (wrong!)
// With reset: Just 1 Maine Coon shown (correct!)

Use the reset option when filters change:

const handleFilterChange = (newBreed: string | null) => {
const params = new URLSearchParams()
params.set('page', '1') // Start from page 1
if (newBreed) params.set('breed', newBreed)
router.visit(`/browse?${params.toString()}`, {
preserveScroll: false, // Scroll to top when filters change
preserveState: false,
// Reset cats data when filters change
// This sends X-Inertia-Reset header to clear existing data
reset: ['cats'],
// Request all props needed (reset causes partial reload)
only: ['cats', 'total', 'page', 'has_more', 'filters'],
})
}

When you use reset: ['cats']:

  1. The Inertia client sends the X-Inertia-Reset: cats header
  2. The server excludes cats from mergeProps (so data replaces instead of merges)
  3. The server includes resetProps: ['cats'] in the response
  4. The client clears the local cats state before applying new data
export default function Browse({ cats, page, has_more, filters }: BrowseProps) {
// Load more - MERGES data
const handleLoadMore = () => {
const params = new URLSearchParams()
params.set('page', (page + 1).toString())
if (filters.breed) params.set('breed', filters.breed)
router.visit(`/browse?${params.toString()}`, {
preserveScroll: true,
preserveState: true,
only: ['cats', 'page', 'has_more'],
})
}
// Filter change - RESETS data
const handleFilterChange = (breed: string | null) => {
const params = new URLSearchParams()
params.set('page', '1')
if (breed) params.set('breed', breed)
router.visit(`/browse?${params.toString()}`, {
preserveScroll: false,
preserveState: false,
reset: ['cats'],
only: ['cats', 'total', 'page', 'has_more', 'filters'],
})
}
return (
<div>
{/* Filter dropdown */}
<select
value={filters.breed || ''}
onChange={(e) => handleFilterChange(e.target.value || null)}
>
<option value="">All Breeds</option>
<option value="Maine Coon">Maine Coon</option>
<option value="Siamese">Siamese</option>
</select>
{/* Cat grid */}
<div className="grid grid-cols-3 gap-4">
{cats.data.map((cat) => (
<CatCard key={cat.id} cat={cat} />
))}
</div>
{/* Load more button */}
{has_more && (
<button onClick={handleLoadMore}>Load More</button>
)}
</div>
)
}

The backend automatically handles the reset header. When X-Inertia-Reset: cats is received:

  • cats (and nested paths like cats.data) are excluded from mergeProps
  • The response includes resetProps: ['cats'] for the client

No backend code changes are required—the existing merge_props configuration works automatically with reset.

Use prepend_props to add new items at the beginning instead of the end:

return inertia.render(
"Feed",
{"posts": new_posts},
prepend_props=["posts"], # New posts appear at the top
)

Use deep_merge_props for deeply nested objects that should be merged recursively:

return inertia.render(
"Dashboard",
{
"stats": {
"daily": {...},
"weekly": {...},
}
},
deep_merge_props=["stats"], # Recursively merge nested objects
)
ParameterTypeDescription
merge_propslist[str]Props to append (arrays are concatenated)
prepend_propslist[str]Props to prepend (new items first)
deep_merge_propslist[str]Props to recursively merge (for objects)
match_props_onlist[str]Fields to match for deduplication
scroll_propsdictScroll/pagination configuration

All prop parameters support dot notation for nested paths:

  • "items" - Top-level array
  • "data.items" - Nested under data
  • "response.data.items" - Deeply nested
  1. Always use match_props_on to prevent duplicates when users navigate back and forth
  2. Use only on the frontend to minimize data transfer
  3. Preserve scroll and state for seamless UX
  4. Include pagination metadata (has_more, page) to control UI
  5. Handle filters - Remember to include filter params when loading more
// Bad: Page jumps to top after load more
router.visit(`/browse?page=${page + 1}`)
// Good: Scroll position preserved
router.visit(`/browse?page=${page + 1}`, {
preserveScroll: true,
preserveState: true,
})
// Bad: Reloads all props including expensive ones
router.visit(`/browse?page=${page + 1}`, {
preserveScroll: true,
})
// Good: Only loads what's needed
router.visit(`/browse?page=${page + 1}`, {
preserveScroll: true,
only: ['cats', 'page', 'has_more'],
})
// Bad: Filters lost when loading more
router.visit(`/browse?page=${page + 1}`)
// Good: Preserve filters
const params = new URLSearchParams()
params.set('page', (page + 1).toString())
if (filters.breed) params.set('breed', filters.breed)
router.visit(`/browse?${params.toString()}`, {...})
  • Partial Reloads - Optimize which props are loaded
  • View Data - Pass server-side template data
  • Try the demo: just demo-fastapi to see infinite scroll in action