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

Existing member? Log in and continue learning

See if you like it, start the course for free!

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.
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).