DEV Community

Cover image for 💯Resource API Mastery: How `resource()` Changes the Architecture of Data Fetching in Modern Angular
ABDELAAZIZ OUAKALA
ABDELAAZIZ OUAKALA

Posted on

💯Resource API Mastery: How `resource()` Changes the Architecture of Data Fetching in Modern Angular

Resource API Mastery: How resource() Changes the Architecture of Data Fetching in Modern Angular

The biggest benefit of resource() isn't writing less code — it's making asynchronous state predictable.


Table of Contents

  1. Introduction: The Async State Problem
  2. The Traditional Pattern: What We've Been Doing
  3. The Real Cost of Scattered State
  4. Meet resource(): Angular's Signal-Native Async Primitive
  5. How resource() Works Under the Hood
  6. The Automatic Lifecycle: Cancel, Reload, Update
  7. Developer Experience: What Changes in Your Day-to-Day
  8. Architecture Patterns with resource()
  9. Template Patterns: Loading, Error, and Success UI
  10. When NOT to Use the Resource API
  11. Where Each Tool Fits: HttpClient, RxJS, and Resource API
  12. Testing Strategies
  13. Performance Considerations
  14. Migration Strategy: Moving from Traditional Patterns
  15. Best Practices Summary
  16. Common Mistakes to Avoid
  17. Enterprise Considerations
  18. Conclusion: The Architecture Mindset Shift

Introduction: The Async State Problem

If you've built Angular applications at scale, you've written this code hundreds of times:

export class SomeComponent {
  loading = false;
  error: string | null = null;
  data: SomeData | null = null;

