Introduction
Many enterprise Angular applications display data using grids or tables. These grids often appear in pairs or sets, showing related information side by side. A very common real-world requirement is that multiple grids must remain synchronized. When the user changes the page in one grid, the other grid should update automatically. When the user applies a filter in one grid, all grids should reflect that filter.
These coordinated behaviours are known as multi-grid synchronization with shared paging and filters.
This pattern is widely required in domains such as banking dashboards, analytics platforms, inventory systems, HR systems, and admin consoles.
Understanding Multi-Grid Synchronization
What is Multi-Grid Synchronization?
In a dashboard-style layout, you may have:
Grid A showing summary or parent-level records
Grid B showing detailed records related to Grid A
Grid C showing metrics filtered by the selections in A or B
The expectation is that if a user changes any of these:
Page number
Page size
Sort order
Filters
Date range
Search term
Then all grids should behave consistently based on a shared state, rather than maintaining their own isolated states.
Why Shared Paging and Shared Filters?
Imagine two grids showing matching datasets:
Grid A: Customer list
Grid B: Customer orders
If you filter customers by country = India, Grid B should show only orders for Indian customers. If you move to page 4 in Grid A, Grid B should also load the corresponding page 4 of results.
This enhances:
User experience
Consistency
Predictability
Business accuracy
Typical enterprise use cases
Master-detail dashboards: Parent grid controls child grid.
Side-by-side comparisons: Two tables showing different calculations for the same filter conditions.
Complex analytics: Multiple grids tracking a common filter set.
Financial dashboards: Paging and date-range filters driving multiple analytics views.
Key Architectural Challenges
Building synchronized grids is not trivial.
The common challenges include:
Challenge 1: Avoiding Tight Coupling
If Grid A directly calls functions on Grid B, the system becomes difficult to maintain. A proper shared state management solution is required.
Challenge 2: Preventing Unnecessary API Calls
If each grid reloads data independently, you may create multiple calls to the same endpoint. Instead, you want controlled and debounced calls.
Challenge 3: Dealing with Race Conditions
When both grids request new data at the same time, results can get out of sync.
Challenge 4: Ensuring Reusability
Hardcoded filter logic inside components makes future changes costly.
Challenge 5: State Reset and Persistence
You need to support:
High-Level Architecture for Multi-Grid Synchronization in Angular
A scalable approach includes:
A shared state service that manages:
Paging
Filters
Sort conditions
Grid-level metadata
A unified data-loading pipeline with RxJS behaviour subjects
Each grid component subscribes to shared state
API service listens to shared state updates and fetches data accordingly
One-way data flow:
Component → Shared State → Data Service → API → Results → Component
Optional: Use NgRx or ComponentStore for larger applications
Designing the Shared Models
Paging Model
export interface Paging {
page: number;
pageSize: number;
}
Filter Model
export interface GridFilters {
search?: string;
country?: string;
status?: string;
dateRange?: {
from: Date;
to: Date;
};
}
Sort Model
export interface SortState {
active: string;
direction: 'asc' | 'desc' | '';
}
Combined Grid State Model
export interface GridState {
paging: Paging;
filters: GridFilters;
sort: SortState;
}
Implementing a Shared State Service in Angular
We use BehaviorSubject to store the latest state and allow components to subscribe.
grid-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { GridState } from '../models/grid-state.model';
@Injectable({
providedIn: 'root'
})
export class GridStateService {
private initialState: GridState = {
paging: { page: 1, pageSize: 10 },
filters: {},
sort: { active: '', direction: '' }
};
private stateSubject = new BehaviorSubject<GridState>(this.initialState);
state$ = this.stateSubject.asObservable();
updatePaging(page: number, pageSize: number) {
const current = this.stateSubject.getValue();
this.stateSubject.next({
...current,
paging: { page, pageSize }
});
}
updateFilters(filters: Partial<GridFilters>) {
const current = this.stateSubject.getValue();
this.stateSubject.next({
...current,
filters: { ...current.filters, ...filters }
});
}
updateSort(sort: SortState) {
const current = this.stateSubject.getValue();
this.stateSubject.next({
...current,
sort
});
}
reset() {
this.stateSubject.next(this.initialState);
}
}
This service acts as a single source of truth.
Unified Data Service for API Calls
grid-data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, combineLatest } from 'rxjs';
import { GridStateService } from './grid-state.service';
import { switchMap } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class GridDataService {
constructor(
private http: HttpClient,
private gridState: GridStateService
) {}
loadData(apiUrl: string): Observable<any> {
return this.gridState.state$.pipe(
switchMap(state => {
let params = new HttpParams()
.set('page', state.paging.page)
.set('pageSize', state.paging.pageSize);
Object.keys(state.filters).forEach(key => {
params = params.set(key, String((state.filters as any)[key]));
});
if (state.sort.active) {
params = params.set('sortBy', state.sort.active)
.set('sortDir', state.sort.direction);
}
return this.http.get(apiUrl, { params });
})
);
}
}
Now both grids will receive synchronized data whenever the shared state changes.
Building Two Synchronized Grids
Grid Component Template
<table>
<thead>
<tr>
<th (click)="sort('name')">Name</th>
<th (click)="sort('country')">Country</th>
<th (click)="sort('status')">Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of data">
<td>{{ row.name }}</td>
<td>{{ row.country }}</td>
<td>{{ row.status }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button (click)="prevPage()">Previous</button>
<span>{{ paging.page }}</span>
<button (click)="nextPage()">Next</button>
</div>
<div class="filters">
<input type="text" placeholder="Search" (input)="applySearch($event.target.value)" />
</div>
Grid Component Class
import { Component, Input, OnInit } from '@angular/core';
import { GridDataService } from '../services/grid-data.service';
import { GridStateService } from '../services/grid-state.service';
@Component({
selector: 'app-synced-grid',
templateUrl: './synced-grid.component.html'
})
export class SyncedGridComponent implements OnInit {
@Input() apiUrl: string;
data: any[] = [];
paging = { page: 1, pageSize: 10 };
constructor(
private dataService: GridDataService,
private state: GridStateService
) {}
ngOnInit() {
this.dataService.loadData(this.apiUrl)
.subscribe(result => {
this.data = result.data;
this.paging = result.paging;
});
}
nextPage() {
this.state.updatePaging(this.paging.page + 1, this.paging.pageSize);
}
prevPage() {
if (this.paging.page > 1) {
this.state.updatePaging(this.paging.page - 1, this.paging.pageSize);
}
}
applySearch(value: string) {
this.state.updateFilters({ search: value });
}
sort(column: string) {
this.state.updateSort({
active: column,
direction: 'asc'
});
}
}
With this, multiple instances of app-synced-grid will automatically stay synchronized.
Example Usage in a Parent Component
<app-synced-grid apiUrl="/api/customers"></app-synced-grid>
<app-synced-grid apiUrl="/api/orders"></app-synced-grid>
Both grids will:
But each grid will call its own API.
Best Practices for Multi-Grid Synchronization
Keep Shared State Independent
The shared state should contain only generic paging, filter, and sort information. Do not mix it with dataset-specific details.
Use Immutable Updates
Never mutate the state object directly.
Use Subjects wisely
BehaviorSubject is appropriate for always holding the latest value.
Debounce filter inputs
Add debounceTime(300) on search fields to avoid flooding the API.
Avoid over-fetching
Use distinctUntilChanged() for paging and filter streams.
Keep grids dumb, state smart
Grids should only display data and relay user events to the shared service.
Use trackBy in ngFor
Improves performance when dealing with large data sets.
Advanced: Using NgRx ComponentStore or Global Store
For very large applications, you can move shared state into:
NgRx Store
NgRx ComponentStore
Akita
RxState
ComponentStore is a good middle-ground, especially for a dashboard with multiple grids.
Performance Optimization Techniques
Memoize API results
Useful if grids show overlapping data.
Avoid re-rendering the entire grid
Use OnPush change detection.
@Component({
selector: 'app-synced-grid',
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
Use virtual scrolling for large datasets
Angular CDK Virtual Scroll improves user experience.
Paginate on the server side
Do not load entire datasets to the browser.
Testing Multi-Grid Synchronization
Unit Testing GridStateService
Test:
Paging updates
Filter updates
Sort updates
Reset behavior
Use Jasmine or Jest for isolated testing.
Component Tests
Write tests to ensure:
Pagination buttons emit correct events
Input filters update shared state
Sort actions update shared state
Integration Tests
Use Cypress or Playwright to test:
These tests ensure that the entire synchronization chain works.
Conclusion
Multi-grid synchronization with shared paging and filters is a common and essential requirement in enterprise Angular applications. Implementing it correctly ensures a clean user experience, consistent business logic, and scalable architecture.
In this article, we covered:
By following this pattern, teams can create highly maintainable dashboards, reduce duplicated logic, and prevent inconsistencies across grids. The approach is simple enough for junior developers and scalable enough for senior developers building large applications.