Angular State Management: Best Practices to Advance Your Project

Katie Mikova / Thursday, June 13, 2024

When we are building Angular apps with heavy data communications, we need to consider a holistic approach, addressing factors like data efficiency, network latency, scalability, resource management, testing, and UX. And one of the things that is extremely vital for avoiding data conflicts while keeping the app scalable and consistent is effective Angular State Management. Without it, data will be just everywhere. 

But what is State Management in Angular and how exactly should it be handled? Let’s delve into Angular State Management best practices with Ignite UI for Angular and look at how to approach it with a Service Basic implementation utilizing Rxjs and State Management with NgRx, supported by real code examples.

Quick article overview: 

Try Ignite UI Angular For Free

What Is State Management in Angular 

This is a process involving different techniques and strategies for handling and maintaining the state of Angular applications. In this case, the state of a web application refers to all the data used by the application, including data fetched from external services and manipulated and displayed by the app, and also all data the application creates, stores, and uses to build the correct UI for a given user.

In simple words, you may wonder why we need State Management in Angular. Think of this - every new page or new functionality, every new UI in the app that indicates a current condition (for example, if a user is logged in or not) adds to the complexity of an app. Once you go beyond the scope of a simple app, managing all of this is hard if one does not apply an organized way of state management. The most common way to do this in Angular is a state container, which not only retrieves, shares, and updates the data but also allows it to be accessed, interacted with, and further modified in a clean and well-organized way.

Making the Call or Knowing When It’s Necessary 

As the application scales and becomes more complex, it usually means dealing with a lot of data, and managing data in big projects can be tricky. Here, things get even messier when you work with asynchronous data, more data changes to be tracked, and more code. Chances are you will end up with too many things going on behind the scenes, like constant change computing, reflows, or API requests, which may even block the browser's main thread and make the app unresponsive. If not approached systematically with state management, the above would mean too many expenses to fix bugs, technical debt, etc.

So, when to use State Management Angular? Here are a few use cases. 

  • Growing application complexity 

While simple apps with minimal data flow may not require State Management, it is especially beneficial in large-scale software projects with multiple components, many data interactions, a growing set of features, and intricate user interfaces.  

What’s achieved here: maintain order, data consistency, better performance, and improved UX + deliver responsive interfaces. 

  • Sharing data 

State management becomes essential when data needs to be shared between different parts of your application, such as components or services. 

What’s achieved here: centralized data management and simplified data sharing. 

  • In asynchronous operations 

If your app involves asynchronous operations such as fetching data from APIs, handling real-time updates, or dealing with user interactions. 

What’s achieved here: streamlined asynchronous data flow and responsive UI. 

  • When aiming at predictable state changes 

Do you want strict control over how and when the state changes in your Angular applications? There are different state management libraries that can facilitate the process. 

What’s achieved here: ensured predictable and easy-to-debug codebase. 

Making Developers' Job Easier: Angular State Management Best Practices With Ignite UI

As to how to manage state in Angular, there are various techniques and tools, depending on the complexity of the app, project requirements, plus your knowledge and preferences. For example, if the state is specific to a single component, you can manage the state in Angular within the component itself. To store and update data here, you will have to rely on class properties and variables. When it comes to large-scale applications, it’s better to consider using state management libraries. 

But let’s look at this in detail and explore the best Angular State Management techniques. 

  1. Service Basic Implementation Utilizing Rxjs as a Great Way To Manage State

State management in Angular using Services and RxJS is a powerful and flexible approach and here are some best practices: 

  • Single responsibility principle 

Ensure each service has a single responsibility. By designing services to handle specific tasks (e.g., logging and authenticating a user, managing user data, etc.), you keep your codebase clean and maintainable. Each service should manage a specific part of the state, making it easier to debug and test. 

  • Service as a single source of truth 

Treat the service as a single source of truth for the state. Components should rely on the service for state information and updates, promoting a unidirectional data flow and making the application easier to reason about. 

  • Encapsulate state logic in services 

Keep state logic inside services, not in components. Services should handle business logic and state management, while components focus on presentation and interaction. This separation of concerns enhances testability and reusability. 

  • Error handling 

Implement error handling within your services. Catch and handle errors in your observables to provide a smooth user experience and prevent the application from crashing. 

  • Utilizing RxJs operators and subjects for State Management 

To do that, firstly, choose the right subject - a basic multicast object, emitting values to multiple subscribers without holding onto any state. Suitable for events or actions. Next, define Behavior Subject. It is a type of Subject that requires an initial value and emits its current value to new subscribers. This is useful for state management as it allows you to initialize the state and always provide the latest state to any new subscribers.ReplaySubject: Emits a specified number of previous values to new subscribers. Useful when you need to replay past state values. Next, with AsyncSubject, you can emit the last value (and only the last value) on completion. It is useful for scenarios where only the final emitted value is needed. 

