471 lines
10 KiB
Markdown
471 lines
10 KiB
Markdown
---
|
|
name: state-implementation
|
|
description: Implement NgRx store with actions and reducers, build selectors, create effects for async operations, configure entity adapters, and integrate HTTP APIs with state management.
|
|
---
|
|
|
|
# State Implementation Skill
|
|
|
|
## Quick Start
|
|
|
|
### Simple Service-Based State
|
|
```typescript
|
|
import { Injectable } from '@angular/core';
|
|
import { BehaviorSubject, Observable } from 'rxjs';
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class UserStore {
|
|
private usersSubject = new BehaviorSubject<User[]>([]);
|
|
users$ = this.usersSubject.asObservable();
|
|
|
|
constructor(private http: HttpClient) {}
|
|
|
|
loadUsers() {
|
|
this.http.get<User[]>('/api/users').subscribe(
|
|
users => this.usersSubject.next(users)
|
|
);
|
|
}
|
|
|
|
addUser(user: User) {
|
|
this.http.post<User>('/api/users', user).subscribe(
|
|
newUser => {
|
|
const current = this.usersSubject.value;
|
|
this.usersSubject.next([...current, newUser]);
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
export class UserListComponent {
|
|
users$ = this.userStore.users$;
|
|
|
|
constructor(private userStore: UserStore) {}
|
|
}
|
|
```
|
|
|
|
### NgRx Basics
|
|
```typescript
|
|
// 1. Define actions
|
|
export const loadUsers = createAction('[User] Load Users');
|
|
export const loadUsersSuccess = createAction(
|
|
'[User] Load Users Success',
|
|
props<{ users: User[] }>()
|
|
);
|
|
export const loadUsersError = createAction(
|
|
'[User] Load Users Error',
|
|
props<{ error: string }>()
|
|
);
|
|
|
|
// 2. Create reducer
|
|
const initialState: UserState = { users: [], loading: false };
|
|
|
|
export const userReducer = createReducer(
|
|
initialState,
|
|
on(loadUsers, state => ({ ...state, loading: true })),
|
|
on(loadUsersSuccess, (state, { users }) => ({
|
|
...state,
|
|
users,
|
|
loading: false
|
|
})),
|
|
on(loadUsersError, (state, { error }) => ({
|
|
...state,
|
|
error,
|
|
loading: false
|
|
}))
|
|
);
|
|
|
|
// 3. Create effect
|
|
@Injectable()
|
|
export class UserEffects {
|
|
loadUsers$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(loadUsers),
|
|
switchMap(() =>
|
|
this.userService.getUsers().pipe(
|
|
map(users => loadUsersSuccess({ users })),
|
|
catchError(error => of(loadUsersError({ error })))
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
constructor(
|
|
private actions$: Actions,
|
|
private userService: UserService
|
|
) {}
|
|
}
|
|
|
|
// 4. Use in component
|
|
@Component({...})
|
|
export class UserListComponent {
|
|
users$ = this.store.select(selectUsers);
|
|
loading$ = this.store.select(selectLoading);
|
|
|
|
constructor(private store: Store) {
|
|
this.store.dispatch(loadUsers());
|
|
}
|
|
}
|
|
```
|
|
|
|
## NgRx Core Concepts
|
|
|
|
### Store
|
|
```typescript
|
|
// Dispatch action
|
|
this.store.dispatch(loadUsers());
|
|
|
|
// Select state
|
|
this.store.select(selectUsers).subscribe(users => {
|
|
console.log(users);
|
|
});
|
|
|
|
// Select with observable
|
|
this.users$ = this.store.select(selectUsers);
|
|
|
|
// Multiple selects
|
|
this.store.select(selectUsers, selectLoading).subscribe(([users, loading]) => {
|
|
// ...
|
|
});
|
|
```
|
|
|
|
### Selectors
|
|
```typescript
|
|
// Feature selector
|
|
export const selectUserState = createFeatureSelector<UserState>('users');
|
|
|
|
// Select from feature
|
|
export const selectUsers = createSelector(
|
|
selectUserState,
|
|
state => state.users
|
|
);
|
|
|
|
// Selector composition
|
|
export const selectActiveUsers = createSelector(
|
|
selectUsers,
|
|
users => users.filter(u => u.active)
|
|
);
|
|
|
|
// Memoized selector
|
|
export const selectUserById = (id: number) => createSelector(
|
|
selectUsers,
|
|
users => users.find(u => u.id === id)
|
|
);
|
|
|
|
// With props
|
|
export const selectUsersByRole = createSelector(
|
|
selectUsers,
|
|
(users: User[], { role }: { role: string }) =>
|
|
users.filter(u => u.role === role)
|
|
);
|
|
|
|
// Usage with props
|
|
this.store.select(selectUsersByRole, { role: 'admin' });
|
|
```
|
|
|
|
### Effects
|
|
```typescript
|
|
// Side effect - HTTP call
|
|
@Injectable()
|
|
export class UserEffects {
|
|
loadUsers$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UserActions.loadUsers),
|
|
switchMap(() =>
|
|
this.userService.getUsers().pipe(
|
|
map(users => UserActions.loadUsersSuccess({ users })),
|
|
catchError(error => of(UserActions.loadUsersError({ error })))
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
// Non-dispatching effect
|
|
logActions$ = createEffect(
|
|
() => this.actions$.pipe(
|
|
tap(action => console.log(action))
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
constructor(
|
|
private actions$: Actions,
|
|
private userService: UserService
|
|
) {}
|
|
}
|
|
```
|
|
|
|
## Entity Adapter
|
|
|
|
### Setup
|
|
```typescript
|
|
export interface User {
|
|
id: number;
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
export const adapter = createEntityAdapter<User>({
|
|
selectId: (user: User) => user.id,
|
|
sortComparer: (a: User, b: User) => a.name.localeCompare(b.name)
|
|
});
|
|
|
|
export interface UserState extends EntityState<User> {
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
const initialState = adapter.getInitialState({
|
|
loading: false,
|
|
error: null
|
|
});
|
|
```
|
|
|
|
### Reducer with Adapter
|
|
```typescript
|
|
export const userReducer = createReducer(
|
|
initialState,
|
|
on(loadUsers, state => ({ ...state, loading: true })),
|
|
on(loadUsersSuccess, (state, { users }) =>
|
|
adapter.setAll(users, { ...state, loading: false })
|
|
),
|
|
on(addUserSuccess, (state, { user }) =>
|
|
adapter.addOne(user, state)
|
|
),
|
|
on(updateUserSuccess, (state, { user }) =>
|
|
adapter.updateOne({ id: user.id, changes: user }, state)
|
|
),
|
|
on(deleteUserSuccess, (state, { id }) =>
|
|
adapter.removeOne(id, state)
|
|
)
|
|
);
|
|
|
|
// Export selectors
|
|
export const {
|
|
selectIds,
|
|
selectEntities,
|
|
selectAll,
|
|
selectTotal
|
|
} = adapter.getSelectors(selectUserState);
|
|
```
|
|
|
|
## Facade Pattern
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class UserFacade {
|
|
users$ = this.store.select(selectAllUsers);
|
|
loading$ = this.store.select(selectUsersLoading);
|
|
error$ = this.store.select(selectUsersError);
|
|
|
|
constructor(private store: Store) {}
|
|
|
|
loadUsers() {
|
|
this.store.dispatch(loadUsers());
|
|
}
|
|
|
|
addUser(user: User) {
|
|
this.store.dispatch(addUser({ user }));
|
|
}
|
|
|
|
updateUser(id: number, changes: Partial<User>) {
|
|
this.store.dispatch(updateUser({ id, changes }));
|
|
}
|
|
|
|
deleteUser(id: number) {
|
|
this.store.dispatch(deleteUser({ id }));
|
|
}
|
|
}
|
|
|
|
// Component usage simplified
|
|
@Component({...})
|
|
export class UserListComponent {
|
|
users$ = this.userFacade.users$;
|
|
loading$ = this.userFacade.loading$;
|
|
|
|
constructor(private userFacade: UserFacade) {
|
|
this.userFacade.loadUsers();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Angular Signals
|
|
|
|
```typescript
|
|
import { signal, computed, effect } from '@angular/core';
|
|
|
|
// Create signal
|
|
const count = signal(0);
|
|
|
|
// Read value
|
|
console.log(count()); // 0
|
|
|
|
// Update value
|
|
count.set(1);
|
|
count.update(c => c + 1);
|
|
|
|
// Computed value
|
|
const doubled = computed(() => count() * 2);
|
|
|
|
// Effect
|
|
effect(() => {
|
|
console.log(`Count is ${count()}`);
|
|
console.log(`Doubled is ${doubled()}`);
|
|
});
|
|
|
|
// Signal-based state
|
|
@Component({...})
|
|
export class CounterComponent {
|
|
count = signal(0);
|
|
doubled = computed(() => this.count() * 2);
|
|
|
|
increment() {
|
|
this.count.update(c => c + 1);
|
|
}
|
|
}
|
|
```
|
|
|
|
## HTTP Integration
|
|
|
|
### HttpClient with Interceptor
|
|
```typescript
|
|
@Injectable()
|
|
export class AuthInterceptor implements HttpInterceptor {
|
|
constructor(private authService: AuthService) {}
|
|
|
|
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
|
const token = this.authService.getToken();
|
|
const authReq = req.clone({
|
|
setHeaders: {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
});
|
|
return next.handle(authReq);
|
|
}
|
|
}
|
|
|
|
// Register
|
|
@NgModule({
|
|
providers: [
|
|
{
|
|
provide: HTTP_INTERCEPTORS,
|
|
useClass: AuthInterceptor,
|
|
multi: true
|
|
}
|
|
]
|
|
})
|
|
export class AppModule { }
|
|
```
|
|
|
|
### Caching Strategy
|
|
```typescript
|
|
@Injectable()
|
|
export class CachingService {
|
|
private cache = new Map<string, any>();
|
|
|
|
get<T>(key: string, request: Observable<T>, ttl: number = 3600000): Observable<T> {
|
|
if (this.cache.has(key)) {
|
|
return of(this.cache.get(key));
|
|
}
|
|
|
|
return request.pipe(
|
|
tap(data => {
|
|
this.cache.set(key, data);
|
|
setTimeout(() => this.cache.delete(key), ttl);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
getUsers() {
|
|
return this.caching.get(
|
|
'users',
|
|
this.http.get<User[]>('/api/users'),
|
|
5 * 60 * 1000 // 5 minutes
|
|
);
|
|
}
|
|
```
|
|
|
|
## Testing State
|
|
|
|
```typescript
|
|
describe('User Store', () => {
|
|
let store: MockStore;
|
|
|
|
beforeEach(() => {
|
|
TestBed.configureTestingModule({
|
|
imports: [StoreModule.forRoot({ users: userReducer })]
|
|
});
|
|
store = TestBed.inject(Store) as MockStore;
|
|
});
|
|
|
|
it('should load users', () => {
|
|
const action = loadUsers();
|
|
const completion = loadUsersSuccess({ users: mockUsers });
|
|
|
|
const effect$ = new UserEffects(
|
|
hot('a', { a: action }),
|
|
mockUserService
|
|
).loadUsers$;
|
|
|
|
const result = cold('b', { b: completion });
|
|
expect(effect$).toBeObservable(result);
|
|
});
|
|
|
|
it('should select users', (done) => {
|
|
store.setState({ users: { users: mockUsers } });
|
|
store.select(selectUsers).subscribe(users => {
|
|
expect(users).toEqual(mockUsers);
|
|
done();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Normalize State**: Flat structure, avoid nesting
|
|
2. **Single Responsibility**: Each reducer handles one feature
|
|
3. **Use Facades**: Simplify component-store interaction
|
|
4. **Memoize Selectors**: Prevent unnecessary recalculations
|
|
5. **Handle Errors**: Always include error states
|
|
6. **Lazy Load Stores**: Register feature stores when needed
|
|
7. **Time-Travel Debugging**: Use Redux DevTools
|
|
|
|
## Advanced Patterns
|
|
|
|
### Composition Pattern
|
|
```typescript
|
|
// Combine multiple stores
|
|
@Injectable()
|
|
export class AppFacade {
|
|
users$ = this.userFacade.users$;
|
|
products$ = this.productFacade.products$;
|
|
cart$ = this.cartFacade.cart$;
|
|
|
|
constructor(
|
|
private userFacade: UserFacade,
|
|
private productFacade: ProductFacade,
|
|
private cartFacade: CartFacade
|
|
) {}
|
|
}
|
|
```
|
|
|
|
### Feature Flags
|
|
```typescript
|
|
export const selectFeatureFlags = createFeatureSelector<FeatureFlags>('features');
|
|
export const selectFeatureEnabled = (feature: string) => createSelector(
|
|
selectFeatureFlags,
|
|
flags => flags[feature]?.enabled ?? false
|
|
);
|
|
|
|
// Component
|
|
<div *ngIf="featureEnabled$ | async">New Feature</div>
|
|
```
|
|
|
|
## Resources
|
|
|
|
- [NgRx Documentation](https://ngrx.io/)
|
|
- [Entity Adapter](https://ngrx.io/guide/entity)
|
|
- [DevTools](https://github.com/reduxjs/redux-devtools-extension)
|