Skip to content

Reactive filtering and sorting

Filtering and sorting often look harmless in the frontend.

A dropdown here. A few checkboxes there. A sort button next to them.

And code like this quickly appears:

visibleArticles = articles
.filter(({ category }) => selectedCategories.includes(category))
.sort((left, right) => left.title.localeCompare(right.title));

Works.

At least at first.

Later, more filters get added. Status. Author. Category. Tags. Publication state. Sorting by title, author, or category. Maybe saved filters. Maybe a “reset all” button.

And suddenly, list logic sits everywhere.

In the component. In the template. In event handlers. In small helper services. In arrays that get mutated on the side.

The problem isn’t filter().

The problem is when the component decides what’s visible.

A classic starting point often looks like this:

selectedCategories: ArticleCategory[] = [];
visibleArticles: Article[] = [];
toggleCategory(category: ArticleCategory): void {
if (this.selectedCategories.includes(category)) {
this.selectedCategories = this.selectedCategories.filter(
(selectedCategory) => selectedCategory !== category,
);
} else {
this.selectedCategories = [...this.selectedCategories, category];
}
this.visibleArticles = this.articles.filter(({ category }) =>
this.selectedCategories.includes(category),
);
}

Or sorting gets handled directly in the click handler:

sortByTitle(): void {
this.visibleArticles.sort((left, right) =>
left.title.localeCompare(right.title),
);
}

This feels pragmatic.

But it has several unpleasant consequences:

The filter logic spreads out. Sorting mutates arrays. UI state isn’t explicitly modeled. Components turn into small controllers. Tests become unnecessarily hard. The visible list becomes yet another state that has to be kept in sync.

The component then doesn’t just know:

The user changed a category filter.

It also knows:

How this filter condition gets applied to articles. How the result gets sorted. Which list is visible afterward.

That gives the component responsibility that doesn’t belong to it.

Filters are state.

Sorting is state.

The visible list is a derivation.

That’s the central shift in perspective.

The user doesn’t change the list. The user changes filter conditions.

The user doesn’t sort the array. The user changes sort state.

The visible list is the consequence of this query state.

This article doesn’t start at the HTTP call.

It starts where the infrastructure provides the already loaded articles as a safe, named signal.

private httpResource
→ ArticleResource
→ articles: Signal<readonly Article[] | null>

The retrieve slice was covered earlier. Infrastructure, HTTP details, API mapping, and the technical lifecycle of httpResource aren’t the topic here.

We deliberately assume:

ArticleResource encapsulates the concrete Angular resource. The store can access the named articles signal. Now it’s only about how a visible, filtered, and sorted list emerges from that.

We’re not building search.

Search would be more like:

User types search text
→ searchChanged({ searchText })

This article is about classic filters:

The user selects one or more filter conditions.

For example categories:

User opens the category dropdown
→ selects "Architecture"
→ categoryFilterToggled({ category: 'architecture' })
User additionally selects "Angular"
→ categoryFilterToggled({ category: 'angular' })
User removes "Architecture"
→ categoryFilterToggled({ category: 'architecture' })

The store holds explicit query state from that:

selectedCategories
sortBy
sortDirection

The ViewModel gets derived reactively from that:

articles
+ selectedCategories
+ sortBy
+ sortDirection
→ filterArticles
→ sortArticles
→ ArticleListVm

The UI sends intents. The ViewModel derives.

The component doesn’t filter. The template doesn’t sort. No service mutates the array.

The component only reports:

This filter condition was activated or deactivated.

What becomes visible from that is a derivation in the store.

The UI doesn't change the list. It changes query state. The list is a derivation

Filters are especially well explained over time.

The user doesn’t click “new list.” They change filter conditions one after another.

Time ─────────────────────────────────────────────▶
UI Intent
──●────────●────────────●──────────────●────────▶
│ │ │ │
│ │ │ └─ sortingChanged(title, asc)
│ │ └──────────────── categoryFilterToggled(architecture)
│ └──────────────────────────── categoryFilterToggled(angular)
└──────────────────────────────────── categoryFilterToggled(architecture)

The individual events aren’t the finished list.

They’re intents.

Events describe intents, not finished lists.

A single click only says:

This category got toggled.

It doesn’t say:

Here’s the new visible list.

That matters.

Because the visible list doesn’t arise in the event. It arises from the state after the event.

A common mistake is modeling UI mechanics as an event.

dropdownChanged;
checkboxClicked;
buttonPressed;

That describes what happened technically.

For the store, though, what matters more is what the user intended.

categoryFilterToggled;
categoryFiltersCleared;
sortingChanged;

The dropdown is only the representation.

The intent is:

A category got activated or deactivated as a filter condition. All category filters got removed. The sorting got changed.

Example with NgRx Signal Events:

import { eventGroup, type } from '@ngrx/signals/events';
export const articleFilterEvents = eventGroup({
source: 'Article List Filter',
events: {
categoryFilterToggled: type<{
readonly category: ArticleCategory;
}>(),
categoryFiltersCleared: type<void>(),
},
});
export const articleSortingEvents = eventGroup({
source: 'Article List Sorting',
events: {
sortingChanged: type<{
readonly sortBy: ArticleSortBy;
readonly sortDirection: SortDirection;
}>(),
},
});

The names deliberately describe intent rather than UI mechanics.

Not:

inputChanged;
dropdownChanged;
clickSortButton;

But:

categoryFilterToggled;
categoryFiltersCleared;
sortingChanged;

That makes the UI more interchangeable.

Whether the filter comes from a dropdown, a checkbox list, saved views, or later from query parameters isn’t decisive for the store.

Filter conditions are state.

For a classic multi-select filter, that’s not one search text, but a set of active conditions.

export type ArticleCategory = 'architecture' | 'angular' | 'testing' | 'design';
export type ArticleSortBy = 'title' | 'author' | 'category';
export type SortDirection = 'asc' | 'desc';
export interface ArticleListQueryState {
readonly selectedCategories: readonly ArticleCategory[];
readonly sortBy: ArticleSortBy;
readonly sortDirection: SortDirection;
}
export const initialArticleListQueryState: ArticleListQueryState = {
selectedCategories: [],
sortBy: 'title',
sortDirection: 'asc',
};

selectedCategories doesn’t describe the articles themselves.

It describes through which lens the user is currently viewing the already loaded articles.

No selected category means:

There’s no category restriction.

One selected category means:

Show articles that match this category.

Several selected categories mean:

Show articles that match one of these categories.

Whether this semantics is OR or AND is a deliberate product decision. With categories, OR is usually the obvious choice. With more complex facets, each filter group can have its own rules.

Query state emerges from events.

Not a list directly.

Time ─────────────────────────────────────────────▶
UI Intent
──●────────────●────────────●──────────────────▶
toggle A toggle B toggle A
selectedCategories
──[]───────[A]────────[A,B]────────[B]─────────▶

That’s the state transition that matters.

The click changes filter conditions. The list only emerges from the new state.

For backend- and OOP-minded developers, this is often the most important step.

We don’t treat the list as an object that gets actively rebuilt.

We treat the filter conditions as state. The list is a projection from that.

The component doesn’t need a raw Article[].

It needs a ViewModel.

export interface ArticleListVm {
readonly selectedCategories: readonly ArticleCategory[];
readonly sortBy: ArticleSortBy;
readonly sortDirection: SortDirection;
readonly items: readonly ArticleListItemVm[];
}
export interface ArticleListItemVm {
readonly id: string;
readonly title: string;
readonly authorName: string;
readonly category: ArticleCategory;
readonly categoryLabel: string;
}

The ViewModel contains two things:

The current query state for the UI. The visible items derived from it.

That lets the template render in a controlled way:

The dropdown knows which categories are active. The sort buttons know which state is active. The list renders vm.items.

The template doesn’t need to know more.

The derivation itself can be cleanly expressed as pure functions.

export const filterArticles = ({
articles,
selectedCategories,
}: {
readonly articles: readonly Article[];
readonly selectedCategories: readonly ArticleCategory[];
}): readonly Article[] => {
if (selectedCategories.length === 0) {
return articles;
}
return articles.filter(({ category }) =>
selectedCategories.includes(category),
);
};

The filter function doesn’t change anything.

It produces a projection over existing data.

