Files
NGRX-Playground/.agents/skills/angular-signals/references/signal-patterns.md
T
2026-03-08 08:51:02 +01:00

8.9 KiB

Angular Signal Patterns

Table of Contents

Resource API

The resource() API handles async data fetching with signals:

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

@Component({...})
export class UserProfile {
  userId = signal<string>('');

  // Resource fetches data when params change
  userResource = resource({
    params: () => ({ id: this.userId() }),
    loader: async ({ params, abortSignal }) => {
      const response = await fetch(`/api/users/${params.id}`, {
        signal: abortSignal,
      });
      return response.json() as Promise<User>;
    },
  });

  // Access resource state
  user = computed(() => this.userResource.value());
  isLoading = computed(() => this.userResource.isLoading());
  error = computed(() => this.userResource.error());
}

Resource Status

const userResource = resource({...});

// Status signals
userResource.value();      // Current value or undefined
userResource.hasValue();   // Boolean - has resolved value
userResource.error();      // Error or undefined
userResource.isLoading();  // Boolean - currently loading
userResource.status();     // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local'

// Manual reload
userResource.reload();

// Local updates
userResource.set(newValue);
userResource.update(current => ({ ...current, name: 'Updated' }));

Resource with Default Value

const todosResource = resource({
  defaultValue: [] as Todo[],
  params: () => ({ filter: this.filter() }),
  loader: async ({ params }) => {
    const response = await fetch(`/api/todos?filter=${params.filter}`);
    return response.json();
  },
});

// value() returns Todo[] (never undefined due to defaultValue)

Conditional Loading

const userId = signal<string | null>(null);

const userResource = resource({
  params: () => {
    const id = userId();
    // Return undefined to skip loading
    return id ? { id } : undefined;
  },
  loader: async ({ params }) => {
    return fetch(`/api/users/${params.id}`).then((r) => r.json());
  },
});
// Status is 'idle' when params returns undefined

Signal Store Pattern

For complex state, create a dedicated store:

interface ProductState {
  products: Product[];
  selectedId: string | null;
  filter: string;
  loading: boolean;
  error: string | null;
}

@Injectable({ providedIn: "root" })
export class ProductSt {
  // Private state
  private state = signal<ProductState>({
    products: [],
    selectedId: null,
    filter: "",
    loading: false,
    error: null,
  });

  // Selectors (computed signals)
  readonly products = computed(() => this.state().products);
  readonly selectedId = computed(() => this.state().selectedId);
  readonly filter = computed(() => this.state().filter);
  readonly loading = computed(() => this.state().loading);
  readonly error = computed(() => this.state().error);

  readonly filteredProducts = computed(() => {
    const { products, filter } = this.state();
    if (!filter) return products;
    return products.filter((p) =>
      p.name.toLowerCase().includes(filter.toLowerCase()),
    );
  });

  readonly selectedProduct = computed(() => {
    const { products, selectedId } = this.state();
    return products.find((p) => p.id === selectedId) ?? null;
  });

  private http = inject(HttpClient);

  // Actions
  setFilter(filter: string): void {
    this.state.update((s) => ({ ...s, filter }));
  }

  selectProduct(id: string | null): void {
    this.state.update((s) => ({ ...s, selectedId: id }));
  }

  async loadProducts(): Promise<void> {
    this.state.update((s) => ({ ...s, loading: true, error: null }));

    try {
      const products = await firstValueFrom(
        this.http.get<Product[]>("/api/products"),
      );
      this.state.update((s) => ({ ...s, products, loading: false }));
    } catch (err) {
      this.state.update((s) => ({
        ...s,
        loading: false,
        error: "Failed to load products",
      }));
    }
  }

  async addProduct(product: Omit<Product, "id">): Promise<void> {
    const newProduct = await firstValueFrom(
      this.http.post<Product>("/api/products", product),
    );
    this.state.update((s) => ({
      ...s,
      products: [...s.products, newProduct],
    }));
  }
}

Form State with Signals

