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.
The Problem
Section titled “The Problem”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.
The Solution: Merge Props
Section titled “The Solution: Merge Props”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!Backend Setup
Section titled “Backend Setup”Basic Usage
Section titled “Basic Usage”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 )Nested Props
Section titled “Nested Props”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)Preventing Duplicates
Section titled “Preventing Duplicates”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)Scroll Configuration
Section titled “Scroll Configuration”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, } }, )Frontend Setup
Section titled “Frontend Setup”Load More Button
Section titled “Load More Button”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> )}Key Frontend Options
Section titled “Key Frontend Options”preserveScroll: true- Keeps the user’s scroll positionpreserveState: true- Preserves component state (form inputs, etc.)only: [...]- Only fetch the props needed for pagination (performance optimization)
Complete Example
Section titled “Complete Example”Here’s a full implementation from our demo app:
Backend (FastAPI)
Section titled “Backend (FastAPI)”from fastapi import FastAPI, Queryfrom 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, } }, )Frontend (React)
Section titled “Frontend (React)”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> )}Resetting Merged Data
Section titled “Resetting Merged Data”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.
The Problem
Section titled “The Problem”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!)Frontend Solution
Section titled “Frontend Solution”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'], })}How It Works
Section titled “How It Works”When you use reset: ['cats']:
- The Inertia client sends the
X-Inertia-Reset: catsheader - The server excludes
catsfrommergeProps(so data replaces instead of merges) - The server includes
resetProps: ['cats']in the response - The client clears the local
catsstate before applying new data
Complete Filter Example
Section titled “Complete Filter Example”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> )}Backend: No Changes Needed
Section titled “Backend: No Changes Needed”The backend automatically handles the reset header. When X-Inertia-Reset: cats is received:
cats(and nested paths likecats.data) are excluded frommergeProps- The response includes
resetProps: ['cats']for the client
No backend code changes are required—the existing merge_props configuration works automatically with reset.
Advanced Options
Section titled “Advanced Options”Prepend Props
Section titled “Prepend Props”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)Deep Merge Props
Section titled “Deep Merge Props”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)API Reference
Section titled “API Reference”inertia.render() Parameters
Section titled “inertia.render() Parameters”| Parameter | Type | Description |
|---|---|---|
merge_props | list[str] | Props to append (arrays are concatenated) |
prepend_props | list[str] | Props to prepend (new items first) |
deep_merge_props | list[str] | Props to recursively merge (for objects) |
match_props_on | list[str] | Fields to match for deduplication |
scroll_props | dict | Scroll/pagination configuration |
Dot Notation
Section titled “Dot Notation”All prop parameters support dot notation for nested paths:
"items"- Top-level array"data.items"- Nested underdata"response.data.items"- Deeply nested
Best Practices
Section titled “Best Practices”- Always use
match_props_onto prevent duplicates when users navigate back and forth - Use
onlyon the frontend to minimize data transfer - Preserve scroll and state for seamless UX
- Include pagination metadata (
has_more,page) to control UI - Handle filters - Remember to include filter params when loading more
Common Pitfalls
Section titled “Common Pitfalls”Forgetting preserveScroll
Section titled “Forgetting preserveScroll”// Bad: Page jumps to top after load morerouter.visit(`/browse?page=${page + 1}`)
// Good: Scroll position preservedrouter.visit(`/browse?page=${page + 1}`, { preserveScroll: true, preserveState: true,})Not Using only
Section titled “Not Using only”// Bad: Reloads all props including expensive onesrouter.visit(`/browse?page=${page + 1}`, { preserveScroll: true,})
// Good: Only loads what's neededrouter.visit(`/browse?page=${page + 1}`, { preserveScroll: true, only: ['cats', 'page', 'has_more'],})Forgetting Filters
Section titled “Forgetting Filters”// Bad: Filters lost when loading morerouter.visit(`/browse?page=${page + 1}`)
// Good: Preserve filtersconst params = new URLSearchParams()params.set('page', (page + 1).toString())if (filters.breed) params.set('breed', filters.breed)router.visit(`/browse?${params.toString()}`, {...})Next Steps
Section titled “Next Steps”- Partial Reloads - Optimize which props are loaded
- View Data - Pass server-side template data
- Try the demo:
just demo-fastapito see infinite scroll in action