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.
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.localStorageSo… 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.
this.checklistsLoaded$.pipe(takeUntilDestroyed()).subscribe({ next: (checklists) => this.state.update((state) => ({ ...state, checklists, loaded: true, })), error: (err) => this.state.update((state) => ({ ...state, error: err })), });We are using a different signature for subscribe here. By default, if we
supply a function directly to subscribe it is going to use that function to
handle next values.
However, we can also do what we are doing above and supply separate functions
for next and error. This is how we handle setting any errors into our state
signal (but, as I mentioned, we won’t really need to deal with that for our
application — this is just to show you how you could handle errors).
We are now also handling setting the loaded state to true once our
checklistsLoaded$ emits.
Now we just need to set up the exact same thing in our ChecklistItemService.
I’ll add the steps below, but see if you can set up loading in the checklist
items before continuing.
Click here to reveal solution
Solution
private checklistItemsLoaded$ = this.storageService.loadChecklistItems();export interface ChecklistItemsState { checklistItems: ChecklistItem[]; loaded: boolean;} private state = signal<ChecklistItemsState>({ checklistItems: [], loaded: false, }); this.checklistItemsLoaded$ .pipe(takeUntilDestroyed()) .subscribe((checklistItems) => this.state.update((state) => ({ ...state, checklistItems, loaded: true, })) );Saving Checklists and Checklist Items
We have a way to load our data, now we need to make sure we save our data any
time our checklists or checklist items change. At the moment that would
just be when they are created or when the checked state of a checklist
changes. But, in the future, we will also be able to edit and delete our data as
well.
It would be annoying to have to manually save data every time we ran any method that changed some data, but fortunately using signals provides us with a very easy way to handle this.
loaded = computed(() => this.state().loaded); // effects effect(() => { if (this.loaded()) { this.storageService.saveChecklists(this.checklists()); } });Since we are referencing the value of our checklists signal here, this effect
will run every time our checklists state changes. However, this will only
occur when our loaded state is true — this means we are not going to trigger
any saves if the existing data has not loaded yet. This shouldn’t really be
possible anyway, but it is a nice precaution to prevent us from overwriting our
saved data with empty arrays.
Let’s do the same for the ChecklistItemService.
loaded = computed(() => this.state().loaded); // effects effect(() => { if (this.loaded()) { this.storageService.saveChecklistItems(this.checklistItems()); } });If we test our application now, we should see that data persists after refreshing!