State management in front-end application

You can find plenty of examples showing how state management libraries work, few people focus on it why do we need state management and what problems does it cause (solve?). In this article I will walk you through, step by step, what common application state issues might look like applications and how to deal with them regardless of whether you use because it doesn't matter what you use, only for what purpose.

December 5, 2023 angularangular-17typescript

You can view the source code for this project by following this link: GitHub

Loading state

Imagine a situation where you have to send a request to a server to get data. This operation can take some time so it can be a good idea to display a spinner with some text to inform a user that something is happening (instead of displaying a blank page). To do this we can create a variable loading and write in our template logic to display one of two views: loading view or data view.

export class ExampleComponent {
  private toDos$ = inject(TodoService).getToDos({ limit: 3 });

  loading = true;
  toDos: ToDo[] = [];

  constructor() {
    this.toDos$.subscribe((toDos) => {
      this.toDos = toDos;
      this.loading = false;
    });
  }
}
if (loading) {
  <p>Spinner</p>;
} else {
  <table> ... </table>;
}

More states

In a real scenario, we have more than just two states. When we display some data we want to allow users to filter, sort or paginate it. In this case, it would be nice to display some kind of spinner when data are updating. But what about errors? A server can also respond with a temporary issue (no connection to the database, internal server error, or timeout). It also would be nice to display.

Similar to before, we can do this by adding new variables to our view: error and refreshing. But this does not cover all needs. We also want to store data about the error and also items to display. This makes our component bigger and less readable.

export class DataToDisplay {
  private toDos$ = inject(TodoService).getToDos({ limit: 3 });

  loading = true;
  refreshing = false;
  error = false;
  toDos: ToDo[] = [];
  errorData: Error | null = null;

  constructor() {
    this.loadToDos();
  }

  refresh() {
    this.refreshing = true;
    this.loadToDos();
  }

  private loadToDos() {
    this.toDos$.subscribe({
      next: (toDos) => {
        this.loading = false;
        this.refreshing = false;
        this.error = false;
        this.toDos = toDos;
      },
      error: (error) => {
        this.error = true;
        this.loading = false;
        this.refreshing = false;
        this.errorData = error;
      },
    });
  }
}
if (loading) {
  <p>Spinner</p>;
} else if (error) {
  <p>Something bad happened</p>;
} else {
  <table>...</table>;
  if (refreshing) {
    <p>Inner spinner</p>;
  }
}

State object

To resolve described problems with too many variables we can create something that is called state object. This pattern is about creating a plan JavaScript object and storing all data needed to display the view in a single object.

export interface ComponentState {
  readonly loading: boolean;
  readonly refreshing: boolean;
  readonly toDos: Item[];
  readonly error: boolean;
  readonly errorData: Error | null;
}

const INITIAL_STATE: ComponentState = {
  loading: true,
  refreshing: false,
  toDos: [],
  error: false,
  errorData: null,
} as const;

readonly is necessary to block possibility to mutate a state.

Now we can come back to our component and replace all properties with single-state objects. Thanks to that our component is more readable.

export class DataToDisplay {
  private toDos$ = inject(TodoService).getToDos({ limit: 3 });

  state = getInitialState();

  constructor() {
    this.loadToDos();
  }

  refresh() {
    this.state = {
      loading: false,
      refreshing: true,
      error: false,
      errorData: null,
      toDos: this.state.toDos,
    };
    this.loadToDos();
  }

  private loadToDos() {
    this.toDos$.subscribe({
      next: (toDos) => {
        this.state = {
          loading: false,
          refreshing: false,
          error: false,
          errorData: null,
          toDos: toDos,
        };
      },
      error: (error) => {
        this.state = {
          loading: false,
          refreshing: false,
          error: true,
          errorData: error,
          toDos: [],
        };
      },
    });
  }
}

Problem with types

Imagine a situation where we want to create an effect to log an error message. When an error occurs we want to get the errorData property.

export function errorCountEffect(state: ComponentState): void {
  if (state.error === true) {
    const type = state.errorData?.message; // I had to add `?` because `errorData` can be null
    // ...
  }
}

We should not have a situation where error is set as true but errorData is null.

const type = state.errorData!.message;

TypeScript will throw an error errorData is possibly null only when you have strictNullChecks or strict flag turned on.

Union states

In a situation where we know that error and errorData properties are connected (when error is false then errorData is null and when error is true then errorData is defined) we can create interfaces for each state and connect them with Union types.

export interface NoErrorState {
  loading: boolean;
  refreshing: boolean;
  toDos: ToDo[];
  error: false;
  errorData: null;
}