As a step 2, you can use multiple state streams. Use RxJS operators like combineLatest, withLatestFrom, etc., to combine multiple state streams. Often, you need to derive a state based on multiple observables. Operators like combineLatest allow you to create new state observables based on the combination of other state streams. Then, write unit tests for services and their state management logic. Ensuring your services work as expected through testing is crucial for maintaining application stability and catching issues early. 

By using a service, you centralize the logic for fetching and storing weather data. This ensures that all components that need weather data get it from a single source of truth. 

Weather service 

interface WeatherData {
    temperature: number;
   description: string;
}
@Injectable({
  providedIn: 'root'
})
export class WeatherService {
  private apiKey = 'YOUR_API_KEY';
  private _weather: BehaviorSubject<WeatherData | null> = new BehaviorSubject<WeatherData | null>(null);
  public weather$: Observable<WeatherData | null> = this._weather.asObservable();

 

constructor(private http: HttpClient) { }
  private getApiUrl(location: string): string {
   return `https://api.weatherapi.com/v1/current.json?key=${this.apiKey}&q=${location}`;
  }
  fetchWeather(location: string): Observable<WeatherData | null> {
    const apiUrl = this.getApiUrl(location);
    return this.http.get<WeatherData>(apiUrl).pipe(
      tap((data: WeatherData) => this._weather.next(data)),
      catchError(error => {
        console.error('Error fetching weather data', error);
        this._weather.next(null);
        return of(null);
      })
    );
  }
}
 

The ‘WeatherService` fetches data from the API and stores it in a `BehaviorSubject`, making it accessible to any component that injects the service. Using `BehaviorSubject` from rxjs. allows your application to reactively manage data changes. When the weather data is updated in the service, all components subscribed to the `BehaviorSubject` are automatically notified and can update their views accordingly. 

Component TS file: 

export class WeatherDisplayComponent implements OnInit {
      weather$: Observable<WeatherData | null>;
      constructor(private weatherService: WeatherService) {
       this.weather$ = this.weatherService.weather$;
      }
     ngOnInit(): void {
      this.weatherService.fetchWeather('New York');
      }
    }

Component HTML file: 

<div *ngIf="weather$ | async as weather"> 
   <h2>Current Weather</h2> 
   <p>Temperature: {{ weather.temperature }}°C</p> 
   <p>Description: {{ weather.description }}</p> 
</div> 

Components remain focused on displaying data and handling user interactions without worrying about data fetching or state management. This separation of concerns makes components easier to write, test, and maintain. 

Here is an example of State Management with NgRx: 

Using NgRx for state management in your Angular application can provide additional benefits, especially for larger and more complex applications. NgRx is a library for managing reactive state in Angular applications using the Redux pattern. 

Define the structure of your weather data: 

weather.model.ts 

export interface WeatherData {
       temperature: number;
       description: string;
    }
    export interface WeatherState {
       weather: WeatherData | null; 
       loading: boolean; 
       error: string | null; 
    } 
 

Define Actions to represent different events in the weather feature: 

weather.actions.ts 

export const loadWeather = createAction(
      '[Weather] Load Weather',
      props<{ location: string }>()
    );
    export const loadWeatherSuccess = createAction(
     '[Weather] Load Weather Success',
      props<{ weather: WeatherData }>()
    );
    export const loadWeatherFailure = createAction(
      '[Weather] Load Weather Failure', 
      props<{ error: string }>()
    );  

Define the reducer to handle state changes based on actions 

weather.reducer.ts 

export const initialState: WeatherState = {
      weather: null,
      loading: false,
      error: null,
    };
    export const weatherReducer = createReducer(
      initialState,
      on(loadWeather, (state) => ({
       ...state,
       loading: true,
       error: null,
      })),
      on(loadWeatherSuccess, (state, { weather }) => ({
       ...state,
       weather,
       loading: false,
      })),
     on(loadWeatherFailure, (state, { error }) => ({
       ...state,
       loading: false,
       error,
      }))
    );


Handle side effects to handle side effects like API calls: 

weather.effects.ts 

@Injectable()
export class WeatherEffects {
  loadWeather$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadWeather),
      mergeMap(action => this.http.get<WeatherData>(`https://api.weatherapi.com/v1/current.json?key=YOUR_API_KEY&q=${action.location}`).pipe(
        map(weather => loadWeatherSuccess({ weather })),
        catchError(error => of(loadWeatherFailure({ error: error.message })))
      )
      ))
  );
  constructor(private actions$: Actions, private http: HttpClient) { }
}
 

In your app.module.ts, register the NgRx store and effects so they can be used across your application. 

