Skip to content

State Management Best Practices

Effective state management is about more than just choosing a library. It’s about establishing patterns, conventions, and practices that ensure your application remains maintainable, scalable, and performant as it grows.

This guide covers proven best practices for state management in Angular, regardless of which library you choose.


All application state should be stored in one centralized location.

const appState = {
user: { },
products: { },
cart: { }
};

State should never be mutated directly. Changes happen through actions.

this.store.dispatch(updateUserName({ name: 'John' }));

Always create new objects/arrays instead of modifying existing ones.

return {
...state,
items: [...state.items, newItem]
};
return {
...state,
items: state.items.map(item =>
item.id === 1 ? { ...item, name: 'Updated' } : item
)
};

Reducers and selectors should be pure functions with no side effects.

const reducer = (state, action) => {
return {
...state,
data: action.payload
};
};

Store data in a normalized format to avoid duplication and ensure consistency.

{
users: {
ids: ['1'],
entities: {
'1': { id: '1', name: 'John' }
}
},
posts: {
ids: ['1', '2'],
entities: {
'1': { id: '1', title: 'Post 1', authorId: '1' },
'2': { id: '2', title: 'Post 2', authorId: '1' }
}
}
}
{
user: {
data: User | null,
loading: boolean,
error: string | null,
lastUpdated: Date | null
},
products: {
data: Product[],
loading: boolean,
error: string | null,
pagination: {
page: number,
pageSize: number,
total: number
}
}
}
{
ui: {
userModalOpen: boolean,
userModalSize: string,
userModalData: any
}
}

export const selectUser = createSelector(
selectUserState,
state => state.data
);
export const selectUserName = createSelector(
selectUser,
user => user?.name
);
export const selectFilteredItems = createSelector(
selectAllItems,
selectFilter,
(items, filter) => items.filter(item => item.type === filter)
);
export const selectItemById = (id: string) =>
createSelector(
selectAllItems,
items => items.find(item => item.id === id)
);
const item$ = this.store.select(selectItemById('123'));

export const loadUserProfile = createAction(
'[User] Load Profile'
);
export const loadUserProfileSuccess = createAction(
'[User] Load Profile Success',
props<{ profile: UserProfile }>()
);
export const loadUserProfileFailure = createAction(
'[User] Load Profile Failure',
props<{ error: string }>()
);
export const updateUser = createAction(
'[User] Update',
props<{ id: string; changes: Partial<User> }>()
);
export const loadUser = createAction(
'[User] Load User',
props<{ id: string }>()
);
export const loadUserSuccess = createAction(
'[User] Load User Success',
props<{ user: User }>()
);
export const loadUserFailure = createAction(
'[User] Load User Failure',
props<{ error: string }>()
);

export const userReducer = createReducer(
initialState,
on(loadUser, state => ({ ...state, loading: true })),
on(loadUserSuccess, (state, { user }) => ({
...state,
data: user,
loading: false,
error: null
})),
on(loadUserFailure, (state, { error }) => ({
...state,
loading: false,
error
}))
);
const userReducer = createReducer(initialUserState, ...);
const productsReducer = createReducer(initialProductsState, ...);
const uiReducer = createReducer(initialUiState, ...);
provideStore({
user: userReducer,
products: productsReducer,
ui: uiReducer
})
on(addItem, (state, { item }) => ({
...state,
items: [...state.items, item]
})),
on(updateItem, (state, { id, changes }) => ({
...state,
items: state.items.map(item =>
item.id === id ? { ...item, ...changes } : item
)
})),
on(removeItem, (state, { id }) => ({
...state,
items: state.items.filter(item => item.id !== id)
}))

loadUser$ = createEffect(() =>
this.actions$.pipe(
ofType(loadUser),
mergeMap(({ id }) =>
this.api.getUser(id).pipe(
map(user => loadUserSuccess({ user })),
catchError(error =>
of(loadUserFailure({ error: error.message }))
)
)
)
)
);
mergeMap(action => this.api.call())
concatMap(action => this.api.call())
switchMap(action => this.api.call())
mergeMap(action => this.api.call(), 3)
loadUserSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(loadUserSuccess),
tap(({ user }) => {
this.router.navigate(['/user', user.id]);
})
),
{ dispatch: false }
);

