Obsah článku / Contents
NgRx Signal Store: Testing, Best Practices & Migration (Časť 3)

NgRx Signal Store: Testing, Best Practices & Migration

V tejto záverečnej časti sa pozrieme na porovnanie provider stratégií, testovanie, best practices a migráciu z classic NgRx.


Kapitola 5: Provider Comparison

typescript
comparison = {
  "providedIn: 'root'": {
    scope: "Celá aplikácia",
    lifecycle: "App bootstrap → destroy",
    instances: "1 (singleton)",
    sharing: "Všetky komponenty",
    syntax: `
      export const MyStore = signalStore(
        { providedIn: 'root' },
        ...
      );
    `,
    use_cases: [
      "User authentication",
      "Global settings",
      "Shopping cart",
      "Notifications"
    ]
  },

  "providers: [Store] (component)": {
    scope: "Komponenta + children",
    lifecycle: "Component create → destroy",
    instances: "N (jedna per komponenta)",
    sharing: "Len v component tree",
    syntax: `
      @Component({
        providers: [MyStore]
      })
    `,
    use_cases: [
      "Form state",
      "Dialog state",
      "Component UI state"
    ]
  },

  "providers: [Store] (route)": {
    scope: "Route tree",
    lifecycle: "Route activate → deactivate",
    instances: "1 per route activation",
    sharing: "Všetky komponenty v route",
    syntax: `
      {
        path: 'feature',
        providers: [MyStore]
      }
    `,
    use_cases: [
      "Feature-specific state",
      "Master-detail views",
      "Section-specific data"
    ]
  }
};

Kapitola 6: Testing

Testing Signal Store

typescript
// todo.store.spec.ts

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TodoStore } from './todo.store';

describe('TodoStore', () => {
  let store: InstanceType<typeof TodoStore>;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [TodoStore]
    });

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

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

  it('should initialize with empty state', () => {
    expect(store.todos()).toEqual([]);
    expect(store.loading()).toBe(false);
    expect(store.error()).toBeNull();
  });

  it('should load todos', async () => {
    const mockTodos = [
      { id: '1', title: 'Test', completed: false }
    ];

    store.loadTodos();

    const req = httpMock.expectOne('/api/todos');
    expect(req.request.method).toBe('GET');
    req.flush(mockTodos);

    // Wait for async operations
    await TestBed.flushEffects();

    expect(store.todos()).toEqual(mockTodos);
    expect(store.loading()).toBe(false);
  });

  it('should add todo', async () => {
    const newTodo = { id: '1', title: 'New', completed: false };

    await store.addTodo('New');

    const req = httpMock.expectOne('/api/todos');
    req.flush(newTodo);

    expect(store.todos()).toContain(newTodo);
  });

  it('should compute stats correctly', () => {
    patchState(store, {
      todos: [
        { id: '1', title: 'Test 1', completed: true },
        { id: '2', title: 'Test 2', completed: false }
      ]
    });

    const stats = store.stats();
    expect(stats.total).toBe(2);
    expect(stats.completed).toBe(1);
    expect(stats.active).toBe(1);
  });
});

Testing with Custom Features

typescript
// paginated-list.store.spec.ts

describe('ProductListStore with Pagination', () => {
  let store: InstanceType<typeof ProductListStore>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [ProductListStore]
    });

    store = TestBed.inject(ProductListStore);
  });

  it('should initialize pagination', () => {
    expect(store.page()).toBe(1);
    expect(store.pageSize()).toBe(20);
    expect(store.totalPages()).toBe(0);
  });

  it('should calculate total pages', () => {
    store.setTotal(100);
    expect(store.totalPages()).toBe(5);
  });

  it('should handle next page', () => {
    store.setTotal(100);

    expect(store.hasNextPage()).toBe(true);
    store.nextPage();
    expect(store.page()).toBe(2);
  });

  it('should not go beyond last page', () => {
    store.setTotal(100);
    store.setPage(5);

    expect(store.hasNextPage()).toBe(false);
    store.nextPage();
    expect(store.page()).toBe(5);
  });
});

Kapitola 7: Best Practices

DO's ✅

typescript
// 1. Use signalStore factory
export const MyStore = signalStore(
  withState(...),
  withComputed(...),
  withMethods(...)
);  // ✅