@NgModule({
       imports: [
        BrowserModule,
        HttpClientModule,
        StoreModule.forRoot({ weather: weatherReducer }),
        EffectsModule.forRoot([WeatherEffects]),
       ]
    }) 

Define selectors for the weather state to derive pieces of state from the weather feature state. 

weather.selectors.ts 

export const selectWeatherState = createFeatureSelector<WeatherState>('weather');
export const selectWeather = createSelector(
  selectWeatherState,
  (state: WeatherState) => state.weather
);
export const selectLoading = createSelector(
  selectWeatherState,
  (state: WeatherState) => state.loading
);
export const selectError = createSelector(
  selectWeatherState,
  (state: WeatherState) => state.error
);


Inject the store into your component and dispatch actions to load weather data and select the state to display. 

weather-display.component.ts 

export class WeatherDisplayComponent implements OnInit {
      weather$: Observable<WeatherData | null>;
      loading$: Observable<boolean>;
      error$: Observable<string | null>;
      constructor(private store: Store<{ weather: WeatherState }>) { 
       this.weather$ = this.store.pipe(select(selectWeather)); 
       this.loading$ = this.store.pipe(select(selectLoading)); 
       this.error$ = this.store.pipe(select(selectError)); 
      }
      ngOnInit(): void { 
       this.store.dispatch(loadWeather({ location: 'New York' })); 
      }
    }

Component HTML file:

<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error">{{ error }}</div>
<div *ngIf="weather$ | async as weather">
   <h2>Current Weather</h2>
   <p>Temperature: {{ weather.temperature }}°C</p>
   <p>Description: {{ weather.description }}</p>
</div>

2. Consider Using State Management Libraries - NgRx 

NgRx is a powerful library for managing state in Angular applications using reactive programming principles. It is inspired by the Redux pattern and integrates well with Angular, making it easier to maintain and debug the state of your application.

Angular State Management with NgRx

Just to make things clearer, here are the core concepts explained: 

Store - Ensures consistent state management, promotes unidirectional data flow and provides a structured way to handle application state. It represents the entire application state as an immutable object tree. Each node in this tree corresponds to a specific part of your application state, accessed through observables, allowing components to subscribe to specific slices of the state. 

Actions - They describe events in NgRx-powered applications. They represent something that took place within the application and can be considered a statement of fact. 

Reducers - Functions that take the current state and an action and return a new state. Reducers specify how the state changes in response to actions. 

Selectors - Functions used to select slices of the state from the store. They help in encapsulating state structure and can be composed to create more complex selectors. 

Effects - Side effects are handled outside of the store. Effects are classes that listen for specific actions and perform tasks such as API calls and dispatch new actions based on the results. They use Angular Dependency Injection and leverage RxJS observables to manage side effects. 

Quick Tips & Tricks When Using NgRx 

Firstly, organize your state and perform feature state separation. To do that, separate the state by feature modules. Each feature module should have its own slice of the state to keep things modular and maintainable. Use a consistent folder structure within each feature module, such as actions, reducers, selectors, and effects.

Then, you can define clear action types. For Action Naming, use descriptive action types. Prefix action types with the feature they belong to, e.g., ‘[Weather] Load Weather’. For Action Payloads, use strongly typed payloads. You should also define interfaces for the payload to ensure type safety. Another important thing in terms of NgRx is to write pure reducers. Always ensure reducers are pure functions and always return a new state object instead of mutating the existing state. And keep logic in reducers minimal. Avoid side effects or complex logic in reducers; delegate those to effects.

Using Selectors for state access is also something to consider. You can use selectors to encapsulate the state structure. This helps in keeping your component code clean and abstracts away the state shape. Or you can compose selectors to create a more complex state selection. An important thing to do is to use effects to handle side effects like API calls, logging, etc. This keeps your reducers pure and your components clean. And keep in mind you should always handle errors in effects. Use appropriate actions to manage error states.

Lastly, there are several things to note in terms of testing. Write unit tests for reducers to ensure they return the correct state for given actions. Test effects to ensure they dispatch the correct actions and handle side effects properly. We recommend using the ‘MockStore’ to test components in isolation from the actual NgRx store.

Let’s demonstrate this more vividly with an Angular State Management example:

Imagine you are building a weather app. In it, you need to fetch data on weather updates from an API and display it in various parts of your application. To do that, you can create a WeatherService in Angular that handles API requests and stores the given data. This service can be injected into different components to access and display the weather information.

Wrap Up

In conclusion, these state management techniques for Angular applications, using Services with RxJS and NgRx, offer unique benefits and trade-offs that should be carefully considered based on project requirements and team expertise. In the end, the decision should prioritize the project's specific requirements, development constraints, and team capabilities. By carefully evaluating these factors, developers can choose the state management approach that best aligns with their goals and facilitates the successful delivery of the Angular application.

Ignite UI for Angular library