Obsah článku / Contents
NgRx Signal Store: Real-World Patterns (Časť 2)

NgRx Signal Store: Real-World Patterns

V tejto časti sa pozrieme na praktické patterns, ktoré budete používať v reálnych projektoch.


Kapitola 3: Real-World Patterns

Pattern 1: Store with HTTP

typescript
// stores/todo.store.ts

import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap } from 'rxjs';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

interface TodosState {
  todos: Todo[];
  loading: boolean;
  error: string | null;
}

export const TodoStore = signalStore(
  { providedIn: 'root' },

  withState<TodosState>({
    todos: [],
    loading: false,
    error: null
  }),

  withComputed(({ todos }) => ({
    completedTodos: computed(() => todos().filter(t => t.completed)),
    activeTodos: computed(() => todos().filter(t => !t.completed)),
    stats: computed(() => {
      const all = todos();
      return {
        total: all.length,
        completed: all.filter(t => t.completed).length,
        active: all.filter(t => !t.completed).length
      };
    })
  })),

  withMethods((store, http = inject(HttpClient)) => ({
    // RxMethod for reactive HTTP calls
    loadTodos: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap(() => http.get<Todo[]>('/api/todos')),
        tap({
          next: (todos) => patchState(store, { todos, loading: false }),
          error: (error) => patchState(store, {
            error: 'Failed to load todos',
            loading: false
          })
        })
      )
    ),

    async addTodo(title: string) {
      try {
        const newTodo = await http.post<Todo>('/api/todos', { title })
          .toPromise();

        if (newTodo) {
          patchState(store, {
            todos: [...store.todos(), newTodo]
          });
        }
      } catch (err) {
        patchState(store, { error: 'Failed to add todo' });
      }
    },

    async toggleTodo(id: string) {
      const todo = store.todos().find(t => t.id === id);
      if (!todo) return;

      // Optimistic update
      const updatedTodos = store.todos().map(t =>
        t.id === id ? { ...t, completed: !t.completed } : t
      );
      patchState(store, { todos: updatedTodos });

      try {
        await http.patch(`/api/todos/${id}`, {
          completed: !todo.completed
        }).toPromise();
      } catch (err) {
        // Revert on error
        patchState(store, {
          todos: store.todos(),
          error: 'Failed to update todo'
        });
      }
    },

    async deleteTodo(id: string) {
      const originalTodos = store.todos();

      // Optimistic delete
      patchState(store, {
        todos: originalTodos.filter(t => t.id !== id)
      });

      try {
        await http.delete(`/api/todos/${id}`).toPromise();
      } catch (err) {
        // Revert on error
        patchState(store, {
          todos: originalTodos,
          error: 'Failed to delete todo'
        });
      }
    }
  }))
);

Usage:

typescript
@Component({
  selector: 'app-todos',
  standalone: true,
  template: `
    <div>
      @if (store.loading()) {
        <p>Loading...</p>
      }

      @if (store.error(); as error) {
        <p class="error">{{ error }}</p>
      }

      <div class="stats">
        <p>Total: {{ store.stats().total }}</p>
        <p>Active: {{ store.stats().active }}</p>
        <p>Completed: {{ store.stats().completed }}</p>
      </div>

      <ul>
        @for (todo of store.todos(); track todo.id) {
          <li>
            <input
              type="checkbox"
              [checked]="todo.completed"
              (change)="store.toggleTodo(todo.id)"
            />
            <span [class.completed]="todo.completed">
              {{ todo.title }}
            </span>
            <button (click)="store.deleteTodo(todo.id)">
              Delete
            </button>
          </li>
        }
      </ul>
    </div>
  `
})
export class TodosComponent implements OnInit {
  readonly store = inject(TodoStore);

  ngOnInit() {
    this.store.loadTodos();
  }
}

Pattern 2: Store with LocalStorage

typescript
// stores/settings.store.ts

import { effect } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, withHooks } from '@ngrx/signals';
import { computed } from '@angular/core';

interface Settings {
  theme: 'light' | 'dark';
  language: 'en' | 'sk';
  notifications: boolean;
}

const STORAGE_KEY = 'app_settings';

