Skip to main content

Angular Signals: Handling toSignal() Initial Values and Async Data

 You have migrated a component to Angular Signals. You take an existing RxJS Observable, wrap it in toSignal(), and immediately hit a friction point.

TypeScript infers the signal’s type as Signal<T | undefined>, forcing you to use non-null assertions (!) or ?. checks in your template. Alternatively, you might encounter the runtime error: NG0600: toSignal() can only be used within an injection context.

These issues stem from a fundamental mismatch between the asynchronous nature of RxJS streams and the synchronous requirements of Signals.

This guide details exactly how to bridge that gap using initialValue and requireSync, ensuring your application remains type-safe and runtime-stable.

The Root Cause: The Temporal Gap

To understand why toSignal defaults to undefined, we must look at the architecture of Signals versus Observables.

Signals are Synchronous

A Signal represents a value that exists right now. When you access a signal (mySignal()), it must return a specific value immediately to update the DOM or derive a computed state.

Observables are Asynchronous

An Observable represents a stream of values over time. When you subscribe to an HTTP call in Angular, that data might arrive 200ms later.

The Conflict

When you call toSignal(source$), Angular creates a Signal immediately. However, if source$ is an HTTP request, the data hasn't arrived yet.

What should the Signal return during that 200ms gap? By default, Angular has no choice but to return undefined. This breaks strict typing if your application expects the data to be guaranteed.

The Problematic Code Pattern

Here is the common pattern that introduces the Signal<User | undefined> issue.

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

interface User {
  id: number;
  name: string;
}

@Component({
  template: `
    <!-- 
      Error: user() is possibly undefined.
      We have to check or use optional chaining.
    -->
    @if (user()) {
      <h1>Hello, {{ user()?.name }}</h1>
    }
  `
})
export class UserProfileComponent {
  private http = inject(HttpClient);

  // INFERRED TYPE: Signal<User | undefined>
  user = toSignal(this.http.get<User>('/api/user')); 
}

While the code runs, the type definition is loose. We lose the guarantee that user exists, complicating downstream logic in computed signals.

Solution 1: providing an initialValue

If you are dealing with asynchronous data (like an HTTP GET request), you must explicitly tell the Signal what to hold while waiting for the response.

You do this via the options object in the second argument of toSignal.

// INFERRED TYPE: Signal<User | null>
// We explicitly allow null, but remove 'undefined' ambiguity
user = toSignal(this.http.get<User>('/api/user'), { 
  initialValue: null 
});

Or, providing a default object:

const defaultUser: User = { id: 0, name: 'Guest' };

// INFERRED TYPE: Signal<User>
// Guaranteed to always have a User, never null or undefined
user = toSignal(this.http.get<User>('/api/user'), { 
  initialValue: defaultUser 
});

Why this works

By providing an initialValue, the Signal is instantiated synchronously with that value. When the Observable emits later, the Signal updates. TypeScript correctly narrows the type because the "gap" is filled.

Solution 2: The requireSync Option

Sometimes, you know for a fact that your Observable is synchronous. Common examples include:

  • BehaviorSubject
  • of('value')
  • Observables implementing startWith()

In these cases, providing an initialValue feels redundant because the Observable emits immediately upon subscription.

You can use { requireSync: true } to enforce this behavior.

import { BehaviorSubject } from 'rxjs';

// A synchronous source
const count$ = new BehaviorSubject<number>(10);

// INFERRED TYPE: Signal<number>
// No 'undefined' union type
count = toSignal(count$, { requireSync: true });

The Risk of requireSync

If you use requireSync: true on an asynchronous stream (like HttpClient), Angular will throw a runtime error:

Error: NG0600: toSignal() called with {requireSync: true} but observable did not emit synchronously.

Only use this option when you control the Observable source and can guarantee a synchronous emission.

Handling Loading States with Signals

In real-world applications, simply initializing with null often isn't enough. You need to know when the data has arrived.

By combining toSignal with computed, we can create robust state derivation without manual subscriptions.

import { Component, inject, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({
  standalone: true,
  selector: 'app-dashboard',
  template: `
    @if (isLoading()) {
      <div class="spinner">Loading...</div>
    } @else {
      <div class="data">{{ data()?.title }}</div>
    }
  `
})
export class DashboardComponent {
  private http = inject(HttpClient);

  // Initialize with null so we can detect the "not loaded" state
  private source = toSignal(
    this.http.get<{title: string}>('/api/dashboard'), 
    { initialValue: null }
  );

  // Derived state
  isLoading = computed(() => this.source() === null);
  
  // Data exposure
  data = computed(() => this.source());
}

Advanced Pitfall: startWith vs initialValue

RxJS veterans often reach for the startWith operator instead of the toSignal configuration object.

// Approach A: RxJS Operator
userA = toSignal(
  this.http.get('/user').pipe(startWith(null)), 
  { requireSync: true } // Required because startWith makes it sync
);

// Approach B: Signal Config
userB = toSignal(
  this.http.get('/user'), 
  { initialValue: null }
);

Which should you use?

Use Approach B (Signal Config).

While both work, Approach B is cleaner semantically. It separates the stream definition (fetching data) from the state definition (default values).

Furthermore, using requireSync with startWith tightly couples the signal creation to the specific operator chain, making refactoring harder later.

Handling Error States

Crucially, toSignal does not catch errors by default. If the Observable errors, the Signal will throw that error whenever it is read. This is often desirable for Error Boundaries, but can crash your component logic if unhandled.

To handle errors gracefully, catch them in the RxJS stream before they reach the Signal.

import { catchError, of } from 'rxjs';

user = toSignal(
  this.http.get<User>('/api/user').pipe(
    catchError(err => {
      console.error('Fetch failed', err);
      // Return a fallback value or null to keep the signal valid
      return of(null);
    })
  ), 
  { initialValue: null }
);

Conclusion

The transition from RxJS to Signals requires a mental shift from "push-based streams" to "poll-based values."

When using toSignal, always assess the timing of your data:

  1. If the data is Async (HTTP, timers): Use { initialValue: ... }.
  2. If the data is Sync (BehaviorSubject, State Management): Use { requireSync: true }.

By explicitly defining the initial state, you satisfy TypeScript's strictness and ensure your templates render predictable content from the very first frame.