Angular Ngrx + Localstorage

This Angular 19 application includes:
- Stock management (products, quantities, etc.).
- A shopping cart system for adding/removing products.
- Persistent authentication (token/user saved in localStorage).
- Optimized usage of NgRx Store (global state) + ComponentStore (light local state).
???? Key Features
???? Authentication
- User login (stores
token
anduser
in localStorage). - Persistence via a custom MetaReducer:
-
auth
slice is restored on startup. - Auth data is automatically saved after each action.
-
????️ Stock Management
- Product list (with quantity, price, etc.).
- Add, edit, delete products.
- State managed with NgRx Store (
stock
slice).
???? Shopping Cart
- Add/remove products from the cart.
- Dynamic total calculation.
- Cart state handled with ComponentStore:
- Lighter and faster for UI interactions.
- Optional local persistence.
Prerequisites
- Node.js (which includes npm)
- Angular CLI:
npm install -g @angular/cli
Getting Started
-
Clone the repository (if applicable):
git clone https://github.com/cheikhbethio/ngrx-2025.git cd ngrx-2025
-
Install dependencies:
npm install
-
Run the development server:
ng serve -o
The application will be available at
http://localhost:4500/
.
Core Concepts
- Angular: The application is built using the Angular framework.
- NgRx: State management is handled using NgRx, following the Redux pattern (Actions, Reducers, Selectors, Store).
- State: Defined in
src/app/core/store/reducers/reducers.type.ts
. - Actions: Trigger state changes (e.g.,
src/app/core/store/actions/
). - Reducers: Handle state transitions based on actions.
- Selectors: Retrieve specific pieces of state for components.
- State: Defined in
- Services: Angular services (
src/app/core/services/
) encapsulate business logic like authentication.
Project Structure (Partial)
src/
├── app/
│ ├── core/
│ │ ├── services/ # Application-wide services (e.g., AuthService)
│ │ ├── store/ # NgRx state management files
│ │ │ ├── actions/
│ │ │ ├── reducers/
│ │ │ ├── selectors/
│ │ │ └── effects/ # (If using @ngrx/effects)
│ │ └── types/ # Core data types/interfaces
│ ├── features/ # (Likely location for feature modules/components)
│ │ ├── auth/
│ │ ├── products/
│ │ └── basket/
│ ├── shared/ # Shared components, directives, pipes
│ ├── app.component.ts
│ ├── app.config.ts # Main application configuration
│ └── app.routes.ts # Application routing
├── assets/ # Static assets (images, etc.)
└── environments/ # Environment configuration
Description
Together we go through all the code
Let's start with the main entry of the code
app.component
Okay, let's check the contents of src/app/app.component.ts
to see what it does.
Read file: src/app/app.component.ts
Based on the content of src/app/app.component.ts
, the AppComponent
does the following:
- Serves as the Root Component: It's the main component bootstrapped for the application (indicated by the
selector: 'app-root'
). - Provides the Main Template: It uses
app.component.html
as its template, which likely contains the main structure of your application's UI, including navigation and a<router-outlet>
where other components are displayed based on the current route. - Imports Necessary Modules/Components: It imports
RouterOutlet
,RouterLink
,RouterLinkActive
for routing, andCommonModule
for common Angular directives. - Injects Services and Store:
- It injects the NgRx
Store
to select data. - It injects the
AuthService
.
- It injects the NgRx
- Selects State: It selects the basket total (
total$
) and basket item count (count$
) from the NgRx store using selectors. These are observables that will likely be used in the template (app.component.html
) to display the current basket status. - Provides Logout Functionality: It has a
logout()
method that calls thelogout
method of the injectedAuthService
. This method is likely called from a logout button in the template.
import { Component, inject } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectBasketCount, selectBasketTotal } from './core/store/selectors/basket.selectors';
import { CommonModule } from '@angular/common';
import { AuthService } from './core/services/auth.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive, CommonModule],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'NGRX';
store = inject(Store);
total$ = this.store.select(selectBasketTotal);
count$ = this.store.select(selectBasketCount);
authService = inject(AuthService);
logout() {
this.authService.logout();
}
}
app.component.html
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" routerLink="/">{{ title }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" routerLink="/admin/stock" routerLinkActive="active" ariaCurrentWhenActive="page">Stocks</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="/admin/basket" routerLinkActive="active" ariaCurrentWhenActive="page">Basket</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container mt-4">
<div class="row">
@if(authService.isAuthenticated() | async) {
<button class="btn btn-danger col-1" (click)="logout()">Logout</button>
<div class="col-3 offset-9 section-height">
<h5 class="card-title">Basket</h5>
<span class="card-text">{{ count$ | async }} for {{ total$ | async }} €</span>
<button class="btn btn-primary " style="margin-left: 30px;" routerLink="/basket">Go</button>
</div>
}
</div>
<router-outlet></router-outlet>
</main>
Connection
We have to login.
Class HomeComponent
:
* Dependency Injection:
* authService = inject(AuthService);
: Injects an instance of the AuthService
.
* router = inject(Router);
: Injects an instance of the Router
.
* Login Form Definition:
* loginForm = new FormGroup(...)
: Creates a new reactive form group named loginForm
.
* It has two FormControl
instances:
* username: new FormControl('')
: For the username input, initialized as an empty string.
* password: new FormControl('')
: For the password input, initialized as an empty string.
This form will be bound to input fields in home.component.html
.
* login()
Method:
* This method is likely called when the user submits the login form.
* console.log(this.loginForm.value);
: Logs the current values from the form (username and password).
* this.authService.login(this.loginForm.value as UserAuth).subscribe(...)
:
* Calls the login
method of the AuthService
, passing the form values (cast to the UserAuth
type).
* Subscribes to the Observable
returned by authService.login()
to handle the outcome:
* next: (token) => {...}
: If the login is successful (the observable emits a true
value, which is named token
here, though it's actually a boolean from AuthService
), it logs a success message and then navigates the user to the /admin/stock
route using this.router.navigate(['/admin/stock']);
.
* error: (error) => {...}
: If an error occurs during the login attempt (e.g., the observable errors out, or if AuthService.login
was designed to throw an error on failure, though currently it returns of(false)
), it logs an error message.
In essence, the HomeComponent
provides a user interface (defined in home.component.html
) for users to enter their username and password, and then uses the AuthService
to attempt to log them in. If successful, it redirects them to an admin section.
import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { AuthService } from '../core/services/auth.service';
import { UserAuth } from '../core/types';
import { Router } from '@angular/router';
@Component({
selector: 'app-home',
imports: [ReactiveFormsModule],
templateUrl: './home.component.html',
styles: ``
})
export class HomeComponent {
authService = inject(AuthService);
router = inject(Router);
loginForm = new FormGroup({
username: new FormControl(''),
password: new FormControl(''),
});
login() {
console.log(this.loginForm.value);
this.authService.login(this.loginForm.value as UserAuth).subscribe({
next: (token) => {
console.log('Login successful, redirecting to admin/stock', token);
this.router.navigate(['/admin/stock']);
},
error: (error) => {
console.error('Login failed', error);
}
});
}
}
Storage in local
With NGRX, you can store any kind of data in the browser.
The problem is that when the page is refreshed, some data might be lost — and that’s something you don’t want to happen, especially when it comes to login credentials or the token used to determine whether the user is connected or not.
We could store the data directly in localStorage, but the problem with that is we’d end up with two sources of truth: one from localStorage and another from NGRX, which can lead to confusion.
To avoid this, we’ll store everything in NGRX and only persist a portion of it to localStorage.
When accessing data, we’ll always go through NGRX, so we maintain a single source of truth.
Let's first put the part that concerns us into localStorage.
To do that, we first store it in NGRX.
auth.reducer
-
initialAuthState
:-
export const initialAuthState: AuthState = { user: null, isAuthenticated: false, };
- This defines the starting state for the authentication part of your application. When the application first loads, the
user
will benull
, andisAuthenticated
will befalse
.
-
-
authReducer
:-
export const authReducer = createReducer(...)
: This is the main reducer function for authentication. - It's created using
createReducer
, which takes theinitialAuthState
as its first argument. - The subsequent arguments are
on(...)
functions, which define how the state should change in response to specific actions:-
on(AuthActions.login, (state, { user }) => ...)
:- This handles the
AuthActions.login
action. - When a
login
action is dispatched (carrying auser
payload), this function is executed. - It uses
produce(state, (draft: AuthState) => { ... })
from Immer. - Inside the Immer
produce
function:-
draft.user = user;
: Theuser
property of the draft state is set to theuser
payload from the action. -
draft.isAuthenticated = true;
: TheisAuthenticated
property is set totrue
.
-
- Immer ensures that a new, immutable state object is returned with these changes.
- This handles the
-
on(AuthActions.logout, (state) => ...)
:- This handles the
AuthActions.logout
action. - When a
logout
action is dispatched: - It uses
produce(state, (draft: AuthState) => { ... })
. - Inside the Immer
produce
function:-
draft.user = null;
: Theuser
property is set back tonull
. -
draft.isAuthenticated = false;
: TheisAuthenticated
property is set back tofalse
.
-
- This handles the
-
-
export const initialAuthState: AuthState = {
user: null,
isAuthenticated: false,
};
export const authReducer = createReducer(
initialAuthState,
on(AuthActions.login, (state, { user }) => produce(state, (draft: AuthState) => {
draft.user = user;
draft.isAuthenticated = true;
})),
on(AuthActions.logout, (state) => produce(state, (draft: AuthState) => {
draft.user = null;
draft.isAuthenticated = false;
}))
);
let make selector
export const selectAuthFeatureState = createFeatureSelector<AppState, AuthState>('authState');
export const selectAuthState = createSelector(
selectAuthFeatureState,
(state: AuthState) => state
);
export const selectIsAuthenticated = createSelector(
selectAuthState,
(state: AuthState) => state.isAuthenticated
);
export const selectUserCredentials = createSelector(
selectAuthState,
(state: AuthState) => state.user
);
And finally the actions for authentication trigger
export const AuthActions = createActionGroup({
source: 'Auth',
events: {
login: props<{ user: UserAuth | null }>(),
logout: emptyProps,
},
});
Remember, we have a more global store, and authentication is just one part of it
export * from './product.reducer';
export * from './basket.reducer';
export * from './reducers.type';
export * from './localstorage-custom.reducers';
export const reducers: ActionReducerMap<AppState> = {
productsState: productsReducer,
basketState: basketReducer,
authState: authReducer, // authentication state to store also in local storage for persistance after refresh
};
Now, we’ll take that same part — and only that part — and store it in localStorage.
To do this, we need to create a file that reads the data from localStorage and loads it into NGRX, and that also does the reverse each time the store is updated.
file: localstorage-custom.reducer.ts
This specific localstorageCustomReducer
is designed to synchronize a part of your NgRx store state with the browser's localStorage
. This is a common pattern for persisting certain state, like authentication status, so that it can be restored when the user revisits the application.
Here's a breakdown of its logic:
-
localstorageCustomReducer
Function:- It takes one argument:
reducer: ActionReducer<any>
, which is the next reducer in the chain (this could be your combined root reducer or another meta-reducer). -
let isBrowser = typeof localStorage !== 'undefined';
: Checks if the code is running in a browser environment wherelocalStorage
is available. This is important because Angular applications can also be rendered on the server (Server-Side Rendering - SSR), wherelocalStorage
wouldn't exist.
- It takes one argument:
-
Returned Reducer Function
(state: AppState, action: Action) => {...}
:- This is the actual meta-reducer logic that will be executed for every dispatched action.
- State Hydration (Loading from
localStorage
):-
if(isBrowser && (action.type === INIT || action.type === UPDATE))
: This condition checks if it's running in a browser AND if the action is the initialINIT
action (when the store is first set up) or anUPDATE
action. -
const storageValue = localStorage.getItem(localStorageKey);
: Tries to retrieve data fromlocalStorage
using thelocalStorageKey
(i.e.,'auth-ngrx'
). -
if(storageValue)
: If data is found inlocalStorage
:-
try ... catch
: It attempts toJSON.parse()
the stored string back into an object. -
return { ...state, [localStorageKey]: parsedAuth };
: If parsing is successful, it returns a new state object. This new state is a merge of the currentstate
and theparsedAuth
data loaded fromlocalStorage
. Critically, it seems to be trying to set the entireparsedAuth
object directly onto a property named'auth-ngrx'
within theAppState
. This might be slightly different from how theauthState
is structured withinAppState
(which isstate.authState
). This is a potential point of interest/bug, aslocalStorageKey
is 'auth-ngrx', but your state structure isAppState.authState
. It should probably be merging intostate.authState
. - If parsing fails (e.g., the data in
localStorage
is corrupted), it logs an error and removes the invalid item fromlocalStorage
.
-
-
- Processing by Next Reducer:
-
const newState = reducer(state, action);
: The original action is passed to thereducer
that was provided to the meta-reducer (your normal application reducers). This computes the "next" state based on the action, after potentially having loaded fromlocalStorage
.
-
- State Persistence (Saving to
localStorage
):-
if(isBrowser)
: Checks if running in a browser. -
localStorage.setItem(localStorageKey, JSON.stringify(newState.authState));
: After the regular reducers have processed the action and producednewState
, this line takes theauthState
slice from thenewState
and saves it tolocalStorage
byJSON.stringify
-ing it.
-
-
return newState;
: The meta-reducer returns thenewState
.
In Summary:
- On Initialization (
INIT
action): The meta-reducer attempts to load the authentication state (or whatever is stored under thelocalStorageKey
) fromlocalStorage
and merge it into the initial application state. - On Every Action:
- First, it lets your regular reducers (passed into it) calculate the new state based on the dispatched action.
- Then, it takes the
authState
slice from this newly calculated state and saves it tolocalStorage
.
Potential Issue to Note:
As highlighted, there's a slight mismatch in how the state is being read from and written to localStorage
in relation to the AppState
structure.
- Reading:
return { ...state, [localStorageKey]: parsedAuth };
wherelocalStorageKey
is'auth-ngrx'
. This would createstate['auth-ngrx']
. - Writing:
localStorage.setItem(localStorageKey, JSON.stringify(newState.authState));
This correctly saves theauthState
slice.
export const localstorageCustomReducer = (reducer: ActionReducer<any>): ActionReducer<any> => {
let isBrowser = typeof localStorage !== 'undefined';
return (state: AppState, action: Action) => {
if(isBrowser && (action.type === INIT || action.type === UPDATE)) {
const storageValue = localStorage.getItem(localStorageKey);
if(storageValue) {
try {
const parsedAuth = JSON.parse(storageValue);
return {
...state,[localStorageKey]: parsedAuth
};
} catch (error) {
console.error('Error parsing local storage value', error);
localStorage.removeItem(localStorageKey);
}
}
}
const newState = reducer(state, action);
if(isBrowser) {
localStorage.setItem(localStorageKey, JSON.stringify(newState.authState));
}
return newState;
}
}
This meta-reducer is a powerful tool for making parts of your NgRx state persistent across browser sessions.
in app.config
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideClientHydration(withEventReplay()),
// Linking the local storage custom reducer to the store
provideStore(reducers, { metaReducers: [localstorageCustomReducer] }),
provideEffects(productEffects),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() })
]
};