NgRx Signal Store: Testing, Best Practices & Migration (Časť 3)
6 min
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 orchestrationProvider 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. ✨