Pagination and filters look simple when considered separately.
The interesting part appears when they begin affecting each other.
Imagine a user is on page eight of a large result set and then changes a filter. The new result set may contain only two pages.
If the old page is preserved, the server can correctly return an empty result. To the user, however, it looks as if the new filter matched nothing.
The page therefore needs to reset when the criteria change.
But that introduces another easy mistake: triggering two identical requests instead of one.
Why the Page Should Reset After a Filter Change
Before filter change 78 results8 pagescurrent page: 8 After filter change 14 results2 pagescurrent page is still: 8 Result: Technically, everything may be working correctly here.
The server receives a page number that does not exist in the new result set and returns an empty list.
But the interface state is semantically wrong.
The current page is not an independent parameter. It belongs to the criteria that produced the result set.
For me, changing a filter therefore means not only "load new data" but also "start the new result set from the first page".
The UI May Be 1-Based While the Internal Index Starts at 0
UI page Internal pageIndex 1 02 13 24 3Many table interfaces show page numbers starting at 1, while component state stores a pageIndex starting at 0.
That is not a problem when the boundary is explicit.
Problems begin when one part of the code passes a page number, another expects an index, and a third adds or subtracts one again.
I therefore keep one internal representation and convert only at the UI or request boundary.
Conversion Between Page Number and Index Should Be Explicit
const pageIndex = ref(0) const currentPage = computed({ get: () => pageIndex.value + 1, set: (page) => { pageIndex.value = Math.max( 0, page - 1, ) },})This small computed removes the need to remember the one-page offset in every handler.
The pagination component works with normal user-facing pages 1, 2, 3..., while internal state remains 0, 1, 2....
This becomes especially useful when several controls can change the current page.
The Naive Solution Easily Triggers Two Requests
watch( filters, () => { pageIndex.value = 0 reload() }, { deep: true, },) watch( pageIndex, () => { reload() },At first glance this code is logical.
The filter changes, so reset the page and reload. The page changes, so reload as well.
But when the user is not already on the first page, changing a filter creates two reactions:
pageIndex.value = 0triggers the page watcher;- the filter watcher also calls
reload()directly.
One user action can therefore produce two identical HTTP requests.
The Duplicate Request Comes From a Chain of Reactive Changes
User changes filter | vfilter watcher | +--> pageIndex = 0 | | | v | page watcher | | | v | reload() | +--> reload() I Keep One Reload Path
watch( filters, () => { if (pageIndex.value !== 0) { pageIndex.value = 0 return } reload() }, { deep: true, },) There are two cases here.
The user was not on the first page. The filter watcher changes pageIndex to 0 and returns. The page watcher observes that change and performs the single reload().
The user was already on the first page. The pageIndex value does not change, so the page watcher will not run. In that case the filter watcher reloads directly.
Both paths therefore result in exactly one reload.
Always Resetting pageIndex and Returning Is Not Enough
watch( filters, () => { pageIndex.value = 0 return }, { deep: true, },)This reveals the other side of reactive behavior.
If pageIndex is already 0, assigning 0 again does not create a state change.
The page watcher therefore has no reason to run.
The filter changed, but no request occurs at all.
The pageIndex.value !== 0 condition is therefore not just a small optimization. It is part of the correct behavior.
I Convert the Internal Index at the Request Boundary
async function reload() { await loadItems({ page: pageIndex.value + 1, search: filters.search, status: filters.status, })}If the API expects pages starting at 1, I convert the index immediately before the request.
The internal logic can then use one consistent pageIndex representation while the server receives a normal page parameter.
This reduces off-by-one errors and makes the behavior easier to reason about.
The Page Belongs to Query State but Is Not an Equal Filter
The semantics matter here.
A search term, status, or another filter defines which records belong to the result set.
The page number only defines which portion of that result set should be displayed.
The dependency therefore has one direction:
- changing the page should not change the filters;
- changing a filter may invalidate the current page.
This distinction explains why criteria changes reset pagination.
Different Filters Should Follow the Same Reset Rule
const filters = reactive({ search: '', status: null, category: null,}) watch( filters, handleCriteriaChange, { deep: true, },)I do not want separate pagination behavior for search, status, category, and every future filter.
They all change the result criteria, so the rule is the same: the current page should no longer be considered valid.
This also reduces the risk that a new filter is added to the form but forgotten in the pagination-reset logic.
For Search I Also Check Request Frequency
Text search has another property: the criterion may change after every typed character.
Even after duplicate reloads are removed, an immediate watcher may still generate many sequential requests while the user types.
For search input I therefore separately consider debouncing or another request-frequency control.
This is a different problem. It should not hide the duplicate reload() issue, although both are easy to notice while inspecting network requests.
One Request per Action Also Reduces Response Races
A duplicate request is not only extra load.
Two requests may complete in a different order from the order in which they started.
If an older response finishes last and the code does not protect against that case, it may overwrite newer table state.
My first step is therefore simple: do not create unnecessary requests in the first place.
For flows where concurrent requests are genuinely expected, stale-response protection or cancellation becomes a separate concern.
What I Verify in Tests
1. User is on page 42. Filter changes3. pageIndex becomes 04. Exactly 1 request is made 1. User is already on page 12. Filter changes3. pageIndex remains 04. Exactly 1 request is made 1. User changes only the page2. Filters stay unchanged3. Exactly 1 request is madeChecking only the final pageIndex value is not enough.
I also care about the number of reloads.
The interface may look completely correct while the network panel shows two identical requests after every filter change.
For this reason, I test both the resulting state and the side effect.
What I Check When Filters and Pagination Work Together
For this kind of table, I go through a short checklist:
- Does a criteria change reset the page?
- Does it still reload when the user is already on page one?
- Can two watchers trigger two identical requests?
- Is there one internal page representation?
- Is pageIndex converted only at the boundary?
- Do all filters follow the same behavior?
- Does text search create unnecessary requests while typing?
- Do tests verify reload count rather than only rendered output?
Correct State Does Not Automatically Mean Correct Request Behavior
I like this case because the problem is almost invisible from the outside.
The page correctly returns to the beginning after a filter change. The table displays the right data. The user may never notice anything wrong.
Internally, however, one state change triggers another reaction and together they create an unnecessary request.
In reactive interfaces, I therefore look not only at final state values but also at the chain of side effects.
If changing a filter should reset pagination and load data once, that is exactly what the code should guarantee: one criteria change, one valid page, one request.
In reactive code, I check not only where the state ended up, but also how many effects it triggered on the way.



