Sign up for your FREE personalized newsletter featuring insights, trends, and news for America's Active Baby Boomers

Newsletter
New

Angular Ngrx + Localstorage

Card image cap

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 and user 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

Getting Started

  1. Clone the repository (if applicable):

    git clone https://github.com/cheikhbethio/ngrx-2025.git  
    cd ngrx-2025  
    
  2. Install dependencies:

    npm install  
    
  3. 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.
  • 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:

  1. Serves as the Root Component: It's the main component bootstrapped for the application (indicated by the selector: 'app-root').
  2. 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.
  3. Imports Necessary Modules/Components: It imports RouterOutlet, RouterLink, RouterLinkActive for routing, and CommonModule for common Angular directives.
  4. Injects Services and Store:
    • It injects the NgRx Store to select data.
    • It injects the AuthService.
  5. 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.
  6. Provides Logout Functionality: It has a logout() method that calls the logout method of the injected AuthService. 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

  1. 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 be null, and isAuthenticated will be false.
  2. authReducer:

    • export const authReducer = createReducer(...): This is the main reducer function for authentication.
    • It's created using createReducer, which takes the initialAuthState 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 a user payload), this function is executed.
        • It uses produce(state, (draft: AuthState) => { ... }) from Immer.
        • Inside the Immer produce function:
          • draft.user = user;: The user property of the draft state is set to the user payload from the action.
          • draft.isAuthenticated = true;: The isAuthenticated property is set to true.
        • Immer ensures that a new, immutable state object is returned with these changes.
      • 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;: The user property is set back to null.
          • draft.isAuthenticated = false;: The isAuthenticated property is set back to false.
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:

  1. 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 where localStorage is available. This is important because Angular applications can also be rendered on the server (Server-Side Rendering - SSR), where localStorage wouldn't exist.
  2. 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 initial INIT action (when the store is first set up) or an UPDATE action.
      • const storageValue = localStorage.getItem(localStorageKey);: Tries to retrieve data from localStorage using the localStorageKey (i.e., 'auth-ngrx').
      • if(storageValue): If data is found in localStorage:
        • try ... catch: It attempts to JSON.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 current state and the parsedAuth data loaded from localStorage. Critically, it seems to be trying to set the entire parsedAuth object directly onto a property named 'auth-ngrx' within the AppState. This might be slightly different from how the authState is structured within AppState (which is state.authState). This is a potential point of interest/bug, as localStorageKey is 'auth-ngrx', but your state structure is AppState.authState. It should probably be merging into state.authState.
        • If parsing fails (e.g., the data in localStorage is corrupted), it logs an error and removes the invalid item from localStorage.
    • Processing by Next Reducer:
      • const newState = reducer(state, action);: The original action is passed to the reducer that was provided to the meta-reducer (your normal application reducers). This computes the "next" state based on the action, after potentially having loaded from localStorage.
    • 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 produced newState, this line takes the authState slice from the newState and saves it to localStorage by JSON.stringify-ing it.
    • return newState;: The meta-reducer returns the newState.

In Summary:

  • On Initialization (INIT action): The meta-reducer attempts to load the authentication state (or whatever is stored under the localStorageKey) from localStorage and merge it into the initial application state.
  • On Every Action:
    1. First, it lets your regular reducers (passed into it) calculate the new state based on the dispatched action.
    2. Then, it takes the authState slice from this newly calculated state and saves it to localStorage.

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 }; where localStorageKey is 'auth-ngrx'. This would create state['auth-ngrx'].
  • Writing: localStorage.setItem(localStorageKey, JSON.stringify(newState.authState)); This correctly saves the authState 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() })  
  ]  
};  
  


Recent