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
tokenanduserin localStorage). - Persistence via a custom MetaReducer:
-
authslice 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 (
stockslice).
π 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 -oThe 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.htmlas 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,RouterLinkActivefor routing, andCommonModulefor common Angular directives. - Injects Services and Store:
- It injects the NgRx
Storeto 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 thelogoutmethod 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
userwill benull, andisAuthenticatedwill befalse.
-
-
authReducer:-
export const authReducer = createReducer(...): This is the main reducer function for authentication. - It's created using
createReducer, which takes theinitialAuthStateas 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.loginaction. - When a
loginaction is dispatched (carrying auserpayload), this function is executed. - It uses
produce(state, (draft: AuthState) => { ... })from Immer. - Inside the Immer
producefunction:-
draft.user = user;: Theuserproperty of the draft state is set to theuserpayload from the action. -
draft.isAuthenticated = true;: TheisAuthenticatedproperty 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.logoutaction. - When a
logoutaction is dispatched: - It uses
produce(state, (draft: AuthState) => { ... }). - Inside the Immer
producefunction:-
draft.user = null;: Theuserproperty is set back tonull. -
draft.isAuthenticated = false;: TheisAuthenticatedproperty 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:
-
localstorageCustomReducerFunction:- 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 wherelocalStorageis available. This is important because Angular applications can also be rendered on the server (Server-Side Rendering - SSR), wherelocalStoragewouldn'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 initialINITaction (when the store is first set up) or anUPDATEaction. -
const storageValue = localStorage.getItem(localStorageKey);: Tries to retrieve data fromlocalStorageusing 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 currentstateand theparsedAuthdata loaded fromlocalStorage. Critically, it seems to be trying to set the entireparsedAuthobject directly onto a property named'auth-ngrx'within theAppState. This might be slightly different from how theauthStateis structured withinAppState(which isstate.authState). This is a potential point of interest/bug, aslocalStorageKeyis 'auth-ngrx', but your state structure isAppState.authState. It should probably be merging intostate.authState. - If parsing fails (e.g., the data in
localStorageis 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 thereducerthat 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 theauthStateslice from thenewStateand saves it tolocalStoragebyJSON.stringify-ing it.
-
-
return newState;: The meta-reducer returns thenewState.
In Summary:
- On Initialization (
INITaction): The meta-reducer attempts to load the authentication state (or whatever is stored under thelocalStorageKey) fromlocalStorageand 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
authStateslice 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 };wherelocalStorageKeyis'auth-ngrx'. This would createstate['auth-ngrx']. - Writing:
localStorage.setItem(localStorageKey, JSON.stringify(newState.authState));This correctly saves theauthStateslice.
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() })
]
};
Top comments (0)