Skip to content

ViewModel from multiple data sources

A ViewModel doesn’t always emerge from exactly one API response.

Reality often looks more like this:

articles
authors
categories
→ ArticleOverviewVm

Or more generally:

Main data
Reference data
Supplementary data
→ UI projection

Previously, you’d often have used forkJoin for this.

forkJoin({
articles: this.articleApi.loadArticles(),
authors: this.authorApi.loadAuthors(),
categories: this.categoryApi.loadCategories(),
}).pipe(
map(({ articles, authors, categories }) =>
toArticleOverviewViewModel({
articles,
authors,
categories,
}),
),
);

That wasn’t wrong.

For a lot of cases, that was often a very clear way to express the operation:

Start several requests.
Wait until all are done.
Then build a combined result.

The price, though, is that the result only emerges once everything is there.

Sometimes that’s exactly right.

But not always.

With signals and computed, we can think differently today.

No longer primarily:

Marry requests
→ build a result

but:

Treat data sources as reactive inputs
→ compute the ViewModel as a derivation

Requests don't get married. State gets derived.

That sounds like a small difference.

In practice, though, it changes the architecture.


This article builds on the retrieve slice.

Everything around it stays the same:

Infrastructure encapsulates the resource lifecycle
Infrastructure provides safe source signals
Store orchestrates sources and the projection
Facade exposes the ViewModel
Component renders

The only difference is:

This time, the ViewModel emerges from multiple data sources.

We deliberately don’t cover the complete data flow here.

Not the topic of this article:

  • command flows
  • events
  • navigation
  • a complete ACL implementation
  • detailed DTO validation
  • error strategy
  • caching
  • entity normalization
  • loading UX in the main example

We only sketch the API boundary:

parse response
→ mapToDomain

The focus is on the ViewModel projection, and on the question:

When do I wait for all data sources? And when do I build a partial ViewModel that fills in later?

Along the way, we separate two kinds of guards:

technical resource guard
→ belongs in the infrastructure
projection guard
→ decides which sources are enough for a valid ViewModel

This distinction matters.

hasValue() protects the technical Angular resource.

Whether articles, authors, and categories are enough for this concrete UI projection, on the other hand, is part of the ViewModel contract.


We’re building an article overview.

The main list comes from articles.

The authors come from authors.

The categories come from categories.

articles
→ id, title, authorId, categoryId
authors
→ id, name
categories
→ id, label

The UI doesn’t want a technical data model, though.

The UI wants a ViewModel:

ArticleOverviewVm
→ title
→ authorName
→ categoryLabel

The mapping, then, emerges from three sources:

article.authorId
→ authors.find(author.id)
article.categoryId
→ categories.find(category.id)

The infrastructure loads sources. The ViewModel aggregates them as a derivation.


Infrastructure: three encapsulated sources

Section titled “Infrastructure: three encapsulated sources”

The infrastructure stays deliberately thin.

It loads data, maps external responses at the API boundary into the domain model, and encapsulates the technical lifecycle of the Angular resource.

The concrete HttpResourceRef stays private.

Only named, safe signals and explicit operations go out.

import { computed, Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Article } from '../entities/article.model';
import { mapArticlesResponseToDomain } from './article.mapper';
@Injectable()
export class ArticlesResource {
private readonly resource = httpResource<readonly Article[]>(
() => '/api/articles',
{
parse: mapArticlesResponseToDomain,
},
);
readonly articles = computed<readonly Article[] | null>(() => {
if (!this.resource.hasValue()) {
return null;
}
return this.resource.value();
});
readonly isLoading = this.resource.isLoading;
readonly error = this.resource.error;
reload(): void {
this.resource.reload();
}
}
import { computed, Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Author } from '../entities/author.model';
import { mapAuthorsResponseToDomain } from './author.mapper';
@Injectable()
export class AuthorsResource {
private readonly resource = httpResource<readonly Author[]>(
() => '/api/authors',
{
parse: mapAuthorsResponseToDomain,
},
);
readonly authors = computed<readonly Author[] | null>(() => {
if (!this.resource.hasValue()) {
return null;
}
return this.resource.value();
});
readonly isLoading = this.resource.isLoading;
readonly error = this.resource.error;
reload(): void {
this.resource.reload();
}
}
import { computed, Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Category } from '../entities/category.model';
import { mapCategoriesResponseToDomain } from './category.mapper';
@Injectable()
export class CategoriesResource {
private readonly resource = httpResource<readonly Category[]>(
() => '/api/categories',
{
parse: mapCategoriesResponseToDomain,
},
);
readonly categories = computed<readonly Category[] | null>(() => {
if (!this.resource.hasValue()) {
return null;
}
return this.resource.value();
});
readonly isLoading = this.resource.isLoading;
readonly error = this.resource.error;
reload(): void {
this.resource.reload();
}
}