interface FormState<T> {
  value: T;
  touched: boolean;
  dirty: boolean;
  valid: boolean;
  errors: string[];
}

function createFormField<T>(
  initialValue: T,
  validators: ((value: T) => string | null)[] = []
) {
  const value = signal(initialValue);
  const touched = signal(false);
  const dirty = signal(false);

  const errors = computed(() => {
    return validators
      .map(v => v(value()))
      .filter((e): e is string => e !== null);
  });

  const valid = computed(() => errors().length === 0);

  return {
    value,
    touched: touched.asReadonly(),
    dirty: dirty.asReadonly(),
    errors,
    valid,

    setValue(newValue: T) {
      value.set(newValue);
      dirty.set(true);
    },

    markTouched() {
      touched.set(true);
    },

    reset() {
      value.set(initialValue);
      touched.set(false);
      dirty.set(false);
    },
  };
}

// Usage
@Component({...})
export class Signup {
  email = createFormField('', [
    v => !v ? 'Email is required' : null,
    v => !v.includes('@') ? 'Invalid email' : null,
  ]);

  password = createFormField('', [
    v => !v ? 'Password is required' : null,
    v => v.length < 8 ? 'Password must be at least 8 characters' : null,
  ]);

  formValid = computed(() =>
    this.email.valid() && this.password.valid()
  );
}

Async Operations

@Component({...})
export class Search {
  query = signal('');

  private http = inject(HttpClient);

  // Debounced search using toObservable
  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      filter(q => q.length >= 2),
      switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)),
      catchError(() => of([]))
    ),
    { initialValue: [] }
  );

  // Loading state
  private searching = signal(false);
  readonly isSearching = this.searching.asReadonly();

  constructor() {
    // Track loading state
    effect(() => {
      const q = this.query();
      if (q.length >= 2) {
        this.searching.set(true);
      }
    });

    effect(() => {
      this.results(); // Subscribe to results
      this.searching.set(false);
    });
  }
}

Optimistic Updates

@Injectable({ providedIn: "root" })
export class Todo {
  private todos = signal<Todo[]>([]);
  readonly items = this.todos.asReadonly();

  private http = inject(HttpClient);

  async toggleTodo(id: string): Promise<void> {
    // Optimistic update
    const previousTodos = this.todos();
    this.todos.update((todos) =>
      todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    );

    try {
      await firstValueFrom(this.http.patch(`/api/todos/${id}/toggle`, {}));
    } catch {
      // Rollback on error
      this.todos.set(previousTodos);
    }
  }
}

Testing Signals

describe("Counter", () => {
  it("should increment count", () => {
    const component = new Counter();

    expect(component.count()).toBe(0);

    component.increment();
    expect(component.count()).toBe(1);

    component.increment();
    expect(component.count()).toBe(2);
  });

  it("should compute doubled value", () => {
    const component = new Counter();

    expect(component.doubled()).toBe(0);

    component.count.set(5);
    expect(component.doubled()).toBe(10);
  });
});

describe("ProductSt", () => {
  let store: ProductSt;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [ProductSt, provideHttpClient(), provideHttpClientTesting()],
    });

    store = TestBed.inject(ProductSt);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it("should filter products", () => {
    // Set initial state
    store["state"].set({
      products: [
        { id: "1", name: "Apple" },
        { id: "2", name: "Banana" },
      ],
      selectedId: null,
      filter: "",
      loading: false,
      error: null,
    });

    expect(store.filteredProducts().length).toBe(2);

    store.setFilter("app");
    expect(store.filteredProducts().length).toBe(1);
    expect(store.filteredProducts()[0].name).toBe("Apple");
  });
});

Signal Debugging

// Debug effect to log signal changes
effect(() => {
  console.log("State changed:", {
    count: this.count(),
    items: this.items(),
    filter: this.filter(),
  });
});

// Conditional debugging
const DEBUG = signal(false);

effect(() => {
  if (untracked(() => DEBUG())) {
    console.log("Debug:", this.state());
  }
});