Skip to content
View lessons Login

Launch sale! Get 30% OFF expires in 126:46:22

3d cartoon hands holding a phone

Unlock full course by purchasing a membership

Persisting Data in Local Storage

Persisting Data in Local Storage

We have our core functionality working now, but as soon as we refresh the application we lose all of our data. This lesson is going to be about making sure that data sticks around.

There are different ways you can go about persisting data and state. In some cases, your application might use an external database (especially if this is an application where data is shared by users like a chat application or social network). However, in this case, the data is going to be stored completely locally on the device itself.

To achieve this, we are eventually going to use Ionic’s Storage API, but first we are going to use just the vanilla Local Storage API that is available in the browser. This will give us a chance to investigate using our own injection tokens in more detail, and then we will see the ease and benefits of using the ready-made solution that Ionic provides for us.

The local storage API is a simple type of key/value storage that is available in the browser by default. It is by no means the best place to store data — storage space is limited and there is the potential for the stored data to be lost. For important data that you don’t want disappearing on you, I would definitely recommend against only storing data in local storage. In later application walkthroughs we look at using an actual remote backend for data storage, but for now local storage will suit our purposes fine.

Creating a Storage Service

We are going to create a service to handle storage for us, but before we implement the storage mechanism itself we are going to discuss the concept of creating our own InjectionToken.

import { Injectable, InjectionToken, PLATFORM_ID, inject } from '@angular/core';
export const LOCAL_STORAGE = new InjectionToken<Storage>(
'window local storage object',
{
providedIn: 'root',
factory: () => {
return inject(PLATFORM_ID) === 'browser'
? window.localStorage
: ({} as Storage);
},
}
);
@Injectable({
providedIn: 'root',
})
export class StorageService {
storage = inject(LOCAL_STORAGE);
}

This is another one of those things where it looks like we are just complicating things. In order to interact with local storage, we want to use:

window.localStorage

So… why all this other junk? Technically we don’t need this for our purposes, but we are creating a safer design for our Angular application.

We are directly accessing a browser API here by using window — but an Angular application does not necessarily always run in the context of a browser, if you were using SSR (server side rendering) for example your Angular application would not have access to the window object.

If you know your application will only ever run in the browser, and you want to just ignore all this stuff, you can do that if you like — you can just use window.localStorage directly. But this is still a useful technique in general.

We might want to create a custom InjectionToken when we want something to change based on some kind of condition. For example, in this case we are changing what our injected LOCAL_STORAGE is (depending on the browser environment). In another circumstance, we might want to change what an injected dependency is based on whether the application is running a development or production version.

The basic idea is that we create an InjectionToken like this:

export const LOCAL_STORAGE = new InjectionToken<Storage>(
'window local storage object',
{
providedIn: 'root',
factory: () => {
return inject(PLATFORM_ID) === 'browser'
? window.localStorage
: ({} as Storage);
},
}
);

Just like with a normal service, we can either provide it in root or we can manually provide it to wherever we want to use it.

Then we have a factory function that determines what will actually be injected when we run inject(LOCAL_STORAGE). In this case, we check if we are running in the browser — in which case we will use window.localStorage. Otherwise, we can supply our alternate storage mechanism. We are not actually using an alternate storage mechanism here, we are just providing a fake object that will satisfy the Storage type.

Before we move on, here is another injection token from a later application build:

export const AUTH = new InjectionToken('Firebase auth', {
providedIn: 'root',
factory: () => {
const auth = getAuth();
if (environment.useEmulators) {
connectAuthEmulator(auth, 'http://localhost:9099', {
disableWarnings: true,
});
}
return auth;
},
});

This one deals with setting up authentication for Firebase. If a development version of the application is running, it will connect to the local emulator. If a production version of the application is running, it will connect to the actual Firebase service.

This is an easily overlooked but very powerful tool.

With our injection token in place, we can implement the rest of our service.

import { Injectable, InjectionToken, PLATFORM_ID, inject } from '@angular/core';
import { of } from 'rxjs';
import { Checklist } from '../interfaces/checklist';
import { ChecklistItem } from '../interfaces/checklist-item';
export const LOCAL_STORAGE = new InjectionToken<Storage>(
'window local storage object',
{
providedIn: 'root',
factory: () => {
return inject(PLATFORM_ID) === 'browser'
? window.localStorage
: ({} as Storage);
},
}
);
@Injectable({
providedIn: 'root',
})
export class StorageService {
storage = inject(LOCAL_STORAGE);
loadChecklists() {
const checklists = this.storage.getItem('checklists');
return of(checklists ? (JSON.parse(checklists) as Checklist[]) : []);
}
loadChecklistItems() {
const checklistsItems = this.storage.getItem('checklistItems');
return of(
checklistsItems ? (JSON.parse(checklistsItems) as ChecklistItem[]) : []
);
}
saveChecklists(checklists: Checklist[]) {
this.storage.setItem('checklists', JSON.stringify(checklists));
}
saveChecklistItems(checklistItems: ChecklistItem[]) {
this.storage.setItem('checklistItems', JSON.stringify(checklistItems));
}
}

Most of this is reasonably unsurprising — to save data we call setItem and save our object as a JSON string, and to load data we call getItem and parse that JSON string back into an object. If the data does not exist in storage, we return an empty array instead.

However, in our load methods we are actually converting the data into an observable stream that emits that data, rather that just returning the data directly.

This is because these load methods will become a source in our services. Just like with every other source, we want to be able to wait for it to emit and then react to it in our reducer step. We don’t want to have to manually/imperatively call these load methods and add them to our state — we want it to just happen automatically.

Let’s take a look at how we can make that happen.

Loading Checklists and Checklist Items

Now that we have methods to load and save data, we need to make use of it in our application.

private checklistsLoaded$ = this.storageService.loadChecklists();
this.checklistsLoaded$.pipe(takeUntilDestroyed()).subscribe((checklists) =>
this.state.update((state) => ({
...state,
checklists,
}))
);

That’s all there is to it. Our checklistsLoaded$ source will emit with the loaded data, and then we set that data in the state signal.

We are actually going to do a little more here. There is some additional state we might be interested in. For example, we might want to know whether the data has been loaded yet or not. We might also want to know if the data failed to load for some reason — this won’t be an issue in our case, but if we were loading data from some API it is reasonable to expect it might fail sometimes.

Let’s add some extra state to handle this.

export interface ChecklistsState {
checklists: Checklist[];
loaded: boolean;
error: string | null;
}
// state
private state = signal<ChecklistsState>({
checklists: [],
loaded: false,
error: null,
});

Now we can update the reducer we just added to also handle setting this new state.

STANDARD STANDARD

Thanks for checking out the preview of this lesson!

You do not have the appropriate membership to view the full lesson. If you would like full access to this module you can view membership options (or log in if you are already have an appropriate membership).