NgRx: Handling API State

February 5, 2019

Introduction

In any complex Angular application, managing the state of API requests is crucial to providing a smooth user experience. When I joined KOHO, I noticed we were managing these states within individual services, each with its own pattern. This lack of consistency made the code harder to maintain and manage, especially when different components depended on multiple services. Our solution was to wait until all services were loaded, delaying page display and resulting in a less dynamic experience.

The solution? A consistent, scalable approach using NgRx to manage API states, allowing components to load independently for a more responsive UI. This article walks through the pattern we developed to handle API state, making it reusable, testable, and easy to manage.


Problem

Managing API request states is challenging because we need to track loading, success, and error states for multiple requests across the application. Without a standardized approach, we end up with inconsistent implementations that increase code complexity and can make debugging more difficult.


Solution

After some research, I leveraged the NgRx library to manage our API states more consistently. NgRx, with its actions, effects, and reducers, allowed us to handle API states in a structured way. This approach adds a bit of boilerplate but provides the benefits of consistency, testability, and reusability.

Key Concepts of Our Approach

  1. Define a CallState Interface
    The CallState interface tracks the state of each API request, capturing loading, done, and error states.

    interface CallState {
      isDone: boolean; // true if request completed successfully
      isError: boolean; // true if request failed
      isLoading: boolean; // true if request is in progress
      error: string | null; // error message if request failed
    }
  2. Create Actions for Each API Endpoint
    For each endpoint, define actions to represent different request states: start, loading, done, error, and reset.

    const apiName = 'Get Transactions';
     
    export const getTransactionActions = {
      start: createAction(`[START] ${apiName}`),
      loading: createAction(`[LOADING] ${apiName}`),
      done: createAction(`[DONE] ${apiName}`, props<{ data: any }>()),
      error: createAction(`[ERROR] ${apiName}`, props<{ error: any }>()),
      reset: createAction(`[RESET] ${apiName}`),
    };
  3. Define an Effect to Handle the API Call
    Effects manage the API call lifecycle by dispatching actions based on the request’s state. The following example starts by emitting a loading action, performs the API call, then emits either a done or error action based on the response.

    @Effect()
    getTransactions$ = this.actions$.pipe(
      ofType(getTransactionActions.start),
      mergeMap(() =>
        this.dataService.getData().pipe(
          map(data => getTransactionActions.done({ data })),
          catchError(error => of(getTransactionActions.error({ error }))),
          startWith(getTransactionActions.loading())
        )
      )
    );
  4. Create a Reducer to Track Call States
    The reducer function updates the CallState based on each action type, maintaining a single source of truth for API state management across the application.

    function getCallStateReducer(action: any, state: CallState): CallState {
      if (action.type.includes('[LOADING]')) {
        return { isDone: false, isError: false, isLoading: true, error: null };
      }
     
      if (action.type.includes('[DONE]')) {
        return { isDone: true, isError: false, isLoading: false, error: null };
      }
     
      if (action.type.includes('[ERROR]')) {
        return {
          isDone: false,
          isError: true,
          isLoading: false,
          error: action.payload.error,
        };
      }
     
      if (action.type.includes('[RESET]')) {
        return { isDone: false, isError: false, isLoading: false, error: null };
      }
     
      return state;
    }
  5. Combine API and Data Reducers
    Use a combined reducer to manage both the transaction data and API call states, ensuring consistent updates.

    interface TransactionState {
      transactions: Transaction[];
      callState: { getTransactions: CallState };
    }
     
    export function transactionReducer(
      state = initialState,
      action
    ): TransactionState {
      return {
        transactions: transactionsReducer(state.transactions, action),
        callState: {
          ...state.callState,
          getTransactions: getCallStateReducer(
            action,
            state.callState.getTransactions
          ),
        },
      };
    }
  6. Display API States in Components
    Components can use NgRx selectors to observe changes in API state, enabling them to conditionally display loading spinners, error messages, or the fetched data based on the current state.

    // Usage in component template
    <ng-container *ngIf="(callState$ | async) as state">
      <p *ngIf="state.isLoading">Loading...</p>
      <p *ngIf="state.isError">Error: {{ state.error }}</p>
      <div *ngIf="state.isDone">Data Loaded</div>
    </ng-container>

Benefits of This Approach

  • Consistent API State Management: Simplifies understanding and debugging across the app.
  • Granular Control: Components can react independently, improving user experience.
  • Centralized Error Handling: Errors are handled in a standardized manner.
  • Testability: Actions, reducers, and selectors can be tested easily.

Conclusion

By using this structured approach with NgRx, we achieved a reusable, consistent way to handle API state. This solution keeps components focused and simplifies state management for future API calls, making the application more scalable and maintainable. Though this pattern adds some boilerplate, it creates a reliable, clean system for managing API states across Angular applications.

Final Thoughts: A standardized API state pattern within NgRx improves the codebase’s consistency and maintainability, with a trade-off of some initial setup.


This approach not only improves consistency and readability but also ensures that new API calls can be integrated easily, keeping the application scalable and developer-friendly.


Roman Khrystynych

Written by Roman Khrystynych who lives and works in Toronto building interesting things.