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 }; },});