DEV Community

Ali Gamal
Ali Gamal

Posted on

Angular `resource()` Patterns Worth Knowing

This is my Thoughts a year ago when Alex released the RFC and how i was studying the ResourcePattern

image

All examples use the Official Joke API, and the Official wikimedia stream api.
No wrapper, no Angular HTTP client: just fetch() and signals.


resource() vs httpResource(): The One-Line Difference

httpResource() resource()
Import @angular/common/http @angular/core
Uses HttpClient? Yes, including interceptors and testing utilities No, plain fetch under your control
What you give it A URL / request object An async loader function
Data sources GET-only reactive fetching Any Promise (fetch, IndexedDB, WebSocket, timers…)
Continuous streams Not supported Supported via the stream option
Reactive params Built into the URL callback Explicit params option
AbortSignal Handled automatically Passed into loader({ abortSignal })

resource() is the lower-level primitive.


Setup: Joke Type + API Constants

Before jumping into the patterns, here are the shared types and constants used across all examples.

// jokes.ts
export interface Joke {
  id: number;
  type: string;
  setup: string;
  punchline: string;
}
Enter fullscreen mode Exit fullscreen mode
// http-demo-apis.ts
export const JOKES_API = {
  JOKE_API_10_JOKES: 'https://official-joke-api.appspot.com/random_ten',
  GET_JOKES_WITH_DYNAMIC_NUMBERS: (n: number) =>
    `https://official-joke-api.appspot.com/jokes/random/${n}`,
};
Enter fullscreen mode Exit fullscreen mode

Full Options Reference

hint:
I will always remember what Alex told me, watch out for the Resource Type where we can extend and add more to it. Let me know what more can you do with it 🀠?

resource<T, P>({
  // ── Reactive params ─────────────────────────────────────────────────
  // Signal reads here register as dependencies.
  // Return undefined to keep the resource idle.
  params: () => ({ count: this.count() }),

  // ── Async loader ────────────────────────────────────────────────────
  // Receives { params, abortSignal, previous }.
  // Must return a Promise<T>.
  loader: async ({ params, abortSignal, previous }) => {
    const res = await fetch(`/api/jokes/${params.count}`, { signal: abortSignal });
    return res.json() as Promise<T>;
  },

  // ── Optional extras ─────────────────────────────────────────────────

  // Returned while loading/idle: value() is never undefined when set.
  defaultValue: [] as T,

  // Custom equality: skip re-renders when the data didn't meaningfully change.
  equal: (a, b) => a.length === b.length && a.every((j, i) => j.id === b[i].id),

  // SSR TransferState key: server serialises under this key;
  // the client reads from cache on hydration instead of re-fetching.
  id: 'jokes-resource',

  // Shown in Angular DevTools signals graph.
  debugName: 'JokeResourceDemo',

  // Useful when creating a resource outside an injection context.
  injector: inject(Injector),
});
Enter fullscreen mode Exit fullscreen mode

The stream Variant

stream replaces loader entirely, the two cannot be used together on the same resource.

resource<T, R>({
  // Same params / defaultValue / equal / injector / id / debugName as above.

  // ── Streaming loader ────────────────────────────────────────────────
  // Cannot be combined with `loader`.
  // Returns a Signal (or a Promise of one) shaped like { value: T } | { error: Error }.
  stream: async ({ params, abortSignal }) => {
    const live = signal<{ value: T } | { error: Error }>({ value: initialValue });

    // Wire up a WebSocket, an SSE connection, an async generator, or any
    // other continuously-updating source here, then call live.set() or
    // live.update() whenever new data arrives.

    return live;
  },
});
Enter fullscreen mode Exit fullscreen mode

Heads up: since stream hands back a plain signal, it never buffers what you write to it either. If you plan to write raw incoming values with .set() instead of merging them, see the callout in Example 6 for why that can quietly drop data.


Example 1: The Pure Form

This is resource() at its absolute minimum. No params, no abort handling, no options: just a loader that returns a Promise.

In service we do:

import { resource, Service } from '@angular/core';
import { Joke } from './jokes';
import { JOKES_API } from './http-demo-service';

@Service()
export class ResourceDemoService {
  pureJokeDataResource = resource({
    loader: (): Promise<Joke[]> =>
      fetch(JOKES_API.JOKE_API_10_JOKES).then((res) => res.json()),
  });
}
Enter fullscreen mode Exit fullscreen mode

No reactive params, no signal() reads. This fires once on creation and stays resolved. The loader is just an async function that returns a Promise<T>. Any async operation qualifies: fetch, indexedDB.get, crypto.subtle.digest

In component we do:

import { Component, inject } from '@angular/core';
import { ResourceDemoService } from '../service/resource-demo.service';

@Component({
  selector: 'app-jokes',
  templateUrl: './jokes.html',
})
export class JokesComponent {
  #service = inject(ResourceDemoService);
  jokes = this.#service.pureJokeDataResource;
}
Enter fullscreen mode Exit fullscreen mode

In HTML then we do:

@if (jokes.isLoading()) {
  <p>Loading...</p>
} @else {
  @for (joke of jokes.value(); track joke.id) {
    <div>
      <strong>{{ joke.setup }}</strong>
      <p>{{ joke.punchline }}</p>
    </div>
  }
}
Enter fullscreen mode Exit fullscreen mode

jokes.value() can be undefined here since no defaultValue was set. Use hasValue() or a defaultValue: [] before iterating.


Example 2: Async/Await with Signal Params

Now resource() becomes reactive. The params callback is a reactive context: read a signal there and the loader re-runs every time it changes.

In service we do:

