An Inertia invoices table jumps to the top and clears its search box on each page change; which visit options fix this?
answer
- scroll resets by default on every visit
- preserveScroll keeps the position
- preserveState keeps the component instance
- 'errors' or a callback for conditional
- scroll-region for overflow containers
basics
~20 sInertia resets scroll and remounts the page component on each GET visit. preserveScroll keeps the scroll position, preserveState keeps local state such as the search input, replace avoids a history entry per page, and a scrollable container needs a scroll-region attribute.
solid answer
~50 sBy default a GET visit creates a fresh page component instance, so local state such as the search input's value and focus is lost, and Inertia scrolls the document, plus any element marked `scroll-region`, back to the top. For the table, pass `preserveScroll` so the position stays and `preserveState` so the same component instance receives the new props: `router.get('/invoices', { search, page }, { preserveState: true, preserveScroll: true, replace: true })`. On a pagination `<Link>` the same props apply. `replace` swaps the current history entry instead of adding one per keystroke or page. Both preserve options also accept `'errors'` or a callback receiving the new page, to keep state only in some outcomes. If the table scrolls inside an `overflow-y-auto` container rather than the document, add the `scroll-region` attribute to that element, or Inertia neither resets nor restores it.
code
jsx · 23 linesimport { Link, router } from '@inertiajs/react'
import { useState } from 'react'
export default function Invoices({ invoices, filters }) {
const [search, setSearch] = useState(filters.search ?? '')
function onSearch(value) {
setSearch(value)
router.get('/invoices', { search: value }, {
preserveState: true, preserveScroll: true, replace: true,
})
}
return (
<main scroll-region="" className="overflow-y-auto">
<input value={search} onChange={(e) => onSearch(e.target.value)} />
<InvoiceTable rows={invoices.data} />
{invoices.links.map((link) => link.url && (
<Link key={link.label} href={link.url} preserveScroll preserveState>{link.label}</Link>
))}
</main>
)
}go deeper
Recall that preserveScroll keeps the scroll position and preserveState keeps local component state across a visit.
Explain the defaults per method, the 'errors' and callback forms, replace for search-as-you-type, and the scroll-region attribute.
Diagnose stale preserved state and container scrolling in persistent layouts, and apply the options only where they match user expectations.
Define navigation conventions for list pages, such as URL-driven filters and scroll rules, so every table behaves the same.
## The two defaults behind the symptom An invoices page with a search box and a paginated table shows two problems on every page change, and each comes from a default: 1. **Scroll reset.** Like a browser loading a new document, Inertia scrolls the document to the top after a visit and resets every element marked as a scroll region. On forward and back navigation it restores the positions it saved. 2. **Fresh component.** A `GET` visit to the same page creates a new instance of the page component, so local state such as an input's value, focus and open dropdowns starts over, even though the component name has not changed. ## The options | Option | Default for GET visits | What `true` does | |---|---|---| | `preserveScroll` | `false` | keeps the document and scroll-region positions | | `preserveState` | `false` | keeps the page component instance, so local state survives and only props change | | `replace` | `false` | replaces the current history entry instead of pushing one | The preserve options accept more than booleans: - `'errors'` preserves only when the new page has validation errors. - A callback receives the new page, e.g. `preserveScroll: (page) => page.props.invoices.current_page > 1`. Defaults differ by method: `router.post`, `put`, `patch`, `delete` and `router.reload` set `preserveState: true`, `reload` also sets `preserveScroll: true`, and a `<Link>` with a non-GET method defaults `preserveState` to `true`. A visit to the current URL sets `replace` automatically. ## Applying them to the table - **Pagination links**: `<Link href={link.url} preserveScroll preserveState>` so the table stays under the user's eyes and the search box keeps focus and text. - **Search as you type**: debounce the input and call `router.get('/invoices', { search }, { preserveState: true, preserveScroll: true, replace: true })`; `replace` stops each keystroke creating a Back step. - **Only the rows**: add `only: ['invoices']` so the filters and other props are not recomputed; that option belongs to partial reloads. ## When the table scrolls inside a container Many admin layouts scroll a `<main class="overflow-y-auto">` element, not the document. Inertia only manages the document and elements carrying a `scroll-region` attribute: - Without the attribute, Inertia neither resets nor restores the container. A container inside a persistent layout keeps its old position when you open another page, and Back does not restore it. - With `<main scroll-region="" className="overflow-y-auto">`, Inertia saves the region's position in history, resets it on normal visits, restores it on Back, and leaves it alone when `preserveScroll` is set. ## Pitfalls - `preserveState: true` means the component is not remounted, so state initialised from props, such as `useState(props.filters.search)`, does not update when props change. Derive from props, or reset explicitly. - Preserving state after a successful create can leave a filled-in form on screen; `'errors'` keeps state only when validation failed. - `preserveScroll` on links to a *different* page usually feels wrong; users expect a new page to start at the top. - Rapid history updates, such as one per keystroke, can hit browser limits on `pushState` and `replaceState` calls, so debounce search. ## Links versus router calls | Situation | Use | |---|---| | A clickable element the user sees, such as pagination | `<Link preserveScroll preserveState>` | | A visit triggered by code, such as a debounced search | `router.get(url, data, options)` | | Refreshing the current page's data | `router.reload({ only: [...] })`, which preserves state and scroll by default | Both accept the same visit options, so the choice is about the trigger, not the behaviour. ## A checklist for the invoices page 1. Mark the scrolling container with `scroll-region` if the document itself does not scroll. 2. Put `preserveScroll` and `preserveState` on pagination links and filter visits, not on navigation to other pages. 3. Use `replace` for keystroke-driven visits so Back returns to the previous page, not the previous letter. 4. Keep the search value derived from the URL or props, so a shared link opens with the same filter applied.
- Why can preserveState make a filter input show stale text?With `preserveState: true` the page component is not remounted, so state that was initialised once from props keeps its old value when new props arrive. If another action changes `filters.search` on the server, the input still shows the previous text. Derive the value from props, or sync it when the prop changes, when you rely on preserved state.
- What does the scroll-region attribute change for a scrollable container?Inertia's scroll handling covers the document plus every element with `scroll-region`. Marking the container lets Inertia save its position into history, reset it on normal visits, restore it on Back and Forward, and skip the reset when `preserveScroll` is set. Without it, the container's position is left entirely to the DOM.
saying these in an interview costs you the question
- preserveScroll also keeps the search input's text.
- preserveState keeps state by caching the previous page's props.
- Inertia restores scroll for any overflow container automatically.
- replace: true prevents the server request.
- GET visits preserve state by default.