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 going to make use of the Local Storage API. This 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
.
Create a file at
src/app/shared/data-access/storage.service.ts
and add the following:
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.
Implement the rest of the
StorageService
:
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));
}
}