By Alexandro · Updated 2026-11-06
Keep server state separate from the visible page
This guide targets @stackline/angular-data-table-component 22.1.7 with the existing Angular 22.2.4 example. A server-paginated table needs a total count, a stable row identity and an explicit selection policy. Selecting rows on one page must not accidentally select the same array positions on another page.
npm install --save-exact @stackline/[email protected]
Use the running Angular 22 application and its actual source for component inputs, events and templates. The server-state fixture below is framework-independent so race and selection semantics can be tested deterministically.
A simulated API and request controller
The API returns {rows,total}. Page numbers start at one. Sorting is performed before slicing. The controller uses a monotonically increasing generation so a slow old response cannot replace the newest page. Selection is a set of IDs across pages. A real API must apply its own authorization and validate sort fields; this local fixture contains only generated data.
// Framework-independent state used by the Angular server-table example.
export function createTableState(fetchPage) {
let generation=0;
const state={rows:[],total:0,loading:false,error:null,selected:new Set()};
return {state,
select(id,selected){selected?state.selected.add(id):state.selected.delete(id);},
async load(query){
const request=++generation;state.loading=true;state.error=null;
try {const result=await fetchPage(query);if(request===generation){state.rows=result.rows;state.total=result.total;}}
catch(error){if(request===generation){state.error=String(error.message||error);state.rows=[];}}
finally{if(request===generation)state.loading=false;}
}
};
}
export async function simulatedAPI({page=1,size=10,sort='name',direction='asc',fail=false}) {
await new Promise(resolve=>setTimeout(resolve,page===1?40:5));
if(fail)throw new Error('Simulated API failure');
const rows=Array.from({length:42},(_,i)=>({id:i+1,name:`Row ${String(i+1).padStart(2,'0')}`}));
rows.sort((a,b)=>(a[sort]>b[sort]?1:a[sort]<b[sort]?-1:0)*(direction==='desc'?-1:1));
return {rows:rows.slice((page-1)*size,page*size),total:rows.length};
}
Connect page and sort events to load with the full query. Render state.rows, display state.total in the pager and set each row checkbox from state.selected.has(row.id). A page-level select-all operation updates IDs on that page only. A dataset-wide selection needs a separate server-side contract; do not imply it by filling a set with visible rows.
Complete Angular integration
The executable guide example combines the actual table component with the simulated API, stable ID selection and explicit failure/race controls. Build it with npm ci --prefix scripts/docs/angular-example and npm run build --prefix scripts/docs/angular-example. Its main source is shared with the multiselect migration example:
import '@angular/compiler';
import {Component,ChangeDetectorRef,inject,provideZonelessChangeDetection} from '@angular/core';
import {bootstrapApplication} from '@angular/platform-browser';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {DataTableModule} from '@stackline/angular-data-table-component';
import {AngularMultiSelectModule} from '@stackline/angular-multiselect-dropdown';
import {createTableState,simulatedAPI} from '../examples/table-state.mjs';
@Component({selector:'guide-example',standalone:true,imports:[CommonModule,FormsModule,DataTableModule,AngularMultiSelectModule],template:`
<h1>Angular 22.2.4: server table and asynchronous multiselect</h1>
<p>Table 22.1.7 · Multiselect 22.1.6 · generated local data only.</p>
<p><a href="/docs/guides/angular-table/">Server-table guide</a> · <a href="/docs/guides/angular-multiselect/">Multiselect migration</a> · <a href="main.ts">Running source</a></p>
<section aria-labelledby="table-title"><h2 id="table-title">Server paging, sorting and persistent ID selection</h2>
<button (click)="load(1)">First page</button> <button (click)="load(page+1)">Next page</button>
<button (click)="direction=direction==='asc'?'desc':'asc';load(1)">Toggle server sort</button>
<button (click)="load(99)">Empty page</button> <button (click)="load(1,true)">Simulate failure</button>
<button (click)="race()">Simulate overlapping requests</button>
<p role="status">{{table.state.loading?'Loading…':table.state.error||('Page '+page+' · '+table.state.total+' total rows')}}</p>
<stackline-data-table title="Server response" [columns]="columns" [data]="table.state.rows" [pagination]="true" [paginationServer]="true" [paginationTotalRows]="table.state.total" [paginationDefaultPage]="page" [paginationPerPage]="size" (pageChange)="load($event.page)" (rowsPerPageChange)="size=$event.rowsPerPage;load(1)"></stackline-data-table>
<fieldset><legend>Selection across pages (stable IDs)</legend><label *ngFor="let row of table.state.rows"><input type="checkbox" [checked]="table.state.selected.has(row.id)" (change)="select(row.id,$event)">{{row.name}}</label></fieldset>
<p id="selection">Selected IDs: {{selectedIDs()}}</p><p *ngIf="!table.state.loading && !table.state.error && table.state.rows.length===0">No rows on this page.</p></section>
<section aria-labelledby="multi-title"><h2 id="multi-title">Asynchronous multiselect with ngModel</h2>
<button (click)="refresh()">Load another dataset</button><p role="status">{{multiLoading?'Loading options…':'Options ready'}}</p>
<angular2-multiselect [data]="items" [(ngModel)]="selected" [settings]="settings" (onSelect)="event='Selected '+$event.itemName" (onDeSelect)="event='Deselected '+$event.itemName" (onSelectAll)="event='Selected all'" (onDeSelectAll)="event='Cleared all'"></angular2-multiselect>
<p role="status">{{event}}</p><p id="multi-selection">Form value: {{selected|json}}</p>
</section>`})
class GuideExample {
private change=inject(ChangeDetectorRef);
table=createTableState(simulatedAPI);page=1;size=10;direction='asc';columns=[{name:'ID',selector:'id'},{name:'Name',selector:'name'}];
items=[{id:1,itemName:'Canada'},{id:2,itemName:'France'},{id:3,itemName:'Brazil'}];selected:any[]=[];multiLoading=false;event='';
settings={text:'Select countries',singleSelection:false,enableSearchFilter:true,selectAllText:'Select all',unSelectAllText:'Clear all'};
constructor(){this.load(1);}
async load(page:number,fail=false){this.page=page;await this.table.load({page,size:this.size,sort:'id',direction:this.direction,fail});this.change.markForCheck();}
race(){this.direction='asc';this.load(1);this.load(2);}
select(id:number,event:Event){this.table.select(id,(event.target as HTMLInputElement).checked);}
selectedIDs(){return [...this.table.state.selected].join(', ')||'none';}
async refresh(){this.multiLoading=true;await new Promise(resolve=>setTimeout(resolve,300));this.items=[{id:1,itemName:'Canada'},{id:4,itemName:'Japan'}];const ids=new Set(this.selected.map(x=>x.id));this.selected=this.items.filter(x=>ids.has(x.id));this.multiLoading=false;this.change.markForCheck();}
}
bootstrapApplication(GuideExample,{providers:[provideZonelessChangeDetection()]}).catch(error=>{document.body.textContent=String(error);throw error;});
Loading, errors, empty results and races
Show loading while the newest request is pending. Present an error with a retry action; do not label a failed request as “no records.” A successful response with zero rows is the empty state. When filters change, return to page one and explicitly decide whether selected IDs remain relevant. In an Angular RxJS data flow, switchMap can cancel a prior subscription, but the server can still have received the request; do not use cancellation as authorization or mutation rollback.
The executable checks cover overlapping requests, descending order, retained selection across pages, empty results, failure and recovery:
import assert from 'node:assert/strict';
import {createTableState,simulatedAPI} from './table-state.mjs';
const table=createTableState(simulatedAPI);
await Promise.all([table.load({page:1}),table.load({page:2})]);
assert.equal(table.state.rows[0].id,11); // Slow page 1 cannot overwrite page 2.
table.select(11,true);await table.load({page:3});assert.ok(table.state.selected.has(11));
await table.load({page:1,sort:'id',direction:'desc'});assert.equal(table.state.rows[0].id,42);
await table.load({page:99});assert.equal(table.state.rows.length,0);assert.equal(table.state.total,42);
await table.load({fail:true});assert.match(table.state.error,/failure/);assert.equal(table.state.loading,false);
await table.load({page:1});assert.equal(table.state.error,null);
await table.load({page:2,size:20});assert.equal(table.state.rows.length,20);assert.equal(table.state.rows[0].id,21);
console.log('table: latest response, stable selection, sorting, empty, failure and recovery passed');
Expected: page two wins despite page one's longer simulated delay; row 11 remains selected after page changes; failure and recovery update state correctly. These tests establish controller semantics. The linked Angular app supplies the component integration and must also be exercised with its real UI.
Accessibility and operational limits
Keep column headers descriptive, provide an accessible loading/error status, preserve keyboard focus after paging and explain cross-page selection next to the selection count. Test large totals and a final partial page. Avoid loading an entire private dataset just to simulate server pagination in production.