asyncAwaitJokeDataResourceWithParameter = (jokeCount: Signal<number | undefined>) => {
  return resource<Joke[], { count: number } | undefined>({
    params: () => (jokeCount() !== undefined ? { count: jokeCount()! } : undefined),
    defaultValue: [],

    loader: async ({ params }) => {
      const response = await fetch(
        JOKES_API.GET_JOKES_WITH_DYNAMIC_NUMBERS(params.count)
      );
      return await response.json();
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

params is the reactive bridge. Angular tracks signal reads inside the params callback, not inside loader. This is intentional: the loader runs in a non-reactive async context, so if you read signals there they won't register as dependencies. Always put signal reads in params.

In component we do:

import { Component, inject, signal } from '@angular/core';
import { ResourceDemoService } from '../service/resource-demo.service';

@Component({
  selector: 'app-jokes-dynamic',
  templateUrl: './jokes-dynamic.html',
})
export class JokesDynamicComponent {
  #service = inject(ResourceDemoService);

  count = signal<number | undefined>(5);

  jokes = this.#service.asyncAwaitJokeDataResourceWithParameter(this.count);

  increase() {
    this.count.update((n) => (n ?? 0) + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

When count changes, Angular re-evaluates params, detects a new value, and re-runs the loader automatically.

In HTML then we do:

<input
  #input
  type="number"
  placeholder="Type count..."
  [value]="count()"
  (input)="count.set(input.valueAsNumber)"
/>
<button (click)="increase()">+ More jokes</button>

@if (jokes.isLoading()) {
  <p>Fetching {{ count() }} jokes...</p>
} @else if (jokes.error()) {
  <p>Error: {{ jokes.error()?.message }}</p>
} @else {
  @for (joke of jokes.value() ?? []; track joke.id) {
    <p><strong>{{ joke.setup }}</strong>: {{ joke.punchline }}</p>
  }
}
Enter fullscreen mode Exit fullscreen mode

Example 3: Conditional Loading (Skip When Undefined)

Return undefined from params when the count is not ready yet, and let defaultValue take care of the rest. This is the pattern resource() was actually built for: no manual guard inside the loader, just tell Angular there is nothing to fetch yet and it puts the resource into idle status for you, value() falls back to defaultValue automatically.

In service we do:

asyncAwaitJokeDataResourceWithParameter = (jokeCount: Signal<number | undefined>) => {
  return resource<Joke[], { count: number } | undefined>({
    params: () => (jokeCount() !== undefined ? { count: jokeCount()! } : undefined),
    defaultValue: [],

    loader: async ({ params }) => {
      const response = await fetch(
        JOKES_API.GET_JOKES_WITH_DYNAMIC_NUMBERS(params.count)
      );
      return await response.json();
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

There is an alternative if you would rather keep a guard inside the loader: check the count there and return an early value like []. The difference is the resulting status. Returning undefined from params gives you idle, guarding inside loader gives you resolved with an empty result. Pick whichever status your UI wants to react to.

// Option A: idle status, value() falls back to defaultValue
params: () => jokeCount() !== undefined ? { count: jokeCount()! } : undefined,

// Option B: resolved status, value() is [] on purpose
loader: async ({ params }) => {
  if (params.count === undefined) return [];
  ...
},
Enter fullscreen mode Exit fullscreen mode

In component we do:

@Component({
  selector: 'app-jokes-conditional',
  templateUrl: './jokes-conditional.html',
})
export class JokesConditionalComponent {
  #service = inject(ResourceDemoService);

  jokesCount = signal<number | undefined>(undefined);

  jokes = this.#service.asyncAwaitJokeDataResourceWithParameter(this.jokesCount);
}
Enter fullscreen mode Exit fullscreen mode

In HTML then we do:

<input
  #input
  type="number"
  placeholder="Enter a count to load jokes"
  (input)="jokesCount.set(input.valueAsNumber)"
/>

@if (jokesCount() === undefined) {
  <p>Enter a number above to fetch jokes.</p>
} @else if (jokes.isLoading()) {
  <p>Loading...</p>
} @else if (jokes.error()) {
  <p>Something went wrong: {{ jokes.error()?.message }}</p>
} @else {
  @for (joke of jokes.value(); track joke.id) {
    <div>
      <strong>{{ joke.setup }}</strong>
      <p>{{ joke.punchline }}</p>
    </div>
  } @empty {
    <p>No jokes found for that count.</p>
  }
}
Enter fullscreen mode Exit fullscreen mode

Example 4: AbortSignal for Cancellation Support

This is where resource() pulls ahead of higher-level wrappers. The loader receives an abortSignal that Angular fires automatically when:

  • The params change before the previous request finishes (stale-request cancellation)
  • The component is destroyed

You can also wire in your own AbortController and combine both signals with AbortSignal.any().

In service we do:

import { inject, Injector, resource, Service, Signal } from '@angular/core';
import { Joke } from './jokes';
import { JOKES_API } from './http-demo-service';

@Service()
export class ResourceDemoService {

  // Manual cancellation controller, rebuilt after each abort
  #manualAbortController = new AbortController();

  abortJokeRequest(reason?: string) {
    this.#manualAbortController.abort(reason ?? 'User cancelled');
    // Rebuild so the next request gets a fresh signal
    this.#manualAbortController = new AbortController();
  }

  jokeResourceWithAbortSignal = (jokeCount: Signal<number | undefined>) => {
    return resource<Joke[], { count: number } | undefined>({
      params: () => (jokeCount() !== undefined ? { count: jokeCount()! } : undefined),
      defaultValue: [],

      loader: async ({ params, abortSignal }) => {
        // Log when Angular aborts (param change / destroy)
        abortSignal.addEventListener(
          'abort',
          () => console.log(`Angular aborted: ${abortSignal.reason}`),
          { once: true },
        );

        // Merge Angular's signal with the manual one
        const manualSignal = this.#manualAbortController.signal;
        const combinedSignal = AbortSignal.any([abortSignal, manualSignal]);

        // Simulate a slow network with a 2-second delay
        await new Promise<void>((resolve, reject) => {
          const timer = setTimeout(resolve, 2000);
          combinedSignal.addEventListener(
            'abort',
            () => {
              clearTimeout(timer);
              reject(combinedSignal.reason);
            },
            { once: true },
          );
        });

        const response = await fetch(
          JOKES_API.GET_JOKES_WITH_DYNAMIC_NUMBERS(params.count),
          { signal: combinedSignal },
        );
        return await response.json();
      },
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

AbortSignal.any([...signals]): the combined signal fires as soon as any of the input signals abort. It's the cleanest way to merge multiple cancellation sources without manual event listener bookkeeping.

Why rebuild #manualAbortController after each abort?
An AbortController is a one-shot device: once aborted, it stays aborted forever. Every new request needs a fresh controller. Rebuilding it immediately in abortJokeRequest() means the next loader call always gets a clean signal.

In component we do:

import { Component, inject, signal } from '@angular/core';
import { ResourceDemoService } from '../service/resource-demo.service';

@Component({
  selector: 'app-jokes-abort',
  templateUrl: './jokes-abort.html',
})
export class JokesAbortComponent {
  #service = inject(ResourceDemoService);

  count = signal<number | undefined>(5);

  jokes = this.#service.jokeResourceWithAbortSignal(this.count);

  cancel() {
    this.#service.abortJokeRequest('User clicked cancel');
  }
}
Enter fullscreen mode Exit fullscreen mode

In HTML then we do:

<input
  #input
  type="number"
  placeholder="Enter count (2 sec delay)"
  [value]="count()"
  (input)="count.set(input.valueAsNumber)"
/>

@if (jokes.isLoading()) {
  <p>Fetching... (2 sec delay)</p>
  <button (click)="cancel()">Cancel request</button>
} @else if (jokes.error()) {
  <p>Cancelled or failed: {{ jokes.error()?.message }}</p>
  <button (click)="jokes.reload()">Retry</button>
} @else {
  @for (joke of jokes.value() ?? []; track joke.id) {
    <p><strong>{{ joke.setup }}</strong>: {{ joke.punchline }}</p>
  }
}
Enter fullscreen mode Exit fullscreen mode

Example 5: Streaming with an Async Generator

Every example so far uses loader, and loader has one job which is run once per request and resolve with a single value. Some data does not behave like that though, it comes in over time, one piece at a time, and that is exactly what the stream option on resource() is built for. Instead of a Promise<T>, stream hands back a Signal that keeps updating as new values show up, and the resource's own value() updates right along with it.

In service we do:

import { resource, Service, Signal, signal } from '@angular/core';
import { Joke } from './jokes';
import { JOKES_API } from './http-demo-service';

@Service()
export class ResourceDemoService {
  // Async generator: yields one joke at a time with a 2s gap between each.
  async *#jokeGenerator(count: number, abortSignal: AbortSignal): AsyncGenerator<Joke> {
    for (let i = 0; i < count; i++) {
      if (abortSignal.aborted) return;

      if (i > 0) {
        // Delay between jokes so the streaming effect is visible.
        await new Promise<void>((resolve, reject) => {
          const timer = setTimeout(resolve, 2000);
          abortSignal.addEventListener(
            'abort',
            () => {
              clearTimeout(timer);
              reject(abortSignal.reason);
            },
            { once: true },
          );
        });
        if (abortSignal.aborted) return;
      }

      const res = await fetch(JOKES_API.GET_JOKES_WITH_DYNAMIC_NUMBERS(1), { signal: abortSignal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const [joke] = await res.json();
      yield joke;
    }
  }

  jokeStreamResource = (jokeCount: Signal<number | undefined>) => {
    return resource<Joke[], { count: number } | undefined>({
      params: () => {
        const count = jokeCount();
        return count !== undefined && count > 0 ? { count } : undefined;
      },
      defaultValue: [],
      debugName: 'JokeStreamResource',
      stream: async ({ params, abortSignal }) => {
        const generator = this.#jokeGenerator(params.count, abortSignal);

        // Pull the first joke before returning, keeps the resource in 'loading' until real data arrives.
        const { value: firstJoke, done } = await generator.next();
        if (done || !firstJoke) throw new Error('No jokes returned');

        const accumulated = signal<{ value: Joke[] } | { error: Error }>({ value: [firstJoke] });

        // Consume the rest with for await...of, fire and forget in an IIFE.
        (async () => {
          try {
            for await (const joke of generator) {
              accumulated.update((prev) => ({
                value: [...('value' in prev ? prev.value : []), joke],
              }));
            }
          } catch (err) {
            if (!abortSignal.aborted) {
              accumulated.set({ error: err instanceof Error ? err : new Error(String(err)) });
            }
          }
        })().then(....);

        return accumulated;
      },
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

Quick chat between us, because this deserves a shoutout so JavaScript has had generator functions since ES2015 (function*) and async generators since ES2018 (async function*), and most of us go years without ever writing one. A generator is a function that can pause itself with yield and pick up exactly where it left off the next time you ask it for a value. An async generator is the same idea, except each step can also await something first, which is perfect for logic like "get the next joke, wait a bit, get the next one."

I thought this was a nice chance to show off a feature that exists in plain JS and rarely gets used. Since async generators can await inside them, the natural way to consume one is for await (const joke of generator). It reads just like a normal for...of loop, but under the hood it awaits each yielded value before moving to the next. That is exactly what this demo needed: fetch one joke, wait two seconds, fetch the next, without ever loading the whole batch up front.

Notice the loader waits on generator.next() once before it ever returns the signal. That first await is what keeps the resource in 'loading' status until a real joke actually exists, then it flips to 'resolved' while the rest keep streaming in behind the scenes. Skip that first await and return the signal immediately, and the resource resolves right away with an empty array instead.

In component we do:

import { Component, inject, signal } from '@angular/core';
import { ResourceDemoService } from '../service/resource-demo.service';

@Component({
  selector: 'app-jokes-stream',
  templateUrl: './jokes-stream.html',
})
export class JokesStreamComponent {
  #service = inject(ResourceDemoService);

  count = signal<number | undefined>(3);

  jokes = this.#service.jokeStreamResource(this.count);
}
Enter fullscreen mode Exit fullscreen mode

In HTML then we do:

<input
  #input
  type="number"
  placeholder="How many jokes to stream?"
  [value]="count()"
  (input)="count.set(input.valueAsNumber)"
/>

@if (jokes.isLoading()) {
  <p>Waiting for the first joke...</p>
} @else if (jokes.error()) {
  <p>Something went wrong: {{ jokes.error()?.message }}</p>
}

@for (joke of jokes.value(); track joke.id) {
  <div>
    <strong>{{ joke.setup }}</strong>
    <p>{{ joke.punchline }}</p>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Example 6: Real-Time Data with Server-Sent Events

Not every stream needs a generator behind it. All stream really wants is a Signal that can change over time, so anything that already pushes data at you, like WebSockets, Server-Sent Events, or a Firestore listener, plugs in just as easily. Here is a live feed of real Wikipedia edits over SSE: no generator, no polling, just a native EventSource writing into a signal.

i will use EventSource coz 0 setup config

In service we do:

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

export interface WikiRecentChange {
  title: string;
  user: string;
  comment: string;
  wiki: string;
  type: 'edit' | 'new' | 'categorize' | 'log';
  timestamp: number;
  server_url: string;
  namespace: number;
  length?: { old: number; new: number };
}

const WIKI_STREAM_URL = 'https://stream.wikimedia.org/v2/stream/recentchange';

@Service()
export class ResourceDemoService {
  // No params, no reactive dependency: this stream connects once and runs
  // until the resource is destroyed. EventSource is closed via abortSignal
  // when the component goes away.
  wikiEditStreamResource = resource<WikiRecentChange[], Record<never, never>>({
    params: () => ({}),
    defaultValue: [],
    debugName: 'WikiEditStream',
    stream: ({ abortSignal }) => {
      const editsSignal = signal<{ value: WikiRecentChange[] } | { error: Error }>({ value: [] });

      const source = new EventSource(WIKI_STREAM_URL);

      // Angular fires abortSignal when the resource is destroyed or reloaded.
      // EventSource has no native AbortSignal support, so we close it manually.
      abortSignal.addEventListener('abort', () => source.close(), { once: true });

      source.onmessage = (event: MessageEvent) => {
        if (abortSignal.aborted) {
          source.close();
          return;
        }
        try {
          const edit = JSON.parse(event.data as string) as WikiRecentChange;
          // Filter to main namespace (0) articles only for a cleaner feed.
          if (edit.namespace !== 0) return;
          editsSignal.update((prev) => ({
            value: [edit, ...('value' in prev ? prev.value : [])].slice(0, 20),
          }));
        } catch {
          /* ignore events or handle your own error */
        }
      };

      source.onerror = () => {
        if (!abortSignal.aborted) {
          editsSignal.set({
            error: new Error('EventSource connection lost, reload to reconnect'),
          });
        }
        source.close();
      };

      return editsSignal;
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

params: () => ({}) always returns the exact same shape, so Angular never sees a new request and the stream function only runs once. That is the connect-once-and-run-forever pattern, so no reactive params to react to, just a single stream that keeps pushing values until the resource itself is destroyed.

EventSource was not built with AbortSignal in mind, there is no { signal } option like fetch has. That is why the code closes it manually inside an abort event listener instead. Worth remembering any time you wrap a non-fetch API in stream: check what that API uses for cancellation, and bridge Angular's abortSignal into it yourself.

Thanks to @_W4XUmu3QzCEcPNElv8dLg for reviewing and dusscssing this part together

Worth knowing before you copy this pattern elsewhere, signals never buffer, no matter which method you write with. resource.value() is just a plain computed() reading whatever signal stream hands back, so if that signal gets written twice before anything reads it, only the second write ever existed as far as anyone downstream is concerned.

Switching from .set() to .update() does not fix this by itself. What actually matters is whether your update function reads the previous value and folds it into the new one. Look at what this example already does above: editsSignal.update((prev) => ({ value: [edit, ...prev.value].slice(0, 20) })) takes whatever was already there and carries it forward into the new value. That is the part doing the work, not the method name.

This is also a different problem from the stale request cancellation in the AbortSignal example. That one is about several independent network requests racing each other, solved with abortSignal. This one is about a single that being overwritten before anything looked at it, and the fix is how you write to that signal, not how you cancel anything.

In component we do:

import { Component, inject } from '@angular/core';
import { ResourceDemoService } from '../service/resource-demo.service';

@Component({
  selector: 'app-wiki-feed',
  templateUrl: './wiki-feed.html',
})
export class WikiFeedComponent {
  #service = inject(ResourceDemoService);
  edits = this.#service.wikiEditStreamResource;
}
Enter fullscreen mode Exit fullscreen mode

In HTML then we do:

@if (edits.isLoading()) {
  <p>Connecting to the live feed...</p>
} @else if (edits.error()) {
  <p>{{ edits.error()?.message }}</p>
}

@for (edit of edits.value(); track edit.timestamp) {
  <div>
    <strong>{{ edit.title }}</strong> ({{ edit.wiki }})
    <p>{{ edit.user }}: {{ edit.comment }}</p>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Quick Comparison

Pattern Has params? Signal-reactive? Skippable? AbortSignal? Streams over time?
Pure form No No No No No
Async/await + params Yes Yes Partial (guard in loader) No No
Conditional loading Yes Yes Yes (return undefined from params) No No
AbortSignal Yes Yes Yes Yes No
Async generator stream Yes Yes Yes Yes Yes
SSE stream Yes (static) No No Yes Yes

Resource Signal API

Verified against the actual Angular Resource<T>, WritableResource<T>, and ResourceRef<T> type definitions.

All members below are properties on the ResourceRef<T> returned by resource(). Each Signal<X> means you call it as a function to read the current value.

// ── Signals (call as a function to read) ──────────────────────────────
resource.value()        // T | undefined: throws in 'error' state; always guard with hasValue()
resource.status()       // ResourceStatus: current lifecycle state (see table below)
resource.error()        // Error | undefined: last known error; only set in 'error' status
resource.isLoading()    // boolean: true during both 'loading' and 'reloading'
resource.snapshot()     // ResourceSnapshot<T>: { status, value, error } as a plain object

// ── Methods ───────────────────────────────────────────────────────────
resource.hasValue()     // boolean: true only when status is 'resolved' or 'local' with a value
resource.reload()       // boolean: true if reload was initiated; false if unnecessary
resource.set(value)     // void: write a local value (moves status to 'local')
resource.update(fn)     // void: update local value with a function (moves status to 'local')
resource.asReadonly()   // Resource<T>: read-only view; strips set/update/reload/destroy
resource.destroy()      // void: cancel in-flight request and return to 'idle'
Enter fullscreen mode Exit fullscreen mode

Common mistake: resource.error is Signal<Error | undefined> on the public interface: calling resource.error() returns Error | undefined, not the signal itself. The error?: Error you may find inside Angular's source is on the internal WrappedRequest type (private implementation detail), not the public API.

Where ResourceStatus is:

type ResourceStatus = 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local';
Enter fullscreen mode Exit fullscreen mode
Status What it means
idle params returned undefined: no request, no value
loading Loader is running for the first time, value() is undefined
reloading Loader re-running (param change / manual reload), value() still holds previous data
resolved Loader completed, value() has the data
error Loader threw or the promise rejected, value() throws if called
local Value was set manually via .set() or .update()

'reloading' is the UX gem: value() still returns stale data while the new fetch runs, so you can show a subtle spinner on top of existing content instead of a full loading screen.


Safe Value Access: Avoiding the Runtime Error

Calling resource.value() when the resource is in an error state throws at runtime. Always guard reads with hasValue(). This is called out explicitly in the official Angular docs.
See https://angular.dev/guide/signals/resource

Option 1: Guard in the template with hasValue()

@if (jokes.hasValue()) {
  @for (joke of jokes.value(); track joke.id) {
    <p>{{ joke.setup }}</p>
  }
} @else if (jokes.error()) {
  <p>Something went wrong.</p>
} @else if (jokes.isLoading()) {
  <p>Loading...</p>
}
Enter fullscreen mode Exit fullscreen mode

Option 2: Guard in the component with computed()

Keeps the template clean and lets you pass the value to child components or derive further state from it.

import { computed, inject } from '@angular/core';

export class JokesComponent {
  #service = inject(ResourceDemoService);
  jokes = this.#service.pureJokeDataResource;

  jokesValue = computed(() =>
    this.jokes.hasValue() ? this.jokes.value() : undefined
  );
}
Enter fullscreen mode Exit fullscreen mode

Then in the template:

@if (jokesValue()) {
  @for (joke of jokesValue()!; track joke.id) {
    <p>{{ joke.setup }}</p>
  }
} @else if (jokes.error()) {
  <p>Something went wrong.</p>
}
Enter fullscreen mode Exit fullscreen mode

Which one to pick

Approach Best for
hasValue() in template Simple components, straightforward conditional rendering
computed() with hasValue() Reusing the value in multiple places, passing it to child components, or deriving further state

Follow me for more

Follow me for more
Twitter / X | LinkedIn | Facebook | Bluesky

Top comments (0)