Sorting has to be just as careful.

Not like this:

articles.sort((left, right) => left.title.localeCompare(right.title));

sort() mutates the array.

That’s dangerous in reactive derivations, because it can change the original resource data. A projection suddenly turns into a side effect.

Better:

export const sortArticles = ({
articles,
sortBy,
sortDirection,
}: {
readonly articles: readonly Article[];
readonly sortBy: ArticleSortBy;
readonly sortDirection: SortDirection;
}): readonly Article[] => {
const directionFactor = sortDirection === 'asc' ? 1 : -1;
return [...articles].sort(
(left, right) => compareArticles({ left, right, sortBy }) * directionFactor,
);
};
const compareArticles = ({
left,
right,
sortBy,
}: {
readonly left: Article;
readonly right: Article;
readonly sortBy: ArticleSortBy;
}): number => {
switch (sortBy) {
case 'title':
return left.title.localeCompare(right.title);
case 'author':
return left.author.name.localeCompare(right.author.name);
case 'category':
return left.categoryLabel.localeCompare(right.categoryLabel);
}
};

Alternatively, toSorted() can be used in modern runtimes. The important point, though, isn’t the specific API.

The important point is:

Sorting doesn’t change the resource data. Sorting produces a new projection.

The ViewModel then emerges from a small pipeline:

export const toArticleListViewModel = ({
articles,
selectedCategories,
sortBy,
sortDirection,
}: {
readonly articles: readonly Article[] | null;
readonly selectedCategories: readonly ArticleCategory[];
readonly sortBy: ArticleSortBy;
readonly sortDirection: SortDirection;
}): ArticleListVm | null => {
if (articles === null) {
return null;
}
const filteredArticles = filterArticles({
articles,
selectedCategories,
});
const sortedArticles = sortArticles({
articles: filteredArticles,
sortBy,
sortDirection,
});
return {
selectedCategories,
sortBy,
sortDirection,
items: sortedArticles.map(toArticleListItemViewModel),
};
};
const toArticleListItemViewModel = ({
id,
title,
author,
category,
categoryLabel,
}: Article): ArticleListItemVm => ({
id,
title,
authorName: author.name,
category,
categoryLabel,
});

That’s testable.

The mapper doesn’t interpret a technical resource status.

It only projects:

readonly Article[] | null
→ ArticleListVm | null

The absence of a readable value was already normalized at the infrastructure boundary.

Without Angular. Without a template. Without a component fixture. Without the DOM.

A test can check directly:

Given these articles, these active categories, and this sorting, this ViewModel emerges.

Now for the reactive part.

The ViewModel emerges from several inputs:

Time ─────────────────────────────────────────────▶
articles
──●────────────────────────────────────────────▶
Article[]
selectedCategories
──[]──────[architecture]────[architecture,angular]──▶
sortState
──title asc────────────────────category desc────▶
vm()
──VM₁────VM₂──────────────────VM₃────────VM₄────▶

When one of the inputs changes, the derivation gets invalidated.

On the next read, a new ViewModel emerges.

computed reads the resource and query state. Every change invalidates the derivation.

The computed here isn’t a nice detail.

It’s the architectural boundary:

Source Signal
+ Query State
= ViewModel

Now the derivation gets wired up in the store.

ArticleResource provides the loaded articles as a named source signal. The store holds the query state. withComputed produces the ViewModel.

The concrete HttpResourceRef stays completely in the infrastructure.

That means the store knows neither:

  • hasValue()
  • the throwing behavior of value()
  • ResourceStatus
  • request or parse details

It only consumes:

articles: Signal<readonly Article[] | null>
import { computed, inject } from '@angular/core';
import {
patchState,
signalStore,
withComputed,
withProps,
withState,
} from '@ngrx/signals';
import { Events, withEventHandlers } from '@ngrx/signals/events';
import { tap } from 'rxjs';
export const ArticleListStore = signalStore(
withState(initialArticleListQueryState),
withProps(() => ({
_articleResource: inject(ArticleResource),
})),
withComputed(
({
_articleResource,
selectedCategories,
sortBy,
sortDirection,
}) => ({
vm: computed(() =>
toArticleListViewModel({
articles: _articleResource.articles(),
selectedCategories: selectedCategories(),
sortBy: sortBy(),
sortDirection: sortDirection(),
}),
),
}),
),
withEventHandlers((store, events = inject(Events)) => ({
toggleCategoryOnCategoryFilterToggled$: events
.on(articleFilterEvents.categoryFilterToggled)
.pipe(
tap(({ category }) => {
const selectedCategories = toggleSelectedCategory({
selectedCategories: store.selectedCategories(),
category,
});
patchState(store, { selectedCategories });
}),
),
clearCategoriesOnCategoryFiltersCleared$: events
.on(articleFilterEvents.categoryFiltersCleared)
.pipe(
tap(() => {
patchState(store, { selectedCategories: [] });
}),
),
setSortingOnSortingChanged$: events
.on(articleSortingEvents.sortingChanged)
.pipe(
tap(({ sortBy, sortDirection }) => {
patchState(store, {
sortBy,
sortDirection,
});
}),
),
})),
);

The small toggle function stays pure too:

const toggleSelectedCategory = ({
selectedCategories,
category,
}: {
readonly selectedCategories: readonly ArticleCategory[];
readonly category: ArticleCategory;
}): readonly ArticleCategory[] => {
if (selectedCategories.includes(category)) {
return selectedCategories.filter(
(selectedCategory) => selectedCategory !== category,
);
}
return [...selectedCategories, category];
};

The structure is deliberately simple.

withProps injects the infrastructure adapter. withState holds the query state. withComputed derives the ViewModel. withEventHandlers reacts to domain intents.

The store reads, as a result, like an architecture overview:

articles
+ selectedCategories
+ sortBy
+ sortDirection
→ ArticleListVm
filter intent
→ Query State

The technical guarding of the Angular resource is no longer visible in it.

Not because it vanished.

But because it was already encapsulated at the technical source.

For collections, an empty array is fundamentally a valid value.

For this article, though, the source stays, at first:

readonly Article[] | null

Because the template currently distinguishes between:

no readable value yet
→ placeholder
successfully loaded empty list
→ ViewModel with items: []

An immediate [] default would also be possible.

Then loading, or “not yet loaded,” would have to be distinguished from the real empty state through a separate signal.

That would be a different, equally valid design.

Here, null deliberately stays the normalized absence of a readable source value.

The role of tap is interesting too.

In a lot of RxJS discussions, tap gets rightly criticized when it hides application logic somewhere. Here, the situation is different.

We’re at an imperative boundary inside the store.

An event comes in. The store state gets changed.

That’s exactly what tap stands for here:

tap(({ category }) => {
const selectedCategories = toggleSelectedCategory({
selectedCategories: store.selectedCategories(),
category,
});
patchState(store, { selectedCategories });
});

That’s not a hidden side effect in the component.

That’s the defined spot where an intent updates the query state.

Outside this boundary, the list stays a derivation.

The computed reads four things:

articleResource.articles()
selectedCategories()
sortBy()
sortDirection()

If one of them changes, the derivation gets invalidated.

On the next read, a new ViewModel emerges.

That’s the central idea.

The user doesn’t click the list around. They change selectedCategories.

The user doesn’t sort the array. They change sortBy and sortDirection.

The visible list is the consequence of this state.

articles + selectedCategories + sortBy + sortDirection
→ ArticleListVm

That way, the UI doesn’t work against the framework. It uses the framework’s reactive model.

State changes. Derivations become invalid. On read, the new projection emerges.

Facade: exposing intents through the facade

Section titled “Facade: exposing intents through the facade”

The component shouldn’t have to know the store directly.

A small facade is enough:

import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
@Injectable()
export class ArticleListFacade {
private readonly store = inject(ArticleListStore);
private readonly dispatchFilter = injectDispatch(articleFilterEvents);
private readonly dispatchSorting = injectDispatch(articleSortingEvents);
readonly vm = this.store.vm;
readonly toggleCategoryFilter = (category: ArticleCategory): void => {
this.dispatchFilter.categoryFilterToggled({ category });
};
readonly clearCategoryFilters = (): void => {
this.dispatchFilter.categoryFiltersCleared();
};
readonly changeSorting = ({
sortBy,
sortDirection,
}: {
readonly sortBy: ArticleSortBy;
readonly sortDirection: SortDirection;
}): void => {
this.dispatchSorting.sortingChanged({ sortBy, sortDirection });
};
}