The three adapters handle the same technical question:

Can the concrete Angular resource be read safely?

For that, hasValue() stays exactly where the resource gets created.

The store afterward only knows:

ArticlesResource.articles
AuthorsResource.authors
CategoriesResource.categories

The aggregation itself doesn’t happen in the infrastructure.

It shouldn’t know which ViewModel gets built later.

It only knows its own respective source.

ArticlesResource
→ load articles
→ encapsulate the technical lifecycle
→ provide the articles signal
AuthorsResource
→ load authors
→ encapsulate the technical lifecycle
→ provide the authors signal
CategoriesResource
→ load categories
→ encapsulate the technical lifecycle
→ provide the categories signal

Not:

ArticlesResource
→ load articles
→ load authors
→ load categories
→ build a UI model

That would be a hidden page service again.

The infrastructure only decides whether a technical resource can be read safely.

Which available sources are enough for a valid UI contract is decided by the projection.


The overview needs a small ViewModel.

export interface ArticleOverviewVm {
readonly items: readonly ArticleOverviewItemVm[];
}
export interface ArticleOverviewItemVm {
readonly id: string;
readonly title: string;
readonly authorName: string | null;
readonly categoryLabel: string | null;
}

Notice here:

readonly authorName: string | null;
readonly categoryLabel: string | null;

That’s intentional.

null here doesn’t automatically mean “error.”

null means:

This partial piece of information isn't currently available.

That becomes important in the second case.

Because a ViewModel doesn’t always have to be binary:

ready
or
not ready

It can also be a stable UI contract that deliberately expresses missing partial information.


The aggregation itself stays pure.

The store collects the source signals.

Named projection functions decide which sources are required for the respective UI contract.

The shared builder builds the ViewModel from the available data.

import { Article } from '../entities/article.model';
import { Author } from '../entities/author.model';
import { Category } from '../entities/category.model';
import {
ArticleOverviewItemVm,
ArticleOverviewVm,
} from './article-overview.vm';
interface ArticleOverviewSources {
readonly articles: readonly Article[] | null;
readonly authors: readonly Author[] | null;
readonly categories: readonly Category[] | null;
}
interface AvailableArticleOverviewSources {
readonly articles: readonly Article[];
readonly authors: readonly Author[] | null;
readonly categories: readonly Category[] | null;
}
export const toCompleteArticleOverviewViewModel = ({
articles,
authors,
categories,
}: ArticleOverviewSources): ArticleOverviewVm | null => {
if (
articles === null ||
authors === null ||
categories === null
) {
return null;
}
return buildArticleOverviewViewModel({
articles,
authors,
categories,
});
};
export const toProgressiveArticleOverviewViewModel = ({
articles,
authors,
categories,
}: ArticleOverviewSources): ArticleOverviewVm | null => {
if (articles === null) {
return null;
}
return buildArticleOverviewViewModel({
articles,
authors,
categories,
});
};
const buildArticleOverviewViewModel = ({
articles,
authors,
categories,
}: AvailableArticleOverviewSources): ArticleOverviewVm => {
const authorsById = authors
? new Map(authors.map((author) => [author.id, author]))
: null;
const categoriesById = categories
? new Map(categories.map((category) => [category.id, category]))
: null;
return {
items: articles.map(
(article): ArticleOverviewItemVm => ({
id: article.id,
title: article.title,
authorName:
authorsById?.get(article.authorId)?.name ?? null,
categoryLabel:
categoriesById?.get(article.categoryId)?.label ?? null,
}),
),
};
};

The two public functions answer different questions:

toCompleteArticleOverviewViewModel
→ Are all sources present?
toProgressiveArticleOverviewViewModel
→ Is the leading source present?

The shared builder only answers:

How does the ViewModel emerge from the available data?

That keeps three responsibilities separate:

Infrastructure
→ secure the technical resource
Projection function
→ determine the required sources
Builder
→ translate available sources into the ViewModel

The store no longer needs any conditions.

It only picks a named projection and connects it to the source signals.

Case 1: projection guard — all sources are required

Section titled “Case 1: projection guard — all sources are required”

Sometimes the page only makes sense when all data is there.

Then a complete projection guard makes sense:

articles missing
→ no VM
authors missing
→ no VM
categories missing
→ no VM
everything present
→ build VM

This is the modern signal variant of:

wait for everything
→ build the result

Except we’re not aggregating requests.

We’re aggregating resource state.