@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
user$ = this.store.select(selectUser);
}
user$ = this.store.select(selectUser);
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.store.select(selectUser)
.pipe(takeUntil(this.destroy$))
.subscribe(user => { });
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
userInfo$ = this.store.select(selectUser).pipe(
map(user => ({
name: user?.name,
email: user?.email
}))
);
export const selectUserInfo = createSelector(
selectUser,
user => ({
name: user?.name,
email: user?.email
})
);

describe('UserReducer', () => {
it('should add a user on loadUserSuccess', () => {
const user = { id: '1', name: 'John' };
const action = loadUserSuccess({ user });
const result = userReducer(initialState, action);
expect(result.data).toEqual(user);
expect(result.loading).toBe(false);
});
});
describe('User Selectors', () => {
it('should select user name', () => {
const state = {
user: {
data: { id: '1', name: 'John' }
}
};
const result = selectUserName(state);
expect(result).toBe('John');
});
});
describe('UserEffects', () => {
it('should dispatch loadUserSuccess on loadUser', () => {
const user = { id: '1', name: 'John' };
const action = loadUser({ id: '1' });
const completion = loadUserSuccess({ user });
actions$ = hot('-a', { a: action });
const response = cold('-b|', { b: user });
const expected = cold('--c', { c: completion });
api.getUser.and.returnValue(response);
expect(effects.loadUser$).toBeObservable(expected);
});
});

❌ Anti-Pattern 1: State in Multiple Places

Section titled “❌ Anti-Pattern 1: State in Multiple Places”
@Injectable()
export class UserService {
user = new BehaviorSubject(null);
}
@Component({
selector: 'app-user'
})
export class UserComponent {
user: User;
userFromService: User;
userFromStore: Observable<User>;
}
on(updateUser, (state, { user }) => ({
...state,
user
}))
{
user: { id: '1', name: 'John', email: 'john@example.com' }
}
export const selectUserDisplayName = createSelector(
selectUser,
user => user?.name
);

❌ Anti-Pattern 4: Putting Logic in Reducers

Section titled “❌ Anti-Pattern 4: Putting Logic in Reducers”
export const selectTotal = createSelector(
selectItems,
items => items.reduce((sum, item) => sum + item.price, 0)
);

  • Shared State — Multiple components need same data
  • Complex Interactions — State changes across multiple features
  • Historical Data — Need to track state changes over time
  • Performance — Need fine-grained change detection
  • Testing — Want predictable, testable state mutations
  • Debugging — Need time-travel debugging capabilities
  • Local Component State — Data used by single component
  • Simple CRUD — Basic form data in single component
  • Temporary UI State — Modal open/closed, dropdown expanded
  • Small Apps — Complexity not justified
  • Simple REST APIs — Just fetch and display

Identify what needs to be in the store vs. local component state.

Plan your state shape, actions, and selectors before implementation.

Start with one feature, then expand to others.

Gradually move existing state management to the store.

Monitor and optimize change detection and subscriptions.


  • ✅ Single source of truth for all application state
  • ✅ State is immutable and read-only
  • ✅ Changes happen only through actions
  • ✅ Normalized state structure
  • ✅ Memoized selectors for all state access
  • ✅ Proper error handling in effects
  • ✅ OnPush change detection enabled
  • ✅ Proper cleanup of subscriptions
  • ✅ Comprehensive unit tests
  • ✅ Redux DevTools enabled for debugging

Following these best practices ensures your state management:

  • Scales — Handles growing application complexity
  • Maintains — Code remains readable and organized
  • Performs — Efficient change detection and rendering
  • Tests — Easy to test in isolation
  • Debugs — Time-travel debugging and clear history
  • Teams — Clear patterns for team collaboration

Remember: The best state management solution is the one that scales with your application needs and team’s understanding.


This guide is based on and inspired by: