← Back to list

Complete NgRx Setup in Angular (Standalone)

With Effects, Selectors, DevTools & State Persistence (Meta-Reducers)

siva geddada · 2026-02-23 00:38 · 3 claps · 3.1 min read
#angular #ngrx #ngrx-effects
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Complete NgRx Setup in Angular (Standalone)

With Effects, Selectors, DevTools & State Persistence (Meta-Reducers)

This is a step-by-step production-ready guide using:

  • Angular (Standalone API)
  • NgRx Store
  • NgRx Effects
  • NgRx DevTools
  • Meta-Reducer for LocalStorage Hydration
  • DummyJSON API (https://dummyjson.com/products)

By the end, you will understand:

  • NgRx lifecycle
  • How to structure feature state
  • How to avoid duplicate API calls after refresh
  • How to persist state

What is NgRx?

NgRx is a reactive state management library for Angular based on the Redux pattern.

It follows unidirectional data flow:

Component
   ↓
Action
   ↓
Reducer
   ↓
Store Updated
   ↓
Selector
   ↓
Component UI Updated

Async work happens in Effects.

Working Example Repository: The complete source code for this tutorial is available on GitHub:

https://github.com/siva-geddada/ngrx

Step 1 — Install NgRx

ng add @ngrx/store@latest

ng add @ngrx/effects@latest

ng add @ngrx/store-devtools@latest

Step 2 — Project Structure

products/
 ├── state/
 │    ├── product.actions.ts
 │    ├── product.reducer.ts
 │    ├── product.effects.ts
 │    ├── product.selectors.ts
 │    ├── product.model.ts
 │
 └── product.component.ts

Step 3 — Define State

product.model.ts

export interface Product {
  id: number;
  title: string;
  price: number;
  thumbnail: string;
}
export interface ProductState {
  loading: boolean;
  products: Product[];
  error: string | null;
}
export const initialProductState: ProductState = {
  loading: false,
  products: [],
  error: null
};

Step 4—Create Actions

product.actions.ts

import { createAction, props } from '@ngrx/store';
import { Product } from './product.model';

export const LoadProductAction =
  createAction('[Product Page] Load Products');
export const LoadProductActionSuccess =
  createAction(
    '[Product API] Load Products Success',
    props<{ products: Product[] }>()
  );
export const LoadProductActionError =
  createAction(
    '[Product API] Load Products Failure',
    props<{ error: string }>()
  );

Action describes what happened, not how.

Step 5—Create Reducer

product.reducer.ts

import { createReducer, on } from '@ngrx/store';
import * as ProductActions from './product.actions';
import { initialProductState } from './product.model';

export const PRODUCT_FEATURE_KEY = 'products';

export const productReducer = createReducer(
  initialProductState,
  on(ProductActions.LoadProductAction, state => ({
    ...state,
    loading: true,
    error: null
  })),
  on(ProductActions.LoadProductActionSuccess, (state, { products }) => ({
    ...state,
    loading: false,
    products
  })),
  on(ProductActions.LoadProductActionError, (state, { error }) => ({
    ...state,
    loading: false,
    error
  }))
);

Reducer must be pure (no HTTP, no side effects).

Step 6—Create Effect

product.effects.ts

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { HttpClient } from '@angular/common/http';
import { switchMap, map, catchError, of } from 'rxjs';
import * as ProductActions from './product.actions';

@Injectable()
export class ProductEffects {

http = inject(HttpClient);

actions$ = inject(Actions);

  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.LoadProductAction),
      switchMap(() =>
        this.http.get<any>('https://dummyjson.com/products').pipe(
          map(res =>
            ProductActions.LoadProductActionSuccess({
              products: res.products
            })
          ),
          catchError(err =>
            of(ProductActions.LoadProductActionError({
              error: err.message
            }))
          )
        )
      )
    )
  );
}

Effects handle asynchronous operations.

Step 7 — Create Selectors

product.selectors.ts

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductState } from './product.model';
import { PRODUCT_FEATURE_KEY } from './product.reducer';

export const selectProductState =
  createFeatureSelector<ProductState>(PRODUCT_FEATURE_KEY);

export const selectProductLoading =
  createSelector(selectProductState, state => state.loading);

export const selectProductLoadingError =
  createSelector(selectProductState, state => state.error);

export const selectProductLoadingSuccess =
  createSelector(selectProductState, state => state.products);

Selector reads data from store efficiently.

Step 8—Register Everything (Standalone)

main.ts

import { provideStore } from '@ngrx/store';
import { provideState } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideHttpClient } from '@angular/common/http';
import { isDevMode } from '@angular/core';

providers: [
  provideStore(),
  provideState(PRODUCT_FEATURE_KEY, productReducer),
  provideEffects([ProductEffects]),
  provideHttpClient(),
  provideStoreDevtools({
    maxAge: 25,
    logOnly: !isDevMode()
  })
]

Step 9—Component Usage

product.component.ts

import { Component, OnInit, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import * as ProductSelectors from './state/product.selectors';
import * as ProductActions from './state/product.actions';

@Component({
  selector: 'app-product',
  templateUrl: './product.component.html'
})
export class ProductComponent implements OnInit {

  private store = inject(Store);
  loading$ = this.store.select(ProductSelectors.selectProductLoading);
  error$ = this.store.select(ProductSelectors.selectProductLoadingError);
  products$ = this.store.select(ProductSelectors.selectProductLoadingSuccess);

  ngOnInit(): void {
    this.products$.pipe(take(1)).subscribe((products) => {
      if (!products || products.length === 0) {
        this.store.dispatch(ProductActions.LoadProductAction());
      }
    });
  }

  onReload() {
    this.store.dispatch(ProductActions.LoadProductAction());
  }
}

Why API Calls Again on Refresh?

Because the NgRx store is in-memory.

Refresh = App restarts Store = initial state → API called again

To prevent that, use Meta-Reducers (Hydration).

Step 10 — Meta-Reducer (State Persistence)

hydration.metareducer.ts

import { ActionReducer, INIT, UPDATE } from '@ngrx/store';

export function hydrationMetaReducer(reducer: ActionReducer<any>) {
  return (state, action) => {
    if (action.type === INIT || action.type === UPDATE) {
      const storedState = localStorage.getItem('appState');
      if (storedState) {
        return JSON.parse(storedState);
      }
    }
    const nextState = reducer(state, action);
    localStorage.setItem('appState', JSON.stringify(nextState));
    return nextState;
  };
}

Register Meta Reducer

provideStore(
  {},
  { metaReducers: [hydrationMetaReducer] }
),

Final Lifecycle

Component dispatches action
        ↓
Reducer sets loading true
        ↓
Effect calls API
        ↓
Success/Failure action dispatched
        ↓
Reducer updates state
        ↓
Selector emits new data
        ↓
UI updates

Conclusion

You now have:

  • Feature state
  • Proper reducer
  • Effects with async handling
  • Selectors
  • DevTools integration
  • Persistent store using meta-reducer
  • Production-ready structure

메타데이터
post_id
907b7b76ff25
slug
complete-ngrx-setup-in-angular-standalone-907b7b76ff25
url
https://medium.com/@siva-cs579/complete-ngrx-setup-in-angular-standalone-907b7b76ff25
canonical_url
https://medium.com/@siva-cs579/complete-ngrx-setup-in-angular-standalone-907b7b76ff25
author_url
https://medium.com/@siva-cs579
status
ok
fetched_at
2026-07-08 21:20:17