The facade translates UI calls into domain intents.

The component doesn’t have to know whether a store, events, signals, or later additional logic sits behind it.

It gets:

vm;
toggleCategoryFilter();
clearCategoryFilters();
changeSorting();

That’s its interface.

The template stays boring.

That’s

@let vm = facade.vm();
@if (vm) {
<button
type="button"
[attr.aria-pressed]="vm.selectedCategories.includes('architecture')"
(click)="facade.toggleCategoryFilter('architecture')"
>
Architecture
</button>
<button
type="button"
[attr.aria-pressed]="vm.selectedCategories.includes('angular')"
(click)="facade.toggleCategoryFilter('angular')"
>
Angular
</button>
<button
type="button"
(click)="facade.clearCategoryFilters()"
>
Reset filters
</button>
<button
type="button"
(click)="
facade.changeSorting({
sortBy: 'title',
sortDirection: 'asc'
})
"
>
Title ascending
</button>
@for (item of vm.items; track item.id) {
<article>
<h2>{{ item.title }}</h2>
<p>{{ item.authorName }}</p>
<p>{{ item.categoryLabel }}</p>
</article>
}
} @else {
<app-list-placeholder />
}

The template doesn’t filter. The template doesn’t sort. The template renders vm.items.

It displays input and sends back intents.

Nothing more.

That’s not a limitation on the UI. That’s relief.

The less list logic sits in the template, the clearer the responsibility stays:

The UI shows the state. The UI reports user intents. The store holds query state. The ViewModel projects.

Why this can feel unfamiliar for OOP-minded developers

Section titled “Why this can feel unfamiliar for OOP-minded developers”

In classic object-oriented thinking, it’s natural to take a list and call methods on it.

list.activateFilter(category);
list.sortByTitle();
list.getVisibleItems();

Or translated into component logic:

this.visibleItems = this.items.filter(...);

That feels direct.

In the frontend, though, modern frameworks don’t primarily work that way. Angular, React, and Vue don’t want us to manually build small controllers everywhere that keep the visible state in sync.

They work better when we express:

This is the state. This is the derivation from it. This is the rendering of that derivation.

For reactive filters, that means:

Not:

When the user clicks, I rebuild the list.

But:

When the user clicks, the filter state changes. The list follows from that.

That’s not academic.

It reduces the number of places where state can drift apart.

This article describes client-side filtering and sorting on already loaded data.

That makes sense when:

The data volume is small enough. The list is already loaded. The operation is presentation-oriented. The result does not require another server-side query.

Server-side filtering and sorting makes sense when:

The payload is large. Pagination is involved. Permissions are relevant. Performance or data volume argues against client-side processing. The query determines which data may be loaded in the first place.

The takeaway:

Client filters sort existing state. Server filters change the query of the infrastructure source.

Even with server-side filtering, the idea stays similar: the UI sends intents, the store holds query state, and ArticleResource derives the technical resource query from it. Only the derivation then no longer lives entirely on the client, but partly in the data source.

That’s its own topic.

Here, we deliberately stay with existing resource data.

This structure looks somewhat more elaborate at first than a quick items.filter(...) in the component.

But it buys clarity.

The query state is visible. The derivation is central. The pure functions are testable. The component stays thin. The template stays free of list logic. The source data doesn’t get mutated. The technical httpResource stays in the infrastructure.

Above all, though, the mental model becomes clear:

UI Intent
→ Query State
→ ViewModel Derivation
→ Rendering

That’s tactical frontend design.

Not because it’s complicated. But because it cleanly separates responsibilities.

Filtering and sorting aren’t component code.

They’re query state.

The list isn’t a mutated array. It’s a derivation.

The infrastructure provides safe source signals. The UI sends intent. The store holds query state and orchestrates the projection. The ViewModel projects. The template renders.

That’s the decisive shift in perspective:

The component doesn’t build the visible list.

The visible list emerges reactively from existing articles, active filter conditions, and sort state.