  ngOnInit() {
    this.loading = true;
    this.http.get('/api/data').subscribe({
      next: (res) => { this.data = res; this.loading = false; },
      error: (err) => { this.error = err.message; this.loading = false; }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

It works. It's familiar. And it's quietly one of the most expensive patterns in your codebase.

Not because it's buggy — because it's scattered state. Loading, error, data, and cleanup logic are strewn across properties, lifecycle hooks, subscription handlers, and template branches. Every component reinvents the same wheel. Every team develops slightly different conventions. And every new developer has to learn "how we do async here" before they can ship a feature.

In modern Angular applications, the Resource API (resource()) addresses this at the architectural level. It doesn't just reduce boilerplate — it changes how we think about asynchronous data by treating it as first-class reactive state within Angular's Signals ecosystem.

This post is not an introduction to the Resource API. It's an architecture deep-dive for senior Angular developers, software architects, and enterprise teams who want to understand why it exists, when to use it, and how it changes component design.


The Traditional Pattern: What We've Been Doing

Let's be precise about what we're replacing. The traditional Angular async pattern typically looks like this:

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

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

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    @if (loading) {
      <div class="skeleton-loader">
        <div class="skeleton-avatar"></div>
        <div class="skeleton-text"></div>
      </div>
    } @else if (error) {
      <div class="error-banner">
        <p>{{ error }}</p>
        <button (click)="loadUser()">Retry</button>
      </div>
    } @else if (user) {
      <div class="user-card">
        <img [src]="user.avatar" [alt]="user.name" />
        <h2>{{ user.name }}</h2>
        <p>{{ user.email }}</p>
        <span class="badge">{{ user.role }}</span>
      </div>
    }
  `
})
export class UserProfileComponent implements OnInit {
  private http = inject(HttpClient);
  private destroyRef = inject(DestroyRef);

  // State properties — scattered and imperative
  loading = false;
  error: string | null = null;
  user: User | null = null;

  // We need a userId, but if it changes, we need to reload
  userId = 1;

  ngOnInit(): void {
    this.loadUser();
  }

  loadUser(): void {
    this.loading = true;
    this.error = null;

    this.http.get<User>(`/api/users/${this.userId}`)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next: (user) => {
          this.user = user;
          this.loading = false;
        },
        error: (err) => {
          this.error = err.message ?? 'Failed to load user';
          this.loading = false;
        }
      });
  }

  refresh(): void {
    this.loadUser();
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern has several characteristics:

  1. Imperative state management — We manually set and reset flags.
  2. Scattered logic — State updates happen in subscription handlers, far from where state is declared.
  3. Manual cleanuptakeUntilDestroyed or ngOnDestroy subscriptions are required.
  4. No automatic reload — If userId changes, we need explicit logic to trigger a new fetch.
  5. No cancellation — If the component is destroyed mid-request, the subscription may leak or the callback may fire on a destroyed view.
  6. Template complexity — The template must know about three separate properties to render correctly.

Multiply this by 200 components in an enterprise application, and you have a consistency problem.


The Real Cost of Scattered State

Before we look at the solution, let's understand the organizational cost of the traditional pattern:

1. Duplicated Boilerplate

Every component that fetches data needs loading, error, data, subscription handling, and cleanup. In a large application, this is thousands of lines of identical infrastructure code.

2. Inconsistent UX

One team shows a spinner. Another shows a skeleton. A third disables the button. A fourth does nothing and lets the UI hang. Without a standardized async state model, your application's loading and error experiences are inconsistent by default.

3. Bug-Prone Cleanup

Forgetting takeUntilDestroyed or missing an unsubscribe leads to memory leaks. Setting loading = false in only one branch of a subscription handler leads to stuck loading states. These are easy to miss in code review because they're "obvious" — until they're not.

4. Testing Friction

Testing imperative async state requires simulating timing, asserting on multiple properties, and verifying cleanup. The test mirrors the complexity of the component.

5. Cognitive Load

Developers shouldn't have to think about async plumbing. They should think about business logic. Scattered state forces every developer to be an async state management expert.

The contrarian truth: The biggest problem with async code isn't subscriptions — it's scattered state. You can have perfect RxJS operators and still ship a broken UX because your loading flag is three properties away from your data.


Meet resource(): Angular's Signal-Native Async Primitive

The Resource API, introduced in Angular's Signals ecosystem, provides a single reactive primitive for component-level asynchronous state.

Here's the same component, rewritten:

import { Component, inject, signal, resource } from '@angular/core';
import { HttpClient } from '@angular/common/http';

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

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    @if (userResource.isLoading()) {
      <div class="skeleton-loader">
        <div class="skeleton-avatar"></div>
        <div class="skeleton-text"></div>
      </div>
    } @else if (userResource.error()) {
      <div class="error-banner">
        <p>{{ userResource.error() }}</p>
        <button (click)="userResource.reload()">Retry</button>
      </div>
    } @else {
      <div class="user-card">
        <img [src]="userResource.value()?.avatar" [alt]="userResource.value()?.name" />
        <h2>{{ userResource.value()?.name }}</h2>
        <p>{{ userResource.value()?.email }}</p>
        <span class="badge">{{ userResource.value()?.role }}</span>
      </div>
    }
  `
})
export class UserProfileComponent {
  private http = inject(HttpClient);
  userId = signal(1);

  userResource = resource({
    request: () => ({ id: this.userId() }),
    loader: ({ request }) => 
      this.http.get<User>(`/api/users/${request.id}`)
  });
}
Enter fullscreen mode Exit fullscreen mode

What just happened?

  • 5 properties became 1 resource.
  • Manual subscription handling became automatic lifecycle management.
  • Imperative reload logic became reactive dependency tracking.
  • Manual cleanup became automatic cancellation.

The resource() function returns a Resource object with these signal-based properties:

Property Type Description
.value() `Signal<T \ undefined>`
.isLoading() Signal<boolean> true while the loader is executing
.error() Signal<unknown> Any error thrown by the loader
.hasValue() Signal<boolean> true if data has been successfully loaded
.reload() () => void Manually trigger a re-fetch with the same request
.status() Signal<ResourceStatus> 'idle', 'loading', 'resolved', 'error', or 'reloading'

All of these are Signals. They integrate seamlessly with computed(), effect(), and Angular's change detection.


How resource() Works Under the Hood

Understanding the internals helps you use it correctly. Here's the execution model:

The Resource Contract

Signal Dependency Change
           │
           ▼
┌─────────────────────┐
│   request() runs    │  ← Re-evaluates whenever tracked signals change
│   (must be pure)    │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Previous request   │  ← Automatically cancelled via AbortController
│     cancelled       │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│   loader() executes │  ← Receives { request, abortSignal }
│   (can be async)    │
└──────────┬──────────┘
           │
     ┌─────┴─────┐
     ▼           ▼
  Success      Error
     │           │
     ▼           ▼
┌─────────┐  ┌─────────┐
│ .value()│  │ .error()│
│ updates │  │ updates │
│ .isLoading()│  │ .isLoading()│
│ = false │  │ = false │
└─────────┘  └─────────┘
Enter fullscreen mode Exit fullscreen mode

Key Behaviors

  1. Dependency Tracking: The request function is executed within a reactive context. Any Signal read inside request becomes a dependency. When that Signal changes, the resource automatically reloads.

  2. Automatic Cancellation: When a new request starts, the previous in-flight request is cancelled via an AbortSignal passed to the loader. This prevents race conditions and memory leaks.

  3. State Synchronization: The resource's internal state transitions are atomic. You never have a frame where isLoading() is false but value() is still stale, or where error() and value() are both set.

  4. Lazy Evaluation: The resource only starts loading when it's first read in a reactive context (e.g., in a template or computed()). If the resource is never consumed, no request is made.


The Automatic Lifecycle: Cancel, Reload, Update

Let's trace what happens when a dependency changes:

@Component({...})
export class ProductListComponent {
  private http = inject(HttpClient);

  // These are reactive dependencies
  categoryId = signal('electronics');
  sortBy = signal<'price' | 'name'>('name');
  page = signal(1);

  productsResource = resource({
    request: () => ({
      category: this.categoryId(),
      sort: this.sortBy(),
      page: this.page()
    }),
    loader: async ({ request, abortSignal }) => {
      const params = new URLSearchParams({
        category: request.category,
        sort: request.sort,
        page: request.page.toString()
      });

      const response = await fetch(`/api/products?${params}`, { 
        signal: abortSignal // Automatic cancellation wired in
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      return response.json() as Promise<Product[]>;
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Scenario: User changes sort order

  1. sortBy.set('price') is called.
  2. Angular's Signals system detects the change.
  3. The request function re-runs, producing { category: 'electronics', sort: 'price', page: 1 }.
  4. The resource compares the new request to the previous one. It's different, so a reload triggers.
  5. The previous fetch call receives an abort signal and is cancelled.
  6. isLoading() becomes true.
  7. The new fetch starts.
  8. On resolution, value() updates with the new products, isLoading() becomes false.
  9. Any computed() or effect() that reads productsResource.value() re-evaluates automatically.

All of this happens without you writing a single line of imperative state management.


Developer Experience: What Changes in Your Day-to-Day

Before: Imperative Mental Model

// 1. Set loading
// 2. Clear error
// 3. Make request
// 4. Handle success
// 5. Handle error
// 6. Set loading false
// 7. Handle cleanup
// 8. Handle userId changes
// 9. Handle refresh
Enter fullscreen mode Exit fullscreen mode

After: Declarative Mental Model

// 1. Define what you need
// 2. Define how to get it
// 3. Let Angular handle the rest
Enter fullscreen mode Exit fullscreen mode

The shift is from "How do I manage this state?" to "What is the relationship between my data and my UI?"

This is the same mental model shift that made React's useEffect powerful, but with Angular's Signals providing fine-grained reactivity and the Resource API providing a purpose-built abstraction for async state.


Architecture Patterns with resource()

Pattern 1: Basic CRUD with HttpClient

The most common use case: fetch data when a component loads, show loading/error/success states.

import { Component, inject, signal, resource } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface Post {
  id: number;
  title: string;
  body: string;
  authorId: number;
}

@Component({
  selector: 'app-post-detail',
  standalone: true,
  imports: [CommonModule],
  template: `
    <article class="post-container">
      @if (postResource.isLoading()) {
        <post-skeleton />
      } @else if (postResource.error()) {
        <error-fallback 
          [error]="postResource.error()"
          (retry)="postResource.reload()" 
        />
      } @else {
        @let post = postResource.value();
        <header>
          <h1>{{ post?.title }}</h1>
          <span class="meta">Post #{{ post?.id }}</span>
        </header>
        <div class="content">{{ post?.body }}</div>

        <div class="actions">
          <button (click)="postResource.reload()">🔄 Refresh</button>
          <button (click)="editPost()">✏️ Edit</button>
        </div>
      }
    </article>
  `
})
export class PostDetailComponent {
  private http = inject(HttpClient);

  // Route parameter as a signal (Angular v16.1+)
  postId = input.required<number>();

  postResource = resource({
    request: () => ({ id: this.postId() }),
    loader: ({ request }) => 
      this.http.get<Post>(`/api/posts/${request.id}`)
  });

  editPost(): void {
    // Navigation logic
  }
}
Enter fullscreen mode Exit fullscreen mode

Key insight: The postId signal input (from input.required) automatically triggers resource reloads when the route changes. No ngOnInit, no ActivatedRoute subscription, no manual cleanup.


Pattern 2: Parameterized Resources with Multiple Dependencies

Real-world components often depend on multiple reactive values: filters, pagination, sorting, search queries.

import { Component, inject, signal, resource, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

interface ProductFilters {
  search: string;
  category: string | null;
  minPrice: number;
  maxPrice: number;
  inStockOnly: boolean;
}

@Component({
  selector: 'app-product-catalog',
  standalone: true,
  template: `
    <div class="catalog-layout">
      <!-- Filter Sidebar -->
      <aside class="filters">
        <input 
          [value]="filters().search"
          (input)="updateSearch($any($event).target.value)"
          placeholder="Search products..."
        />

        <select (change)="updateCategory($any($event).target.value)">
          <option value="">All Categories</option>
          <option value="electronics">Electronics</option>
          <option value="clothing">Clothing</option>
          <option value="books">Books</option>
        </select>

        <label>
          <input 
            type="checkbox" 
            [checked]="filters().inStockOnly"
            (change)="toggleStockFilter()"
          />
          In Stock Only
        </label>
      </aside>

      <!-- Product Grid -->
      <main class="product-grid">
        @if (productsResource.isLoading()) {
          <product-grid-skeleton [count]="12" />
        } @else if (productsResource.error()) {
          <error-state 
            [error]="productsResource.error()"
            (retry)="productsResource.reload()"
          />
        } @else {
          @let products = productsResource.value();

          <div class="results-header">
            <span>{{ productCount() }} products found</span>
            <span>Page {{ currentPage() }} of {{ totalPages() }}</span>
          </div>

          <div class="grid">
            @for (product of products; track product.id) {
              <product-card [product]="product" />
            } @empty {
              <empty-state message="No products match your filters" />
            }
          </div>

          <pagination 
            [currentPage]="currentPage()"
            [totalPages]="totalPages()"
            (pageChange)="goToPage($event)"
          />
        }
      </main>
    </div>
  `
})
export class ProductCatalogComponent {
  private http = inject(HttpClient);

  // Individual filter signals
  searchQuery = signal('');
  selectedCategory = signal<string | null>(null);
  priceRange = signal({ min: 0, max: 10000 });
  inStockOnly = signal(false);
  currentPage = signal(1);
  pageSize = signal(24);

  // Combined filter object — stable reference matters
  filters = computed(() => ({
    search: this.searchQuery(),
    category: this.selectedCategory(),
    minPrice: this.priceRange().min,
    maxPrice: this.priceRange().max,
    inStockOnly: this.inStockOnly(),
    page: this.currentPage(),
    pageSize: this.pageSize()
  }));

  productsResource = resource({
    request: () => this.filters(),
    loader: ({ request }) => {
      const params = new HttpParams()
        .set('search', request.search)
        .set('minPrice', request.minPrice.toString())
        .set('maxPrice', request.maxPrice.toString())
        .set('inStock', request.inStockOnly.toString())
        .set('page', request.page.toString())
        .set('pageSize', request.pageSize.toString());

      if (request.category) {
        params.set('category', request.category);
      }

      return this.http.get<{ items: Product[]; total: number }>(
        '/api/products', 
        { params }
      );
    }
  });

  // Derived state
  productCount = computed(() => 
    this.productsResource.value()?.items.length ?? 0
  );

  totalPages = computed(() => {
    const total = this.productsResource.value()?.total ?? 0;
    return Math.ceil(total / this.pageSize());
  });

  // Actions
  updateSearch(query: string): void {
    this.searchQuery.set(query);
    this.currentPage.set(1); // Reset to first page on filter change
  }

  updateCategory(category: string): void {
    this.selectedCategory.set(category || null);
    this.currentPage.set(1);
  }

  toggleStockFilter(): void {
    this.inStockOnly.update(v => !v);
    this.currentPage.set(1);
  }

  goToPage(page: number): void {
    this.currentPage.set(page);
  }
}
Enter fullscreen mode Exit fullscreen mode

Key insight: The filters computed signal creates a stable, serializable request object. When any individual filter changes, the computed re-evaluates, the resource detects the new request, and automatically reloads. The currentPage.set(1) calls ensure UX consistency (resetting pagination when filters change).


Pattern 3: Derived State with computed()

Resources integrate naturally with computed() for derived state that updates automatically.

@Component({...})
export class OrderSummaryComponent {
  private http = inject(HttpClient);
  orderId = input.required<string>();

  orderResource = resource({
    request: () => ({ id: this.orderId() }),
    loader: ({ request }) => 
      this.http.get<Order>(`/api/orders/${request.id}`)
  });

  // Derived computed signals — all reactive
  orderTotal = computed(() => {
    const order = this.orderResource.value();
    if (!order) return 0;
    return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  });

  orderStatus = computed(() => {
    const order = this.orderResource.value();
    if (!order) return 'unknown' as const;

    if (order.cancelledAt) return 'cancelled' as const;
    if (order.shippedAt) return 'shipped' as const;
    if (order.paidAt) return 'paid' as const;
    return 'pending' as const;
  });

  isActionable = computed(() => {
    const status = this.orderStatus();
    return status === 'pending' || status === 'paid';
  });

  formattedTotal = computed(() => {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD'
    }).format(this.orderTotal());
  });
}
Enter fullscreen mode Exit fullscreen mode

Key insight: All computed signals are lazy and memoized. They only re-evaluate when orderResource.value() changes, not on every change detection cycle. This is performance-efficient by design.


Pattern 4: Side Effects with effect()

Use effect() for side effects that should run when resource state changes — analytics, toast notifications, logging, or cross-component communication.

@Component({...})
export class CheckoutComponent {
  private http = inject(HttpClient);
  private analytics = inject(AnalyticsService);
  private toast = inject(ToastService);

  cartId = input.required<string>();

  checkoutResource = resource({
    request: () => ({ cartId: this.cartId() }),
    loader: ({ request }) => 
      this.http.post<CheckoutResult>('/api/checkout', {
        cartId: request.cartId
      })
  });

  constructor() {
    // Effect runs whenever tracked signals change
    effect(() => {
      const status = this.checkoutResource.status();

      if (status === 'resolved') {
        const result = this.checkoutResource.value();
        this.analytics.track('checkout_completed', {
          orderId: result?.orderId,
          total: result?.total
        });
        this.toast.success('Order placed successfully!');
      }

      if (status === 'error') {
        const error = this.checkoutResource.error();
        this.analytics.track('checkout_failed', { error: String(error) });
        this.toast.error('Checkout failed. Please try again.');
      }
    });
  }

  placeOrder(): void {
    // Trigger the resource manually if needed
    this.checkoutResource.reload();
  }
}
Enter fullscreen mode Exit fullscreen mode

Key insight: Effects run asynchronously after the DOM updates. They're the right place for side effects because they don't block rendering and they automatically track their dependencies.

Caution: Never call .reload() inside an effect that reads the same resource — this creates an infinite loop.


Pattern 5: Service Integration

Resources should live in components, but they can delegate to services for business logic and HttpClient calls.

// Service layer — pure business logic, no state
@Injectable({ providedIn: 'root' })
export class ProjectService {
  private http = inject(HttpClient);

  getProject(id: number): Observable<Project> {
    return this.http.get<Project>(`/api/projects/${id}`).pipe(
      map(project => ({
        ...project,
        // Transform raw API response
        displayName: project.name.replace(/-/g, ' ')
      }))
    );
  }

  updateProject(id: number, changes: Partial<Project>): Observable<Project> {
    return this.http.patch<Project>(`/api/projects/${id}`, changes);
  }

  getProjectMembers(projectId: number): Observable<User[]> {
    return this.http.get<User[]>(`/api/projects/${projectId}/members`);
  }
}

// Component layer — async state management
@Component({
  selector: 'app-project-dashboard',
  standalone: true,
  template: `
    <div class="dashboard">
      @if (projectResource.isLoading()) {
        <dashboard-skeleton />
      } @else if (projectResource.error()) {
        <error-state (retry)="projectResource.reload()" />
      } @else {
        @let project = projectResource.value();

        <project-header [project]="project" />
        <project-stats [project]="project" />

        <!-- Nested resource for related data -->
        <project-members [projectId]="project!.id" />
      }
    </div>
  `
})
export class ProjectDashboardComponent {
  private projectService = inject(ProjectService);
  projectId = input.required<number>();

  projectResource = resource({
    request: () => ({ id: this.projectId() }),
    loader: ({ request }) => 
      // Convert Observable to Promise for resource
      firstValueFrom(this.projectService.getProject(request.id))
  });
}

// Child component with its own resource
@Component({
  selector: 'project-members',
  standalone: true,
  template: `
    @if (membersResource.isLoading()) {
      <member-list-skeleton />
    } @else {
      <ul>
        @for (member of membersResource.value(); track member.id) {
          <li>{{ member.name }} — {{ member.role }}</li>
        }
      </ul>
    }
  `
})
export class ProjectMembersComponent {
  private projectService = inject(ProjectService);
  projectId = input.required<number>();

  membersResource = resource({
    request: () => ({ id: this.projectId() }),
    loader: ({ request }) =>
      firstValueFrom(this.projectService.getProjectMembers(request.id))
  });
}
Enter fullscreen mode Exit fullscreen mode

Key insight: Services remain the source of truth for business logic and HTTP calls. Resources live in components and manage the async state lifecycle. Use firstValueFrom() to bridge RxJS Observables into the Promise-based resource loader.


Pattern 6: SSR and Hydration

Resources work seamlessly with Angular's server-side rendering and hydration.

@Component({
  selector: 'app-blog-post',
  standalone: true,
  template: `
    <article>
      @if (postResource.isLoading()) {
        <content-skeleton />
      } @else if (postResource.error()) {
        <error-fallback />
      } @else {
        @let post = postResource.value();
        <h1>{{ post?.title }}</h1>
        <time>{{ post?.publishedAt | date }}</time>
        <div [innerHTML]="post?.content"></div>
      }
    </article>
  `
})
export class BlogPostComponent {
  private http = inject(HttpClient);
  private platformId = inject(PLATFORM_ID);

  slug = input.required<string>();

  postResource = resource({
    request: () => ({ slug: this.slug() }),
    loader: async ({ request }) => {
      // On the server, this runs during SSR
      // On the client, Angular hydrates the state automatically
      const response = await fetch(`/api/posts/${request.slug}`);

      if (!response.ok) {
        throw new Error(`Failed to load post: ${response.status}`);
      }

      return response.json() as Promise<BlogPost>;
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Key insight: During SSR, the resource loader runs on the server, and the resolved state is serialized into the HTML. During hydration on the client, Angular restores this state without re-fetching. The resource transitions from 'resolved' immediately, providing instant interactivity.


Pattern 7: Error Boundaries and Retry

Resources provide granular error state, which you can use to build sophisticated error handling.

@Component({
  selector: 'app-data-fetcher',
  standalone: true,
  template: `
    <div class="data-container">
      @if (dataResource.isLoading()) {
        <loading-spinner />
      } @else if (dataResource.error()) {
        <error-card 
          [error]="dataResource.error()"
          [attemptCount]="retryCount()"
          (retry)="handleRetry()"
          (dismiss)="dismissError()"
        />
      } @else {
        <data-visualization [data]="dataResource.value()" />
      }
    </div>
  `
})
export class DataFetcherComponent {
  private http = inject(HttpClient);
  endpoint = input.required<string>();

  retryCount = signal(0);
  maxRetries = 3;

  dataResource = resource({
    request: () => ({ 
      endpoint: this.endpoint(),
      retry: this.retryCount()
    }),
    loader: async ({ request, abortSignal }) => {
      try {
        const response = await fetch(request.endpoint, { signal: abortSignal });

        if (!response.ok) {
          throw new HttpErrorResponse({ 
            error: await response.text(),
            status: response.status,
            statusText: response.statusText,
            url: request.endpoint
          });
        }

        return response.json();
      } catch (error) {
        // Log for monitoring
        console.error('Resource load failed:', error);
        throw error;
      }
    }
  });

  handleRetry(): void {
    if (this.retryCount() < this.maxRetries) {
      this.retryCount.update(c => c + 1);
      this.dataResource.reload();
    }
  }

  dismissError(): void {
    // Could navigate away or show cached data
  }
}
Enter fullscreen mode Exit fullscreen mode

Key insight: By including retryCount in the request object, the resource reloads automatically when the user triggers a retry. The error state is fully reactive — you can build error UIs as complex as your product requires.


Pattern 8: Optimistic Updates

While resources are primarily for fetching, you can combine them with local state for optimistic UI patterns.

@Component({...})
export class TaskBoardComponent {
  private taskService = inject(TaskService);
  boardId = input.required<string>();

  // Server state
  tasksResource = resource({
    request: () => ({ boardId: this.boardId() }),
    loader: ({ request }) => 
      firstValueFrom(this.taskService.getTasks(request.boardId))
  });

  // Optimistic local state for drag-and-drop
  localTaskOrder = signal<Task[] | null>(null);

  // Displayed tasks: prefer local optimistic state
  displayedTasks = computed(() => 
    this.localTaskOrder() ?? this.tasksResource.value() ?? []
  );

  async moveTask(taskId: string, newIndex: number): Promise<void> {
    const currentTasks = this.displayedTasks();
    const task = currentTasks.find(t => t.id === taskId);
    if (!task) return;

    // 1. Optimistically update local state
    const reordered = [...currentTasks];
    const oldIndex = reordered.findIndex(t => t.id === taskId);
    reordered.splice(oldIndex, 1);
    reordered.splice(newIndex, 0, task);
    this.localTaskOrder.set(reordered);

    try {
      // 2. Send to server
      await firstValueFrom(
        this.taskService.moveTask(this.boardId(), taskId, newIndex)
      );

      // 3. On success, clear optimistic state (resource will reflect truth)
      this.localTaskOrder.set(null);
      this.tasksResource.reload();
    } catch (error) {
      // 4. On failure, revert to server state
      this.localTaskOrder.set(null);
      // Error handling via UI notification
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key insight: The computed() displayedTasks prefers local optimistic state when available, falling back to the resource. This pattern keeps the resource as the source of truth while allowing temporary local overrides.


Template Patterns: Loading, Error, and Success UI

Progressive Disclosure Pattern

Show the most useful state at each point in the lifecycle:

@Component({
  template: `
    <div class="content-area">
      <!-- Always show header, even while loading -->
      <header>
        <h1>Dashboard</h1>
        <button 
          (click)="dashboardResource.reload()"
          [disabled]="dashboardResource.isLoading()"
        >
          @if (dashboardResource.isLoading()) {
            <spinner size="sm" />
          } @else {
            🔄 Refresh
          }
        </button>
      </header>

      @if (dashboardResource.isLoading() && !dashboardResource.hasValue()) {
        <!-- Initial load: full skeleton -->
        <dashboard-skeleton />
      } @else if (dashboardResource.error() && !dashboardResource.hasValue()) {
        <!-- Initial load failed: full error state -->
        <error-fullscreen 
          [error]="dashboardResource.error()"
          (retry)="dashboardResource.reload()"
        />
      } @else {
        <!-- We have data (might be stale while reloading) -->
        <div class="dashboard-content" [class.stale]="dashboardResource.isLoading()">
          @if (dashboardResource.isLoading()) {
            <!-- Background reload indicator -->
            <div class="reload-banner">
              <spinner size="sm" /> Updating...
            </div>
          }

          @if (dashboardResource.error() && dashboardResource.hasValue()) {
            <!-- Background reload failed, but we have stale data -->
            <div class="stale-warning">
              ⚠️ Couldn't refresh. Showing data from {{ lastUpdated() }}.
            </div>
          }

          <!-- Main content -->
          <metrics-grid [metrics]="dashboardResource.value()?.metrics" />
          <activity-chart [data]="dashboardResource.value()?.activity" />
        </div>
      }
    </div>
  `
})
export class DashboardComponent {
  private http = inject(HttpClient);

  dashboardResource = resource({
    request: () => ({}),
    loader: () => this.http.get<DashboardData>('/api/dashboard')
  });

  lastUpdated = computed(() => {
    const data = this.dashboardResource.value();
    return data ? new Date(data.timestamp).toLocaleTimeString() : 'unknown';
  });
}
Enter fullscreen mode Exit fullscreen mode

Skeleton-First Pattern

For routes where data is expected, show skeletons immediately:

@Component({
  template: `
    @if (profileResource.isLoading()) {
      <profile-skeleton />
    } @else if (profileResource.error()) {
      <profile-error [error]="profileResource.error()" />
    } @else {
      <profile-content [profile]="profileResource.value()!" />
    }
  `
})
export class ProfilePageComponent {
  private http = inject(HttpClient);
  userId = input.required<string>();

  profileResource = resource({
    request: () => ({ id: this.userId() }),
    loader: ({ request }) => 
      this.http.get<UserProfile>(`/api/users/${request.id}/profile`)
  });
}
Enter fullscreen mode Exit fullscreen mode

Empty State Pattern

Handle the case where data loads successfully but is empty:

@Component({
  template: `
    @if (notificationsResource.isLoading()) {
      <notification-skeleton [count]="3" />
    } @else if (notificationsResource.error()) {
      <error-toast [error]="notificationsResource.error()" />
    } @else {
      @let notifications = notificationsResource.value() ?? [];

      @if (notifications.length === 0) {
        <empty-state 
          icon="🔔"
          title="No notifications"
          description="You're all caught up!"
        />
      } @else {
        <notification-list [items]="notifications" />
      }
    }
  `
})
export class NotificationCenterComponent {
  private http = inject(HttpClient);

  notificationsResource = resource({
    request: () => ({}),
    loader: () => this.http.get<Notification[]>('/api/notifications')
  });
}
Enter fullscreen mode Exit fullscreen mode

When NOT to Use the Resource API

The Resource API is powerful, but it's not universal. Here are the scenarios where you should not use it:

1. Complex Event Streams

If your data source is a continuous stream of events (SSE, long-polling with incremental updates), resource() is the wrong abstraction. Use RxJS Observable with proper stream handling.

// ❌ Don't do this
const streamResource = resource({
  request: () => ({}),
  loader: () => {
    // This will only capture the FIRST event
    return new Promise(resolve => {
      eventSource.onmessage = (e) => resolve(JSON.parse(e.data));
    });
  }
});

// ✅ Use RxJS instead
const eventStream = new Observable<Message>(subscriber => {
  const es = new EventSource('/api/events');
  es.onmessage = (e) => subscriber.next(JSON.parse(e.data));
  return () => es.close();
});
Enter fullscreen mode Exit fullscreen mode

2. WebSockets

WebSocket connections are persistent, bidirectional, and stateful. The request/response model of resource() doesn't map to this paradigm.

// ✅ Use RxJS webSocket or native WebSocket
const wsSubject = webSocket('wss://api.example.com/realtime');
wsSubject.subscribe(msg => /* handle message */);
Enter fullscreen mode Exit fullscreen mode

3. Multi-Stream Orchestration

When you need combineLatest, merge, forkJoin, or complex operator chains, RxJS is the right tool.

// ✅ RxJS for orchestration
const dashboardData$ = forkJoin({
  metrics: metricsService.getMetrics(),
  alerts: alertService.getActiveAlerts(),
  users: userService.getOnlineUsers()
}).pipe(
  debounceTime(100),
  distinctUntilChanged()
);
Enter fullscreen mode Exit fullscreen mode

4. Heavy Reactive Pipelines

If your data flow requires switchMap, debounceTime, throttleTime, scan, or custom operators, stay with RxJS.

// ✅ RxJS for complex pipelines
searchResults$ = this.searchQuery$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(query => this.searchService.search(query)),
  catchError(err => of([]))
);
Enter fullscreen mode Exit fullscreen mode

5. Non-Component State

Global state (Redux/NgRx stores, shared services) should not use resource(). Resources are component-scoped by design.


Where Each Tool Fits: HttpClient, RxJS, and Resource API

Layer Tool Role
Network HttpClient HTTP requests, interceptors, error handling at the protocol level
Stream Logic RxJS Complex async pipelines, event streams, multi-source orchestration
Component State resource() Component-level async state lifecycle (loading, error, data)
Derived State computed() Transformations based on signals/resources
Side Effects effect() Reactions to state changes (analytics, toasts, logging)

The senior mindset: Resource API isn't replacing HttpClient. It's improving how components consume asynchronous data. Each tool has a different architectural role.

┌─────────────────────────────────────────────┐
│           Presentation Layer                │
│  Template: @if (res.isLoading())            │
│            {{ res.value() }}                │
├─────────────────────────────────────────────┤
│         Component Logic Layer               │
│  resource({ request, loader })              │
│  computed(() => transform(res.value()))     │
│  effect(() => sideEffect(res.status()))     │
├─────────────────────────────────────────────┤
│           Service Layer                     │
│  HttpClient.get() / Observable pipelines    │
│  Business logic, data transformation        │
├─────────────────────────────────────────────┤
│           Network Layer                     │
│  HTTP / WebSocket / Server-Sent Events      │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Testing Strategies

Testing Resources with TestBed

import { ComponentFixture, TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';

describe('UserProfileComponent', () => {
  let fixture: ComponentFixture<UserProfileComponent>;
  let component: UserProfileComponent;
  let httpMock: HttpTestingController;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [UserProfileComponent],
      providers: [
        provideHttpClient(),
        provideHttpClientTesting()
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(UserProfileComponent);
    component = fixture.componentInstance;
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('should show loading state initially', () => {
    fixture.componentRef.setInput('userId', 1);
    fixture.detectChanges();

    expect(component.userResource.isLoading()).toBe(true);
    expect(component.userResource.value()).toBeUndefined();
  });

  it('should display user data after successful load', fakeAsync(() => {
    const mockUser: User = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
      role: 'admin'
    };

    fixture.componentRef.setInput('userId', 1);
    fixture.detectChanges();

    // Flush the pending HTTP request
    const req = httpMock.expectOne('/api/users/1');
    expect(req.request.method).toBe('GET');
    req.flush(mockUser);

    tick(); // Let microtasks resolve
    fixture.detectChanges();

    expect(component.userResource.isLoading()).toBe(false);
    expect(component.userResource.value()).toEqual(mockUser);
    expect(component.userResource.error()).toBeNull();
  }));

  it('should handle HTTP errors', fakeAsync(() => {
    fixture.componentRef.setInput('userId', 999);
    fixture.detectChanges();

    const req = httpMock.expectOne('/api/users/999');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });

    tick();
    fixture.detectChanges();

    expect(component.userResource.isLoading()).toBe(false);
    expect(component.userResource.value()).toBeUndefined();
    expect(component.userResource.error()).toBeTruthy();
  }));

  it('should reload when userId changes', fakeAsync(() => {
    fixture.componentRef.setInput('userId', 1);
    fixture.detectChanges();

    httpMock.expectOne('/api/users/1').flush({ id: 1, name: 'User 1', email: '', role: '' });
    tick();

    // Change the input
    fixture.componentRef.setInput('userId', 2);
    fixture.detectChanges();

    expect(component.userResource.isLoading()).toBe(true);

    httpMock.expectOne('/api/users/2').flush({ id: 2, name: 'User 2', email: '', role: '' });
    tick();
    fixture.detectChanges();

    expect(component.userResource.value()?.name).toBe('User 2');
  }));
});
Enter fullscreen mode Exit fullscreen mode

Testing Resources in Isolation

import { resource, signal } from '@angular/core';

describe('resource() behavior', () => {
  it('should track loading state through lifecycle', async () => {
    const id = signal(1);

    const res = resource({
      request: () => ({ id: id() }),
      loader: async ({ request }) => {
        await new Promise(r => setTimeout(r, 10));
        return { id: request.id, data: 'test' };
      }
    });

    // Initial state
    expect(res.isLoading()).toBe(true);
    expect(res.value()).toBeUndefined();

    // Wait for resolution
    await new Promise(r => setTimeout(r, 20));

    expect(res.isLoading()).toBe(false);
    expect(res.value()).toEqual({ id: 1, data: 'test' });
  });

  it('should cancel previous request on dependency change', async () => {
    const id = signal(1);
    const abortSignals: AbortSignal[] = [];

    const res = resource({
      request: () => ({ id: id() }),
      loader: async ({ abortSignal }) => {
        abortSignals.push(abortSignal);
        await new Promise(r => setTimeout(r, 100));
        return { id: id() };
      }
    });

    // Trigger first request
    id.set(1);
    await new Promise(r => setTimeout(r, 10));

    // Trigger second request before first completes
    id.set(2);
    await new Promise(r => setTimeout(r, 10));

    // First abort signal should be aborted
    expect(abortSignals[0].aborted).toBe(true);
    expect(abortSignals[1].aborted).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

1. Lazy Evaluation

Resources only execute their loader when their state is read in a reactive context. If a resource is declared but never consumed in a template or computed(), no request is made.

@Component({...})
export class LazyComponent {
  // This resource won't load until something reads it
  heavyResource = resource({
    request: () => ({}),
    loader: () => fetchExpensiveData()
  });

  // Only when the user clicks "Show Details" does the resource activate
  showDetails = signal(false);

  // Template: @if (showDetails()) { {{ heavyResource.value() }} }
}
Enter fullscreen mode Exit fullscreen mode

2. Memoization

The request function's return value is compared to the previous request using Object.is(). If the request hasn't changed, no reload occurs.

// ✅ Good: Stable request object
request: () => ({ id: this.userId() })

// ❌ Bad: New object every time (causes infinite reloads)
request: () => ({ id: this.userId(), timestamp: Date.now() })

// ✅ Fix: Only include reactive dependencies
request: () => ({ id: this.userId() })
Enter fullscreen mode Exit fullscreen mode

3. Cancellation Prevents Race Conditions

Automatic cancellation means you never have to worry about out-of-order responses:

// Without resource: You'd need switchMap
this.searchQuery$.pipe(
  debounceTime(300),
  switchMap(q => this.http.get(`/api/search?q=${q}`))
).subscribe();

// With resource: Cancellation is built-in
searchResource = resource({
  request: () => ({ q: this.searchQuery() }),
  loader: ({ request }) => this.http.get(`/api/search?q=${request.q}`)
});
Enter fullscreen mode Exit fullscreen mode

4. Change Detection Efficiency

Because resources use Signals, change detection is fine-grained. Only components that read the resource's signals are marked for check, not the entire component tree.


Migration Strategy: Moving from Traditional Patterns

Step 1: Identify Candidates

Look for components with this pattern:

  • loading boolean property
  • error property
  • data property
  • subscribe() call
  • Manual cleanup

Step 2: Extract the Request Shape

Determine what signals drive the request:

// Before: Imperative
ngOnInit() {
  this.loadData(this.userId, this.filters);
}

onFilterChange(filters) {
  this.filters = filters;
  this.loadData(this.userId, this.filters);
}

// After: Reactive
filters = signal(defaultFilters);

resource({
  request: () => ({ userId: this.userId(), filters: this.filters() }),
  loader: ({ request }) => this.service.load(request.userId, request.filters)
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Migrate Template

Replace scattered property checks with resource state checks:

<!-- Before -->
@if (loading) { <skeleton /> }
@else if (error) { <error /> }
@else { <content [data]="data" /> }

<!-- After -->
@if (resource.isLoading()) { <skeleton /> }
@else if (resource.error()) { <error /> }
@else { <content [data]="resource.value()" /> }
Enter fullscreen mode Exit fullscreen mode

Step 4: Remove Imperative Code

Delete:

  • loading = false assignments
  • error = null resets
  • takeUntilDestroyed pipes
  • Manual unsubscribe calls
  • ngOnInit fetch logic
  • refresh() methods (use .reload())

Step 5: Test

Resources are more predictable to test because state transitions are centralized. Update tests to assert on resource state rather than individual properties.


Best Practices Summary

# Practice Rationale
1 Include all reactive dependencies in request Ensures automatic reload when anything changes
2 Keep loaders pure — no side effects Side effects belong in effect() or event handlers
3 Use .reload() for user-initiated refresh Don't recreate resources; reload them
4 Use @if control flow with resource state Clean, readable, signal-native template handling
5 Combine with computed() for derived state Avoid duplicating transformation logic
6 Let resources handle cancellation Don't manually abort; dependency changes do it automatically
7 Keep HttpClient in services Resources use services; services use HttpClient
8 Use signal inputs for route parameters input.required<number>() → automatic resource reload
9 Handle .error() in template, not loader Separation of concerns: loader fetches, template presents
10 Use firstValueFrom() for Observable services Bridge RxJS services into Promise-based resource loaders
11 Avoid creating resources inside effect() Creates infinite loops and memory leaks
12 Test resource state, not implementation Assert on .value(), .isLoading(), .error()

Common Mistakes to Avoid

Mistake 1: Side Effects in Loader

// ❌ Wrong: Side effect in loader
loader: async ({ request }) => {
  const data = await fetchData(request.id);
  this.analytics.track('data_loaded'); // Side effect!
  return data;
}

// ✅ Correct: Pure loader + effect for side effects
loader: async ({ request }) => fetchData(request.id)

constructor() {
  effect(() => {
    if (this.resource.status() === 'resolved') {
      this.analytics.track('data_loaded');
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Unstable Request Objects

// ❌ Wrong: New object every evaluation
request: () => ({ id: this.id(), random: Math.random() })

// ✅ Correct: Only reactive dependencies
request: () => ({ id: this.id() })
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Forgetting Error States

// ❌ Wrong: No error handling
@if (resource.isLoading()) { <loading /> }
@else { <data [value]="resource.value()" /> }

// ✅ Correct: All three states
@if (resource.isLoading()) { <loading /> }
@else if (resource.error()) { <error [err]="resource.error()" /> }
@else { <data [value]="resource.value()" /> }
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Calling .reload() in effect()

// ❌ Wrong: Infinite loop
constructor() {
  effect(() => {
    console.log(this.resource.value());
    this.resource.reload(); // Triggers effect again!
  });
}

// ✅ Correct: Use event handlers
onRefreshClick() {
  this.resource.reload();
}
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Using Resources for Non-Async State

// ❌ Wrong: Resource for synchronous data
const nameResource = resource({
  request: () => ({}),
  loader: () => Promise.resolve(this.user().name) // Not async!
});

// ✅ Correct: Use computed for sync transformations
const displayName = computed(() => this.user().name.toUpperCase());
Enter fullscreen mode Exit fullscreen mode

Enterprise Considerations

Standardization Across Teams

Large Angular applications benefit when Loading, Errors, Success, Refresh, and Cancellation are modeled consistently across every feature. The Resource API helps standardize that experience.

Recommended enterprise pattern: Create a set of reusable resource wrappers and UI components:

// Shared resource factory with enterprise defaults
@Injectable({ providedIn: 'root' })
export class ResourceFactory {
  private http = inject(HttpClient);

  createApiResource<T, R>(options: {
    endpoint: (request: R) => string;
    transform?: (raw: unknown) => T;
  }) {
    return (requestSignal: Signal<R>) => 
      resource({
        request: () => options.endpoint(requestSignal()),
        loader: async ({ request }) => {
          const response = await fetch(request);
          if (!response.ok) {
            throw new ApiError(response.status, await response.text());
          }
          const raw = await response.json();
          return options.transform ? options.transform(raw) : raw as T;
        }
      });
  }
}

// Shared UI components
@Component({
  selector: 'async-container',
  standalone: true,
  template: `
    @if (resource.isLoading() && !resource.hasValue()) {
      <ng-content select="[loading]" />
    } @else if (resource.error() && !resource.hasValue()) {
      <ng-content select="[error]" />
    } @else {
      <ng-content select="[content]" />
    }
  `
})
export class AsyncContainerComponent<T> {
  resource = input.required<Resource<T>>();
}
Enter fullscreen mode Exit fullscreen mode

Onboarding Benefits

New developers only need to learn one async pattern instead of five different team conventions. The resource contract (value(), isLoading(), error(), reload()) is self-documenting.

Monitoring and Observability

Centralized resource patterns make it easier to add global interceptors for:

  • Request timing metrics
  • Error rate tracking
  • Loading state analytics
  • Cache hit/miss ratios

Conclusion: The Architecture Mindset Shift

The Resource API represents more than a new function in Angular. It represents a shift in how we architect component-level asynchronous state.

From imperative to declarative. Instead of telling Angular how to manage loading, error, and data states, we declare what data we need and when it should refresh. Angular handles the lifecycle.

From scattered to centralized. Instead of spreading async state across multiple properties and methods, we have a single reactive object that owns the entire lifecycle.

from manual to automatic. Instead of writing cancellation, cleanup, and reload logic by hand, we leverage Signals' dependency tracking and Angular's built-in lifecycle management.

The golden rule remains: Treat asynchronous data as state — not as a side effect.

The Resource API is a strong fit for component-level asynchronous state. Traditional HttpClient remains valuable for many scenarios. RxJS still shines with complex event streams, WebSockets, and multi-stream orchestration. These tools complement one another.

In modern Angular applications, the question is no longer "How do I fetch data?" but "How do I model async state so my components stay predictable, testable, and maintainable?"

resource() is Angular's answer to that question.


Resources & References


I write about Angular architecture, enterprise UI patterns, and frontend best practices at Programming Mastery Academy — follow along for more breakdowns like this one.


📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.

🌐 Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:

🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.


Top comments (0)