// 2. Use patchState for updates
patchState(store, { count: 5 });  // ✅

// 3. Use computed for derived state
withComputed(({ count }) => ({
  doubled: computed(() => count() * 2)
}));  // ✅

// 4. Use custom features for reusability
withPagination()  // ✅
withLoading()     // ✅
withUndo()        // ✅

// 5. Use withEntities for collections
withEntities<Product>()  // ✅

DON'Ts ❌

typescript
// 1. Don't mutate state directly
patchState(store, (state) => {
  state.count++;  // ❌ Mutation!
  return state;
});

// Instead:
patchState(store, { count: store.count() + 1 });  // ✅

// 2. Don't create signals outside store
class MyComponent {
  count = signal(0);  // ❌ Use store instead
}

// 3. Don't put complex logic in computed
withComputed(() => ({
  data: computed(() => {
    // ❌ HTTP call in computed
    http.get('/api/data').subscribe();
  })
}));

// 4. Don't forget to use rxMethod for observables
methods((store) => ({
  // ❌ Manual subscription
  loadData() {
    http.get('/api/data').subscribe(data => {
      patchState(store, { data });
    });
  }
}))

// Instead:
methods((store) => ({
  // ✅ Use rxMethod
  loadData: rxMethod(
    pipe(
      switchMap(() => http.get('/api/data')),
      tap(data => patchState(store, { data }))
    )
  )
}))

Kapitola 8: Migration from Classic NgRx

Before (Classic NgRx)

typescript
// State interface
interface CounterState {
  count: number;
  loading: boolean;
}

// Initial state
const initialState: CounterState = {
  count: 0,
  loading: false
};

// Actions
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const setLoading = createAction(
  '[Counter] Set Loading',
  props<{ loading: boolean }>()
);

// Reducer
export const counterReducer = createReducer(
  initialState,
  on(increment, state => ({ ...state, count: state.count + 1 })),
  on(decrement, state => ({ ...state, count: state.count - 1 })),
  on(setLoading, (state, { loading }) => ({ ...state, loading }))
);

// Selectors
export const selectCount = (state: CounterState) => state.count;
export const selectLoading = (state: CounterState) => state.loading;

// Usage
store.select(selectCount).subscribe(count => console.log(count));
store.dispatch(increment());

// Total: ~60 lines of boilerplate 😰

After (Signal Store)

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

  withState({
    count: 0,
    loading: false
  }),

  withMethods((store) => ({
    increment() {
      patchState(store, { count: store.count() + 1 });
    },

    decrement() {
      patchState(store, { count: store.count() - 1 });
    },

    setLoading(loading: boolean) {
      patchState(store, { loading });
    }
  }))
);

// Usage
console.log(store.count());  // No subscription!
store.increment();

// Total: ~20 lines! 🎉

Záver

NgRx Signal Store advantages:

typescript
benefits = {
  "Zero boilerplate": "No actions, reducers, selectors",
  "Type-safe": "Full TypeScript inference",
  "Composable": "Custom features (withPagination, withUndo...)",
  "Reactive": "Built on signals",
  "Testable": "Easy to test",
  "Performant": "Fine-grained updates",
  "Official": "Part of NgRx ecosystem"
};

vs_classic_ngrx = {
  "Lines of code": "~70% less",
  "Concepts to learn": "50% fewer",
  "Boilerplate": "Zero",
  "Subscriptions": "Not needed",
  "Performance": "Better (signals)"
};

When to use Signal Store:

typescript
✅ New Angular projects (v16+)
✅ Component/feature state
✅ Forms, UI state
✅ Any reactive state management
✅ When you want simplicity

Consider Classic NgRx when:
🔶 Large existing NgRx codebase
🔶 Need DevTools time-travel
🔶 Complex state orchestration

Provider Decision Tree:

typescript
if (shared_across_entire_app) {
  return "{ providedIn: 'root' }";
} else if (component_specific) {
  return "providers in component";
} else if (feature_specific) {
  return "providers in route";
}

Tutorial napísal developer ktorý prešiel z classic NgRx na Signal Store. Sometimes less is more. Zero boilerplate FTW! 🚀

P.S.: NgRx Signal Store je budúcnosť state managementu v Angulari. Clean. Simple. Powerful. ✨

Všetky články