export interface ErrorState {
  loading: boolean;
  refreshing: boolean;
  toDos: ToDo[];
  error: true;
  errorData: Error;
}

export type ComponentState = NoErrorState | ErrorState;

Thanks to that separation, we tell TypeScript that ComponentState is one of two states: NoErrorState or ErrorState and TypeScript will know when error is true then errorData is defined.

export function errorCountEffect(state: ComponentState): void {
  if (state.error === true) {
    const type = state.errorData.message; // There is no error now! No need to add `?`.

    // ...
  }
}

TypeScript knows that when error is true then state inside if block has type ErrorState (TS Playground).

Split into more states

When we know that we can create many states and connect them into a single type we can define all possible states with interfaces.

interface LoadingState {
  loading: true;
}

interface LoadedState {
  toDos: ToDo[];
}

interface RefreshingState {
  refreshing: true;
  toDos: ToDo[];
}

interface ErrorState {
  error: true;
  errorData: Error;
}

export type ComponentState = LoadingState | LoadedState | RefreshingState | ErrorState;

Checking current state

Let us go back to our effect where we want to check current state. To do this we have to check which properties are defined and which are not.

export function errorCountEffect(state: ComponentState): void {
  if ("error" in state) {
    const message = state.errorData.message;
    // ...
  }
}

This if statement is not readable and each time we have to know what to check.

Keyword in is used as a type guard. Because only LoadedState has data property TypeScript knows that inside if variable currentState have to be LoadedState (TS Playground).

Named states

To make easier checking current state we can create a special property which will hold current state name.

export interface LoadingState {
  name: "LOADING";
}

export interface LoadedState {
  name: "LOADED";
  toDos: ToDo[];
}

export interface RefreshingState {
  name: "REFRESHING";
  toDos: ToDo[];
}

export interface ErrorState {
  name: "ERROR";
  error: Error;
}

export type ComponentState = LoadingState | LoadedState | RefreshingState | ErrorState;

We can create a interface to share base properties. This is nice to have but not required.

interface State<T> {
  name: T;
}

export interface LoadedState extends State<"LOADED"> {
  toDos: ToDo[];
}

Now we can refactor the previous function and use simple checking of name property (TS Playground).

export function errorCountEffect(state: ComponentState): void {
  if (state.name === "ERROR") {
    const message = state.errorData.message;
    // ...
  }
}

Comparing writing templates

Let us come back to our templates from the beginning. When we had individual properties for each state our template looks like below.

if (loading) {
  <p>Spinner</p>;
} else if (error) {
  <p>Something bad happened</p>;
} else {
  <table>...</table>;
  if (refreshing) {
    <p>Inner spinner</p>;
  }
}

But when we migrated to the named states the template has to change. We cannot look now for individual properties. We have to now write a template where we will check a current state and for each state we need to have a template.

switch (state.name) {
  case "LOADING":
    <p>Spinner</p>;
    break;
  case "LOADED":
    <table> ... </table>;
    break;
  case "REFRESHING":
    <table> ... </table>;
    <p>Inner spinner</p>;
    break;
  default:
    <p>Unknown state</p>;
    break;
}

We can also handle many states at single case. It approach will be helpful where two states have a very similar look.

switch (state.name) {
  case "LOADING":
    <p>Spinner</p>;
    break;
  case "LOADED":
  case "REFRESHING":
    <table> ... </table>;
    if (state.state === "REFRESHING") {
      <p>Inner spinner</p>;
    }
    break;
  default:
    <p>Unknown state</p>;
    break;
}

Comparing changing state

When we take a loot into the code from the beginning we can see that we had to “reset” each property one by one. It it very easy to make a mistake and forget to change a variable to original value.

let loading = true;
let refreshing = false;
let error = false;
let toDos: ToDo[] = [];
let errorData: Error | null = null;

this.toDos$.subscribe({
  next: (toDos) => {
    this.loading = false;
    this.refreshing = false;
    this.error = false;
    this.toDos = toDos;
  },
  error: (error) => {
    this.error = true;
    this.loading = false;
    this.refreshing = false;
    this.errorData = error;
  },
});

In situation where we will use named states the code is much more shorter and most important more readable.

let state: ComponentState = { name: "LOADING" };

this.toDos$.subscribe({
  next: (toDos) => {
    state = { name: "LOADED", data: toDos };
  },
  error: (error) => {
    state = { name: "ERROR", error: error };
  },
});

Final version

Do you like the content?

Your support helps me continue my work. Please consider making a donation.

Donations are accepted through PayPal or Stripe. You do not need a account to donate. All major credit cards are accepted.

Leave a comment