Component State and Service State
These demonstrations are to show "lifting" state, from state inside of a component, to state within a provided service, to persisting the state so that it survives reloads.
Getting the Sample Code
You can download the sample code for this by finding yourself a nice clean directory on your machine and running
npx gitpick JeffryGonzalez/site-applied-angular/tree/main/projects/angular-reference/state
cd state
npm i && code .Video Walkthrough of Demo
Ephemeral Component State
This is state (signals) that live within the component. You'll notice that every time you visit the component, the data is reloaded, and your sorting preferences are reset.
The following code is the first demo, it lives at src/ephemeral-user/sort-filter-one.ts in the project:
import { CurrencyPipe, TitleCasePipe } from '@angular/common';
import {
Component,
ChangeDetectionStrategy,
resource,
signal,
computed,
} from '@angular/core';
type ApiProducts = [{ id: number; name: string; price: number }];
@Component({
selector: 'app-sort-filter-one',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe, TitleCasePipe],
template: `
<div class="prose prose-lg">
<h2>Showing Sorting and Filtering With Component State</h2>
<p>
All of the state here is from an HTTP request to a (mocked)
<code>/api/products</code> resource.
</p>
@if (products.isLoading()) {
<p>Loading...</p>
} @else {
<table class="table table-auto table-zebra">
<thead>
<td>Id</td>
<td>
<button (click)="setSortBy('name')" class="link">
Name
@if (sortby() === 'name') {
<span class="text-accent">
@if (orderBy() === 'asc') {
↑
} @else {
↓
}
</span>
}
</button>
</td>
<td>
<button (click)="setSortBy('price')" class="link">
Price
@if (sortby() === 'price') {
<span class="text-accent">
@if (orderBy() === 'asc') {
↑
} @else {
↓
}
</span>
}
</button>
</td>
</thead>
<tbody>
@for (product of productsSorted(); track product.id) {
<tr>
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ product.price | currency }}</td>
</tr>
}
</tbody>
</table>
<div>
<p>
Sorting by: {{ sortby() | titlecase }}
@if (orderBy() === 'asc') {
<span>In Ascending order.</span>
} @else {
<span>In Descending order.</span>
}
</p>
</div>
}
</div>
`,
styles: ``,
})
export class SortFilterOneComponent {
sortby = signal<'name' | 'price'>('name');
orderBy = signal<'asc' | 'desc'>('asc');
products = resource<ApiProducts, unknown>({
loader: () => fetch('https://some-api/products').then((res) => res.json()),
});
setSortBy(sortBy: 'name' | 'price') {
this.sortby.set(sortBy);
if (this.orderBy() === 'asc') {
this.orderBy.set('desc');
} else {
this.orderBy.set('asc');
}
}
productsSorted = computed(() => {
const sortBy = this.sortby();
const orderBy = this.orderBy();
const products = this.products.value() || [];
return products.sort((a, b) => {
if (sortBy === 'name') {
return orderBy === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
} else {
return orderBy === 'asc' ? a.price - b.price : b.price - a.price;
}
});
});
}You can see in the above sample code that there are a couple of signals (sortBy and orderBy). I'm using an Angular resource to load some data.
There is a computed called productsSorted that uses the signals to produce the sorted output for the template.
Lifting State to a Service
In this example, we lift the signals for sorting to a service (@NGRX/signal-store) so that the values are retained while the application is running.
The service lives at src/ephemeral-user/sort-filter-store.ts:
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
type SortByOptions = 'name' | 'price';
type OrderByOptions = 'asc' | 'desc';
type SortFilterState = {
sortBy: SortByOptions;
orderBy: OrderByOptions;
};
export const SortFilterStore = signalStore(
withState<SortFilterState>({
sortBy: 'name',
orderBy: 'asc',
}),
withMethods((state) => ({
setSortBy: (sortBy: SortByOptions) => {
patchState(state, { sortBy });
if (state.orderBy() === 'asc') {
patchState(state, { orderBy: 'desc' });
} else {
patchState(state, { orderBy: 'asc' });
}
},
setOrderBy: (orderBy: OrderByOptions) => patchState(state, { orderBy }),
})),
);It is provided in the routes at src/ephemeral-user/routes.ts:
import { Routes } from '@angular/router';
import { EphemeralUserComponent } from './ephemeral-user';
import { SortFilterOneComponent } from './sort-filter-one';
import { SortFilterTwoComponent } from './sort-filter-two';
import { SortFilterStore } from './sort-filter-store';
import { SortFilterThreeComponent } from './sort-filter-three';
import { SortFilterStoreTwo } from './sort-filter-store-two';
export const EPHEMERAL_USER_ROUTES: Routes = [
{
path: '',
providers: [SortFilterStore, SortFilterStoreTwo],
component: EphemeralUserComponent,
children: [
{
path: 'sort-filter-one',
component: SortFilterOneComponent,
},
{
path: 'sort-filter-two',
component: SortFilterTwoComponent,
},
{
path: 'sort-filter-three',
component: SortFilterThreeComponent,
},
],
},
];And the component for this (at /src/ephemeral-user/sort-filter-two.ts):
import { CurrencyPipe, TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
resource,
} from '@angular/core';
import { SortFilterStore } from './sort-filter-store';
type ApiProducts = [{ id: number; name: string; price: number }];
@Component({
selector: 'app-sort-filter-two',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe, TitleCasePipe],
template: `
<div class="prose prose-lg">
<h2>Showing Sorting and Filtering Service State</h2>
<p>
All of the state here is from an HTTP request to a (mocked)
<code>/api/products</code> resource.
</p>
<p>The state is stored in a service (<code>SortFilterStore</code>)</p>
</div>
@if (products.isLoading()) {
<p>Loading...</p>
} @else {
<table class="table table-auto table-zebra">
<thead>
<td>Id</td>
<td>
<button (click)="store.setSortBy('name')" class="link">
Name
@if (store.sortBy() === 'name') {
<span class="text-accent">
@if (store.orderBy() === 'asc') {
↑
} @else {
↓
}
</span>
}
</button>
</td>
<td>
<button (click)="store.setSortBy('price')" class="link">
Price
@if (store.sortBy() === 'price') {
<span class="text-accent">
@if (store.orderBy() === 'asc') {
↑
} @else {
↓
}
</span>
}
</button>
</td>
</thead>
<tbody>
@for (product of productsSorted(); track product.id) {
<tr>
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ product.price | currency }}</td>
</tr>
}
</tbody>
</table>
<div>
<p>
Sorting by: {{ store.sortBy() | titlecase }}
@if (store.orderBy() === 'asc') {
<span>In Ascending order.</span>
} @else {
<span>In Descending order.</span>
}
</p>
</div>
}
`,
styles: ``,
})
export class SortFilterTwoComponent {
store = inject(SortFilterStore);
products = resource<ApiProducts, unknown>({
loader: () => fetch('https://some-api/products').then((res) => res.json()),
});
productsSorted = computed(() => {
const sortBy = this.store.sortBy();
const orderBy = this.store.orderBy();
const products = this.products.value() || [];
return products.sort((a, b) => {
if (sortBy === 'name') {
return orderBy === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
} else {
return orderBy === 'asc' ? a.price - b.price : b.price - a.price;
}
});
});
}You can see the signals for sortBy and orderBy are lifted to the service. Since this component owns the products resource, I left the computed for productsSorted in the component itself.
That means that each time this component is viewed (either loaded, or navigated to during the life of the instance of the application) it will reload the products from the api.
Persisting State
The final demo is persisting the state in the browsers localStorage.
The only real change is in the service itself, which is at /src/ephemeral-user/sort-filter-three.ts. Notice the withHooks addition to the service.
import {
patchState,
signalStore,
watchState,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
type SortByOptions = 'name' | 'price';
type OrderByOptions = 'asc' | 'desc';
type SortFilterState = {
sortBy: SortByOptions;
orderBy: OrderByOptions;
};
export const SortFilterStoreTwo = signalStore(
withState<SortFilterState>({
sortBy: 'name',
orderBy: 'asc',
}),
withMethods((state) => ({
setSortBy: (sortBy: SortByOptions) => {
patchState(state, { sortBy });
if (state.orderBy() === 'asc') {
patchState(state, { orderBy: 'desc' });
} else {
patchState(state, { orderBy: 'asc' });
}
},
setOrderBy: (orderBy: OrderByOptions) => patchState(state, { orderBy }),
})),
withHooks({
onInit: (state) => {
const savedJsonState = localStorage.getItem('sort-filter-state');
if (savedJsonState) {
const savedState = JSON.parse(savedJsonState);
patchState(state, {
sortBy: savedState.sortBy,
orderBy: savedState.orderBy,
});
}
watchState(state, (current) => {
localStorage.setItem('sort-filter-state', JSON.stringify(current));
});
},
}),
);The onInit hook:
- checks
localStoragefor a saved value, and if it exists, it patches the state with this value. - uses the
watchStatefunction that will be called each time that state changes, it uses this to save changes tolocalStorage