If every source is genuinely required, the guard protects the ViewModel.

import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withProps } from '@ngrx/signals';
import { ArticlesResource } from '../infrastructure/articles.resource';
import { AuthorsResource } from '../infrastructure/authors.resource';
import { CategoriesResource } from '../infrastructure/categories.resource';
import { toCompleteArticleOverviewViewModel } from './article-overview.mapper';
export const ArticleOverviewStore = signalStore(
withProps(() => ({
_articlesResource: inject(ArticlesResource),
_authorsResource: inject(AuthorsResource),
_categoriesResource: inject(CategoriesResource),
})),
withComputed(
({
_articlesResource,
_authorsResource,
_categoriesResource,
}) => ({
vm: computed(() =>
toCompleteArticleOverviewViewModel({
articles: _articlesResource.articles(),
authors: _authorsResource.authors(),
categories: _categoriesResource.categories(),
}),
),
}),
),
);

The ViewModel here is only present once all three source signals deliver values.

The store itself contains no visible guard logic.

The decision sits in the named projection:

articles
+ authors
+ categories
→ toCompleteArticleOverviewViewModel()
→ ArticleOverviewVm | null

During this time, the UI can show a simple placeholder:

@let vm = facade.vm();
@if (vm) {
@for (item of vm.items; track item.id) {
<article>
<h2>{{ item.title }}</h2>
<p>{{ item.authorName }}</p>
<p>{{ item.categoryLabel }}</p>
</article>
}
} @else {
<app-overview-placeholder />
}

That’s simple.

And it’s correct when partial information is not useful to the UI on its own.


computed remembers which signals were read during the computation.

In our case:

_articlesResource.articles()
_authorsResource.authors()
_categoriesResource.categories()

The technical resource API has already disappeared here.

computed only observes the application-close source signals.

As soon as one of the read dependencies changes, the derivation becomes invalid.

On the next read of vm(), it gets recomputed.

That’s the decisive point.

Not the store manually pushing a new VM.

Not the component calling rebuildVm().

Not a service merging three subscriptions.

But:

Source signal changes
→ computed becomes invalid
→ vm() gets read again
→ a new derivation emerges
→ the template renders the new state

That’s the mental shift.


Case 2: projection guard — only the main source is required

Section titled “Case 2: projection guard — only the main source is required”

Now it gets interesting.

Maybe articles is the main source.

Without articles, the page can’t show anything.

But authors and categories are only enrichments.

Then the UI doesn’t have to wait for everything.

articles missing
→ no VM
articles present
→ build VM
authors missing
→ authorName: null
categories missing
→ categoryLabel: null
authors arrive later
→ VM updates
categories arrive later
→ VM updates

That’s not a hack.

That’s a deliberately partial ViewModel.

import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withProps } from '@ngrx/signals';
import { ArticlesResource } from '../infrastructure/articles.resource';
import { AuthorsResource } from '../infrastructure/authors.resource';
import { CategoriesResource } from '../infrastructure/categories.resource';
import { toProgressiveArticleOverviewViewModel } from './article-overview.mapper';
export const ArticleOverviewStore = signalStore(
withProps(() => ({
_articlesResource: inject(ArticlesResource),
_authorsResource: inject(AuthorsResource),
_categoriesResource: inject(CategoriesResource),
})),
withComputed(
({
_articlesResource,
_authorsResource,
_categoriesResource,
}) => ({
vm: computed(() =>
toProgressiveArticleOverviewViewModel({
articles: _articlesResource.articles(),
authors: _authorsResource.authors(),
categories: _categoriesResource.categories(),
}),
),
}),
),
);

The store code only differs by the chosen projection.

But the effect is big.

In the first case, we say:

I only build the VM
once all sources are there.

In the second case, we say:

I need the main source.
Everything else is progressive enrichment.

The main data carries the page. Supplementary data enriches the ViewModel later.

As soon as authorsResource gets a value later, vm gets recomputed.

Then:

authorName: null

automatically becomes:

authorName: "Ada Lovelace"

Without manual patching.

Without a second subscription.

Without combineLatest in the component.

Without imperative synchronization.


The computed in the second case doesn’t just read articles.

It also reads the source signals of the secondary sources:

_articlesResource.articles()
_authorsResource.authors()
_categoriesResource.categories()

That makes all three signals dependencies of the derivation.

When AuthorsResource.authors() later switches from null to a value, the computed becomes invalid.

On the next read, a new ViewModel emerges.

AuthorsResource.authors()
null → Author[]
computed invalidates
→ vm() gets read again
→ the progressive projection runs again
→ authorName gets filled in

That’s the central point.