function loadSettings(): Settings {
  const stored = localStorage.getItem(STORAGE_KEY);
  return stored ? JSON.parse(stored) : {
    theme: 'light',
    language: 'en',
    notifications: true
  };
}

export const SettingsStore = signalStore(
  { providedIn: 'root' },

  withState<Settings>(loadSettings()),

  withComputed(({ theme, language }) => ({
    isDarkTheme: computed(() => theme() === 'dark'),
    currentLanguage: computed(() => language())
  })),

  withMethods((store) => ({
    setTheme(theme: 'light' | 'dark') {
      patchState(store, { theme });
    },

    setLanguage(language: 'en' | 'sk') {
      patchState(store, { language });
    },

    toggleNotifications() {
      patchState(store, {
        notifications: !store.notifications()
      });
    }
  })),

  // Use hooks for side effects
  withHooks({
    onInit(store) {
      // Auto-save to localStorage on any change
      effect(() => {
        const settings: Settings = {
          theme: store.theme(),
          language: store.language(),
          notifications: store.notifications()
        };
        localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
      });
    }
  })
);

Pattern 3: Store with Entities

typescript
// stores/products.store.ts

import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { addEntity, removeEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
import { computed } from '@angular/core';

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

export const ProductsStore = signalStore(
  { providedIn: 'root' },

  // Use entity management
  withEntities<Product>(),

  withState({
    selectedId: null as string | null,
    filter: ''
  }),

  withComputed((store) => ({
    filteredProducts: computed(() => {
      const filter = store.filter().toLowerCase();
      return store.entities().filter(p =>
        p.name.toLowerCase().includes(filter)
      );
    }),

    selectedProduct: computed(() => {
      const id = store.selectedId();
      return id ? store.entityMap()[id] : null;
    }),

    productsByCategory: computed(() => {
      const products = store.entities();
      const grouped = new Map<string, Product[]>();

      products.forEach(p => {
        const list = grouped.get(p.category) ?? [];
        list.push(p);
        grouped.set(p.category, list);
      });

      return grouped;
    })
  })),

  withMethods((store) => ({
    addProduct(product: Product) {
      patchState(store, addEntity(product));
    },

    updateProduct(id: string, changes: Partial<Product>) {
      patchState(store, updateEntity({ id, changes }));
    },

    deleteProduct(id: string) {
      patchState(store, removeEntity(id));
    },

    selectProduct(id: string) {
      patchState(store, { selectedId: id });
    },

    setFilter(filter: string) {
      patchState(store, { filter });
    }
  }))
);

Pattern 4: Derived Stores (Composition)

typescript
// Base store
export const ProductStore = signalStore(
  { providedIn: 'root' },

  withEntities<Product>(),

  withMethods((store) => ({
    loadProducts(products: Product[]) {
      patchState(store, setAllEntities(products));
    }
  }))
);

// Derived store that depends on ProductStore
export const CartStore = signalStore(
  { providedIn: 'root' },

  withState({
    items: [] as Array<{ productId: string; quantity: number }>
  }),

  withComputed((store, productStore = inject(ProductStore)) => ({
    // Join cart items with products
    itemsWithDetails: computed(() => {
      const products = productStore.entityMap();
      return store.items().map(item => {
        const product = products[item.productId];
        return {
          ...item,
          product,
          subtotal: product ? product.price * item.quantity : 0
        };
      });
    }),

    total: computed(() => {
      const items = store.itemsWithDetails();
      return items.reduce((sum, item) => sum + item.subtotal, 0);
    })
  })),

  withMethods((store) => ({
    addToCart(productId: string, quantity: number = 1) {
      const items = store.items();
      const existing = items.find(i => i.productId === productId);

      if (existing) {
        patchState(store, {
          items: items.map(i =>
            i.productId === productId
              ? { ...i, quantity: i.quantity + quantity }
              : i
          )
        });
      } else {
        patchState(store, {
          items: [...items, { productId, quantity }]
        });
      }
    },

    removeFromCart(productId: string) {
      patchState(store, {
        items: store.items().filter(i => i.productId !== productId)
      });
    }
  }))
);

Kapitola 4: Custom Features

Custom Feature: withUndo

typescript
// features/with-undo.ts

import { computed, Signal } from '@angular/core';
import { signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals';

interface UndoState<T> {
  history: T[];
  currentIndex: number;
}

export function withUndo<T>(initialState: T) {
  return signalStoreFeature(
    withState<UndoState<T>>({
      history: [initialState],
      currentIndex: 0
    }),

    withComputed((store) => ({
      currentState: computed(() =>
        store.history()[store.currentIndex()]
      ),

      canUndo: computed(() => store.currentIndex() > 0),

      canRedo: computed(() =>
        store.currentIndex() < store.history().length - 1
      )
    })),

    withMethods((store) => ({
      addState(state: T) {
        const history = store.history().slice(0, store.currentIndex() + 1);
        patchState(store, {
          history: [...history, state],
          currentIndex: history.length
        });
      },

      undo() {
        if (store.canUndo()) {
          patchState(store, {
            currentIndex: store.currentIndex() - 1
          });
        }
      },

      redo() {
        if (store.canRedo()) {
          patchState(store, {
            currentIndex: store.currentIndex() + 1
          });
        }
      }
    }))
  );
}

// Usage
export const EditorStore = signalStore(
  withUndo({ content: '', cursor: 0 }),

  withMethods((store) => ({
    updateContent(content: string, cursor: number) {
      store.addState({ content, cursor });
    }
  }))
);

Custom Feature: withPagination

typescript
// features/with-pagination.ts

import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals';

interface PaginationState {
  page: number;
  pageSize: number;
  total: number;
}

export function withPagination(pageSize: number = 20) {
  return signalStoreFeature(
    withState<PaginationState>({
      page: 1,
      pageSize,
      total: 0
    }),

    withComputed((store) => ({
      totalPages: computed(() =>
        Math.ceil(store.total() / store.pageSize())
      ),

      hasNextPage: computed(() =>
        store.page() < store.totalPages()
      ),

      hasPrevPage: computed(() =>
        store.page() > 1
      ),

      offset: computed(() =>
        (store.page() - 1) * store.pageSize()
      )
    })),

    withMethods((store) => ({
      nextPage() {
        if (store.hasNextPage()) {
          patchState(store, { page: store.page() + 1 });
        }
      },

      prevPage() {
        if (store.hasPrevPage()) {
          patchState(store, { page: store.page() - 1 });
        }
      },

      setPage(page: number) {
        patchState(store, { page });
      },

      setPageSize(pageSize: number) {
        patchState(store, { pageSize, page: 1 });
      },

      setTotal(total: number) {
        patchState(store, { total });
      }
    }))
  );
}

// Usage
export const ProductListStore = signalStore(
  { providedIn: 'root' },

  withEntities<Product>(),
  withPagination(20),

  withMethods((store, http = inject(HttpClient)) => ({
    async loadPage() {
      const response = await http.get<PaginatedResponse<Product>>(
        '/api/products',
        {
          params: {
            offset: store.offset().toString(),
            limit: store.pageSize().toString()
          }
        }
      ).toPromise();

      if (response) {
        patchState(store, setAllEntities(response.items));
        store.setTotal(response.total);
      }
    }
  }))
);

Custom Feature: withLoading

typescript
// features/with-loading.ts

import { signalStoreFeature, withMethods, withState } from '@ngrx/signals';

interface LoadingState {
  loading: boolean;
  error: string | null;
}

export function withLoading() {
  return signalStoreFeature(
    withState<LoadingState>({
      loading: false,
      error: null
    }),

    withMethods((store) => ({
      setLoading(loading: boolean) {
        patchState(store, { loading, error: null });
      },

      setError(error: string) {
        patchState(store, { loading: false, error });
      },

      clearError() {
        patchState(store, { error: null });
      }
    }))
  );
}

// Usage
export const TodoStore = signalStore(
  { providedIn: 'root' },

  withEntities<Todo>(),
  withLoading(),  // Add loading feature

  withMethods((store, http = inject(HttpClient)) => ({
    async loadTodos() {
      store.setLoading(true);

      try {
        const todos = await http.get<Todo[]>('/api/todos').toPromise();
        patchState(store, setAllEntities(todos ?? []));
      } catch (err) {
        store.setError('Failed to load todos');
      }
    }
  }))
);

V poslednej časti sa pozrieme na Provider Comparison, Testing, Best Practices a Migration z classic NgRx.

Všetky články