Every Angular project I have worked on in the last few years has needed the same screen. A list of records coming from an API, paginated on the server because there are forty thousand of them, sortable by clicking a column header, filterable per column, with a search box, and usually with row selection because someone wants to archive twelve invoices at once.
And every time, I built it again.
Not from zero, exactly. I would copy the previous one, rip out the parts that were specific to the old project, and discover three days later that the paging logic had a subtle bug I had already fixed once, in a repository I no longer had access to.
That is the part that wore me down. Not the difficulty — none of this is hard — but the repetition, and the fact that the knowledge kept evaporating.
Why I did not just install something
I did look.
Angular Material's table gives you good primitives and expects you to assemble the screen: the table, the paginator and the sort header are separate pieces that you wire together, and the result carries Material's design language. If your app already looks like Material, that is a feature. Mine usually did not, and fighting a design system you did not choose is its own kind of work.
The full-featured commercial grids solve everything I have described and a great deal more. They also cost money per developer, and for the kind of internal tool I was building — a back office for a client with four users — the licence was hard to justify.
So I kept writing it by hand, which is the option that looks free and is not. You pay for it in maintenance, spread over years, in instalments.
The decision that actually mattered
When I finally sat down to build the thing properly, the design question was not "which features" but where the data comes from.
A table that owns its data is easy to write and useless the moment the dataset outgrows the browser. A table that knows nothing about data is honest but leaves you writing the same paging code you were trying to avoid. I wanted one component that could do both, and could tell which mode it was in without a flag that people would forget to set.
The answer turned out to be simple: the table infers the mode from what you give it.
Hand it a plain array and say nothing else, and it paginates, sorts, filters and searches in memory. Hand it an array and a total item count, and it stops touching the data — now it only renders the page you gave it and tells you when the user wants a different one.
<!-- client mode: the table does the work -->
<hub-table [data]="orders" [headers]="headers" />
<!-- server mode: you do the work, the table asks -->
<hub-table
[data]="orders()"
[headers]="headers"
[totalItems]="totalItems()"
[(page)]="page"
[(perPage)]="perPage"
[(ordination)]="ordination"
[(searchTerm)]="searchTerm"
[loading]="isLoading()" />
Setting totalItems is the switch. There is no serverSide boolean, because a boolean is a thing you can set to the wrong value while the data says otherwise. A total item count cannot contradict itself: if you know how many rows exist in total, you are paginating on the server, or you would not know.
Everything is a two-way binding
The second decision follows from the first, and it is the one I would defend hardest.
The table has no outputs. Not one EventEmitter. Everything a consumer would want to listen to is a model() — Angular's two-way signal binding — so page, perPage, ordination, searchTerm, filters, loading and error are all read and written from both sides.
This matters more than it sounds. With an output you get a notification and then you own the bookkeeping: store the page number somewhere, remember to reset it to 1 when the search term changes, make sure the component's idea of the current page and yours do not drift apart. With a model there is one value, and both ends look at it.
readonly page = signal(1);
readonly perPage = signal(20);
readonly searchTerm = signal('');
readonly ordination = signal<PaginableTableOrdination | undefined>(undefined);
Bind those four signals and the table becomes a view over your state instead of a thing you synchronise with. Your loading function reads them; the user's clicks write them. Nothing in the middle.
Sorting is the clearest example. Clicking a sortable header writes { property: 'reference', direction: 'ASC' } into ordination and flips to 'DESC' on the second click. In client mode the table then sorts in memory. In server mode it does nothing else — your effect sees the new value and fetches. Same binding, same shape, and the component never needed to know which one it was doing.
(One honest note: it is a two-state toggle. There is no third click that returns to unsorted. I have wanted it twice and not built it.)
Signals let the whole thing collapse into one line
Angular's resource() changed the shape of this code again, and the table grew a second way in. Bind a resource whole:
protected readonly page = signal(1);
protected readonly invoices = resource({
params: () => ({ page: this.page() }),
loader: ({ params }) => this.api.fetchInvoices(params.page)
});
<hub-table [resource]="invoices" [headers]="headers" (pageChange)="page.set($event ?? 1)" />
The table reads three things off it — value(), isLoading(), error() — and mirrors the last two into its own state, so the loading skeleton and the error panel come for free. The interface it asks for is structural, three zero-argument getters, which means resource() and httpResource() satisfy it without the library importing anything from a newer Angular than it supports.
The part worth knowing, because it surprised me while writing it: the table never calls reload(). Paging does not refetch by itself. The page signal is a resource parameter, so changing it re-runs the loader through Angular's own machinery, not through a side effect the table fires. If you want a refetch, you change a param. That is the only path, and having only one path is the point.
What I got wrong, and what is still missing
The library is ng-hub-ui-paginable. MIT, no account, no telemetry, and it works from Angular 18 upwards. It is the part of this post where I am supposed to tell you it does everything. It does not, and the gaps are worth stating plainly, because a list of features tells you nothing and a list of limits tells you whether you can use it.
No virtual scrolling. Every row of the current page renders. For pages of 10–100 rows, which is what pagination is for, this has never been the bottleneck. If you need to render fifty thousand rows in one scroll container, this is the wrong component and I would point you at a grid built for it.
No export, no column reordering, no row grouping. Never needed them badly enough to build them well, and half-built features are worse than absent ones.
Rows track by index. So a refetched page re-renders rather than diffing by id. I know why this is wrong and it is on the list.
Accessibility cost me a release, and writing this post is what caused it. I sat down to describe what the table gives a screen reader, went to check, and found it announced almost nothing. aria-sort did not appear anywhere in the package, so a reader was told the rows had been reordered and never by which column. The sort button had no accessible name. Neither did the checkboxes, the control that opens a row, or any column filter. A column that only had a filter still drew a focusable sort button that did nothing at all.
That is fixed in 22.25.0, together with scope="col" on the headers, aria-busy while the table loads, a live region for the row count and aria-current on the paginator. Two things are still wrong: the range filters have no label, and the paginator's ellipses take focus when they should not.
The thing I got most right, by accident, was refusing to build a "table framework". It is one component with a data input and a set of two-way bindings. When it does not do what you need, you project a template:
<hub-table [data]="invoices()" [headers]="headers">
<ng-template cellTpt header="status" let-item="item">
<span class="badge" [class.badge--paid]="item.status === 'paid'">
{{ item.status }}
</span>
</ng-template>
</hub-table>
That escape hatch is why I have not had to fork it for a project yet.
If you have been rebuilding the same table too: the code is at github.com/hub-env/ng-hub-ui-paginable, the docs and live examples are at hubui.dev/en/paginable/overview, and it installs with npm i ng-hub-ui-paginable.
And if you build your own instead, I would still make the same first decision. Let the data tell the component what mode it is in. Every bug I had in the old hand-written versions came from a flag that said one thing while the data said another.
Top comments (0)