The UI doesn’t have to know which source just delivered a value.

It only reads vm() again.

And vm() is a new derivation of the current state.

Every arriving source invalidates computed. The ViewModel gets re-derived.


The template can deliberately handle null.

@let vm = facade.vm();
@if (vm) {
@for (item of vm.items; track item.id) {
<article>
<h2>{{ item.title }}</h2>
@if (item.authorName) {
<p>{{ item.authorName }}</p>
} @else {
<p>Author not yet available</p>
}
@if (item.categoryLabel) {
<p>{{ item.categoryLabel }}</p>
} @else {
<p>Category not yet available</p>
}
</article>
}
} @else {
<app-overview-placeholder />
}

The component doesn’t decide which APIs are missing.

It only renders the contract:

vm === null
→ main data missing
authorName === null
→ author not available
categoryLabel === null
→ category not available

That keeps the component flat.


A small but important trap:

const authors = _authorsResource.authors() ?? [];

That looks convenient.

But it blurs two states.

[]

can mean:

not yet loaded

or:

loaded, but empty

Those are different statements.

That’s why, for dependent sources, it’s often better to do this:

const authors = _authorsResource.authors();

Then the contract is clear:

null
→ source isn't available yet
[]
→ source is available, but contains no entries

That makes the UI more honest.

And the tests better.

The distinction stays part of the source contract:

AuthorsResource.authors()
→ null | readonly Author[]

The store doesn’t have to know how the infrastructure derives this contract from httpResource.


This article isn’t a plea against forkJoin.

forkJoin still makes sense when the workflow genuinely requires exactly this:

Start several one-time operations.
Wait for all results.
Then continue.

For example:

  • preparing an export
  • only opening a dialog once all master data is loaded
  • fully populating a wizard initially
  • starting a one-time computation
  • completing several commands and then moving on

But for page ViewModels, forkJoin is often not the best mental model.

A page ViewModel is rarely just a one-time result.

It’s usually a reactive projection.

Source Signal A
Source Signal B
Source Signal C
→ computed
→ ViewModel

If a source changes, the ViewModel shouldn’t get re-orchestrated.

It should re-derive.


In this article, I deliberately didn’t build loading into the ViewModel.

That would be a next step.

From the same pattern, you can later derive very precise loading states:

authorName missing
and AuthorsResource.isLoading() is true
→ local author skeleton
categoryLabel missing
and CategoriesResource.isLoading() is true
→ local category skeleton

Then loading would no longer be a global switch for the whole page.

Instead, part of the ViewModel contract.

The UI could already show articles and only mark the areas with skeletons whose secondary sources are still missing.

That is a powerful extension.

But it’s not necessary for the basic pattern.

The basic pattern is:

encapsulate technical resources in the infrastructure
read source signals
apply projection guards
deliberately model missing secondary data as null
derive the ViewModel reactively

The real point isn’t whether you write three HTTP calls with forkJoin or three resources with computed.

The real point is:

Do I see my ViewModel as the result of a request? Or as a derivation from reactive state?

That’s a fundamental difference.

With forkJoin, I think in terms of completion:

all requests done
→ build the result

With computed, I think in terms of validity:

which source signals currently deliver values?
→ derive a valid VM from that

That opens up new possibilities.

Not just:

load everything
then show everything

but also:

show the main data
fill in secondary data
explicitly model missing parts
keep the UI contract stable

That’s the point where frontend architecture becomes enjoyable.

Not because the code gets more complicated.

But because the code can express more.


A ViewModel from multiple data sources isn’t a special case.

It is a common frontend boundary.

The only question is how deliberately we model it.

Case 1:
All sources are required.
→ complete projection
→ only build the VM once all source signals deliver values
Case 2:
One source is leading.
Other sources enrich.
→ progressive projection
→ only the main source is required
→ model missing parts as null
→ the VM updates as soon as secondary data arrives

computed is the decisive building block for this.

It reads named source signals.

It remembers the read dependencies.

It becomes invalid when one of these dependencies changes.

And it produces a new derivation on the next read.

Source signal changes
→ computed invalidates
→ the ViewModel gets re-derived
→ the UI renders the new state

That’s the difference from before.

Not because everything used to be wrong.

But because we have more control over the UI contract today.

The central boundary stays clear:

Infrastructure
→ decides whether a technical resource can be read safely
ViewModel projection
→ decides which available sources are enough for a valid UI contract

And when you do this cleanly, possibilities suddenly emerge:

partial ViewModels
flat components
testable mappers
visible derivations
local skeletons later too

That’s exactly where the value lies.

Not in more framework magic.

But in clearer modeling.