Simple state management in Angular with only Services and Signal.
- Description
- Get Started
- Signal Store
- Schematics CLI
- Effect Management
- Linked Signals
- Plugin System
- Transactions
- Debounced Updates
- Throttled Updates
- Immer-style Updates
- Redux DevTools Integration
- Integration with ng-http-caching
- WebMCP
- Server-side rendering (SSR)
- RxJS Store
- Alternatives
- Support
- My other libraries
Sharing state between components as simple as possible and leverage the good parts of component state and Angular's dependency injection system.
See the demo
npm i ng-simple-stateprovideNgSimpleState has some global optional config defined by NgSimpleStateConfig interface:
| Option | Description | Default |
|---|---|---|
| enableDevTool | if true enable Redux DevTools browser extension for inspect the state of the store. |
false |
| persistentStorage | Set the persistent storage local, session or instance of NgSimpleStateStorage. |
undefined |
| comparator | A function used to compare the previous and current state for equality. | a === b |
| serializeState | A function used to serialize the state to a string. | JSON.stringify |
| deserializeState | A function used to deserialize the state from a string. | JSON.parse |
| plugins | Array of plugins to extend store functionality. | [] |
| immerProduce | Custom Immer produce function for immutable updates. | undefined |
| webMcp | Enable experimental WebMCP tool integration. | false |
| transferState | Transfer the state rendered on the server to the browser (see "Server-side rendering (SSR)"). | false |
Side note: each store can be override the global configuration implementing storeConfig() method (see "Override global config").
import { isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { provideNgSimpleState } from 'ng-simple-state';
bootstrapApplication(AppComponent, {
providers: [
provideNgSimpleState({
enableDevTool: isDevMode(),
persistentStorage: 'local'
})
]
});There are two type of store NgSimpleStateBaseRxjsStore based on RxJS BehaviorSubject and NgSimpleStateBaseSignalStore based on Angular Signal:
This is an example for a counter store in a src/app/counter-store.ts file.
Obviously, you can create every store you want with every complexity you need.
- Define your state interface, eg.:
export interface CounterState {
count: number;
}- Define your store service by extending
NgSimpleStateBaseSignalStore, eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseSignalStore } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseSignalStore<CounterState> {
}- Implement
initialState()andstoreConfig()methods and provide the initial state of the store, eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseSignalStore<CounterState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
}- Implement one or more selectors of the partial state you want, in this example
selectCount()eg.:
import { Injectable, Signal } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseSignalStore<CounterState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
selectCount(): Signal<number> {
return this.selectState(state => state.count);
}
}- Implement one or more actions for change the store state, in this example
increment()anddecrement()eg.:
import { Injectable, Signal } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseSignalStore<CounterState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
selectCount(): Signal<number> {
return this.selectState(state => state.count);
}
increment(increment: number = 1): void {
this.setState(state => ({ count: state.count + increment }));
}
decrement(decrement: number = 1): void {
this.setState(state => ({ count: state.count - decrement }));
}
}import { Component } from '@angular/core';
import { CounterStore } from './counter-store';
@Component({
selector: 'app-root',
imports: [CounterStore]
})
export class AppComponent {
}import { Component, Signal, inject } from '@angular/core';
import { CounterStore } from './counter-store';
@Component({
selector: 'app-root',
template: `
<h1>Counter: {{ counterSig() }}</h1>
<button (click)="counterStore.decrement()">Decrement</button>
<button (click)="counterStore.resetState()">Reset</button>
<button (click)="counterStore.increment()">Increment</button>
`,
})
export class AppComponent {
public counterStore = inject(CounterStore);
public counterSig: Signal<number> = this.counterStore.selectCount();
}If you want manage just a component state without make a new service, your component can extend directly NgSimpleStateBaseSignalStore:
import { Component, Signal } from '@angular/core';
import { NgSimpleStateBaseSignalStore } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Component({
selector: 'app-counter',
template: `
{{counterSig()}}
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
`
})
export class CounterComponent extends NgSimpleStateBaseSignalStore<CounterState> {
public counterSig: Signal<number> = this.selectState(state => state.count);
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterComponent'
};
}
initialState(): CounterState {
return {
count: 0
};
}
increment(): void {
this.setState(state => ({ count: state.count + 1 }));
}
decrement(): void {
this.setState(state => ({ count: state.count - 1 }));
}
}If you need to override the global configuration provided by provideNgSimpleState() you can implement storeConfig() and return a specific configuration for the single store, eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateStoreConfig } from 'ng-simple-state';
@Injectable()
export class CounterStore extends NgSimpleStateBaseSignalStore<CounterState> {
override storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
persistentStorage: 'session', // persistentStorage can be 'session' or 'local' (default is localStorage)
storeName: 'CounterStore2', // set a specific name for this store (must be be unique)
}
}
}The options are defined by NgSimpleStateStoreConfig interface:
| Option | Description | Default |
|---|---|---|
| enableDevTool | if true enable Redux DevTools browser extension for inspect the state of the store. |
false |
| storeName | The store name. | undefined |
| persistentStorage | Set the persistent storage local, session or instance of NgSimpleStateStorage. |
undefined |
| comparator | A function used to compare the previous and current state for equality. | a === b |
| serializeState | A function used to serialize the state to a string. | JSON.stringify |
| deserializeState | A function used to deserialize the state from a string. | JSON.parse |
| plugins | Array of plugins to extend store functionality. | [] |
| immerProduce | Custom Immer produce function for immutable updates. | undefined |
| webMcp | Enable experimental WebMCP tool integration. | false |
ng-simple-state is simple to test. Eg.:
import { TestBed } from '@angular/core/testing';
import { provideNgSimpleState } from 'ng-simple-state';
import { CounterStore } from './counter-store';
describe('CounterStore', () => {
let counterStore: CounterStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideNgSimpleState({
enableDevTool: false
}),
CounterStore
]
});
counterStore = TestBed.inject(CounterStore);
});
it('initialState', () => {
expect(counterStore.getCurrentState()).toEqual({ count: 0 });
});
it('increment', () => {
counterStore.increment();
expect(counterStore.getCurrentState()).toEqual({ count: 1 });
});
it('decrement', () => {
counterStore.decrement();
expect(counterStore.getCurrentState()).toEqual({ count: -1 });
});
it('selectCount', () => {
const valueSig = counterStore.selectCount();
expect(valueSig()).toBe(0);
});
});This is an example for a todo list store in a src/app/todo-store.ts file.
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseSignalStore } from 'ng-simple-state';
export interface Todo {
id: number;
name: string;
completed: boolean;
}
export type TodoState = Array<Todo>;
@Injectable()
export class TodoStore extends NgSimpleStateBaseSignalStore<TodoState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'TodoStore'
};
}
initialState(): TodoState {
return [];
}
add(todo: Omit<Todo, 'id'>): void {
this.setState(state => [...state, {...todo, id: Date.now()}]);
}
delete(id: number): void {
this.setState(state => state.filter(item => item.id !== id) );
}
setComplete(id: number, completed: boolean = true): void {
this.setState(state => state.map(item => item.id === id ? {...item, completed} : item) );
}
}usage:
import { Component, Signal, inject } from '@angular/core';
import { Todo, TodoStore } from './todo-store';
@Component({
selector: 'app-root',
template: `
<input #newTodo> <button (click)="todoStore.add({name: newTodo.value, completed: false})">Add todo</button>
<ol>
@for(todo of todoListSig(); track todo.id) {
<li>
@if(todo.completed) {
✅
}
{{ todo.name }}
<button (click)="todoStore.setComplete(todo.id, !todo.completed)">Mark as {{ todo.completed ? 'Not completed' : 'Completed' }}</button>
<button (click)="todoStore.delete(todo.id)">Delete</button>
</li>
}
</ol>
`,
providers: [TodoStore]
})
export class AppComponent {
public todoStore = inject(TodoStore);
public todoListSig: Signal<Todo[]> = this.todoStore.selectState();
}@Injectable()
@Directive()
export abstract class NgSimpleStateBaseSignalStore<S extends object | Array<any>> implements OnDestroy {
/**
* Return the Signal of the state
* @returns Signal of the state
*/
public get state(): Signal<S>;
/**
* Reset store to first loaded store state:
* - the last saved state
* - otherwise the initial state provided from `initialState()` method.
*/
resetState(): boolean;
/**
* Restart the store to initial state provided from `initialState()` method
*/
restartState(): boolean;
/**
* Override this method for set a specific config for the store
* @returns NgSimpleStateStoreConfig
*/
storeConfig(): NgSimpleStateStoreConfig<S>;
/**
* Set into the store the initial state
* @returns The state object
*/
initialState(): S;
/**
* Select a store state
* @param selectFn State selector (if not provided return full state)
* @param comparator A function used to compare the previous and current state for equality. Defaults to a `===` check.
* @returns Signal of the selected state
*/
selectState<K>(selectFn?: (state: Readonly<S>) => K, comparator?: (previous: K, current: K) => boolean): Signal<K>;
/**
* Return the current store state (snapshot)
* @returns The current state
*/
getCurrentState(): Readonly<S>;
/**
* Return the first loaded store state:
* the last saved state
* otherwise the initial state provided from `initialState()` method.
* @returns The first state
*/
getFirstState(): Readonly<S> | null;
/**
* Set a new state
* @param newState New state
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
setState(newState: Partial<S>, actionName?: string): boolean;
/**
* Set a new state
* @param selectFn State reducer
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
setState(stateFn: NgSimpleStateSetState<S>, actionName?: string): boolean;
/**
* Replace state
* @param newState New state
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
replaceState(newState: S, actionName?: string): boolean;
/**
* Replace state
* @param selectFn State reducer
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
replaceState(stateFn: NgSimpleStateReplaceState<S>, actionName?: string): boolean;
/**
* Create a linked signal that derives from store state
* @param options LinkedSignal options with source, computation and equal
* @returns WritableSignal that is linked to the store state
*/
linkedState<K>(options: NgSimpleStateLinkedOptions<S, K>): WritableSignal<K>;
/**
* Create an effect that reacts to state changes
* @param name Unique effect name
* @param effectFn Effect function that receives current state
* @returns EffectRef for cleanup
*/
createEffect(name: string, effectFn: (state: S) => void | (() => void)): EffectRef;
/**
* Create an effect that reacts to selected state changes
* @param name Unique effect name
* @param selector State selector
* @param effectFn Effect function that receives selected value
* @returns EffectRef for cleanup
*/
createSelectorEffect<K>(name: string, selector: (state: S) => K, effectFn: (selected: K) => void | (() => void)): EffectRef;
/**
* Set state using Immer-style producer function for immutable updates
* @param producer Producer function that receives draft state
* @param actionName The action label into Redux DevTools
* @returns True if the state is changed
*/
produce(producer: NgSimpleStateProducer<S>, actionName?: string): boolean;
/**
* Destroy a specific effect by name
* @param name Effect name to destroy
*/
destroyEffect(name: string): void;
/**
* Destroy all registered effects
*/
destroyAllEffects(): void;
/**
* Get all registered effect names
* @returns Array of effect names
*/
getEffectNames(): string[];
}Generate stores quickly using Angular CLI:
# Generate a Signal store (recommended)
ng generate ng-simple-state:store my-feature
# Generate an RxJS store
ng generate ng-simple-state:store my-feature --type=rxjs
# With persistent storage
ng generate ng-simple-state:store my-feature --persistentStorage=localEffects are side-effect functions that react to state changes. They are useful for logging, analytics, syncing with external services, or triggering additional actions.
Each effect has a name (any unique string you choose) that serves as an identifier, you can use it later to destroy the effect with destroyEffect(name). If you create a new effect with the same name, the previous one is automatically cleaned up.
| Method | Description |
|---|---|
createEffect(name, effectFn) |
Runs effectFn(state) on every state change |
createSelectorEffect(name, selector, effectFn) |
Runs effectFn(selected) only when the selected slice changes |
destroyEffect(name) |
Destroys a specific effect by its name |
import { Injectable, Signal } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface UserState {
user: { id: number; name: string } | null;
isLoading: boolean;
lastActivity: string;
}
@Injectable({ providedIn: 'root' })
export class UserStore extends NgSimpleStateBaseSignalStore<UserState> {
storeConfig(): NgSimpleStateStoreConfig<UserState> {
return { storeName: 'UserStore' };
}
initialState(): UserState {
return { user: null, isLoading: false, lastActivity: '' };
}
constructor() {
super();
this.createEffect('logger', (state) => {
console.log('[UserStore] State updated:', state);
});
// effect runs only when state.user changes
this.createSelectorEffect(
'userChanged',
state => state.user,
(user) => {
if (user) {
console.log('User logged in:', user.name);
} else {
console.log('User logged out');
}
}
);
}
selectUser(): Signal<{ id: number; name: string } | null> {
return this.selectState(state => state.user);
}
login(user: { id: number; name: string }): void {
this.setState({ user, isLoading: false, lastActivity: 'login' });
}
logout(): void {
this.setState({ user: null, lastActivity: 'logout' });
}
disableLogging(): void {
this.destroyEffect('logger');
}
}@Component({
selector: 'app-user',
template: `
@if (user()) {
<p>Welcome, {{ user()?.name }}!</p>
<button (click)="store.logout()">Logout</button>
} @else {
<button (click)="store.login({ id: 1, name: 'John' })">Login</button>
}
`
})
export class UserComponent {
store = inject(UserStore);
user = this.store.selectUser();
}Create reactive linked signals that derive from store state with custom computation:
import { Injectable, Signal, computed } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface ProfileState {
firstName: string;
lastName: string;
age: number;
}
@Injectable({ providedIn: 'root' })
export class ProfileStore extends NgSimpleStateBaseSignalStore<ProfileState> {
storeConfig(): NgSimpleStateStoreConfig<ProfileState> {
return { storeName: 'ProfileStore' };
}
initialState(): ProfileState {
return { firstName: '', lastName: '', age: 0 };
}
// Linked signal with custom computation
fullName = this.linkedState({
source: state => ({ first: state.firstName, last: state.lastName }),
computation: (name) => `${name.first} ${name.last}`.trim()
});
selectFirstName(): Signal<string> {
return this.selectState(state => state.firstName);
}
setName(firstName: string, lastName: string): void {
this.setState({ firstName, lastName });
}
}Usage in component:
@Component({
selector: 'app-profile',
template: `
<p>Full Name: {{ store.fullName() }}</p>
<input [value]="firstName()" (input)="updateFirstName($event)" placeholder="First name" />
<input [value]="lastName()" (input)="updateLastName($event)" placeholder="Last name" />
`
})
export class ProfileComponent {
store = inject(ProfileStore);
firstName = this.store.selectFirstName();
lastName = this.store.selectState(s => s.lastName);
updateFirstName(event: Event): void {
this.store.setName((event.target as HTMLInputElement).value, this.lastName());
}
updateLastName(event: Event): void {
this.store.setName(this.firstName(), (event.target as HTMLInputElement).value);
}
}Extend store functionality with plugins. Plugins can intercept state changes, perform side effects, and add features like undo/redo.
Enable state history with undo/redo functionality:
import { isDevMode } from '@angular/core';
import { provideNgSimpleState, undoRedoPlugin } from 'ng-simple-state';
bootstrapApplication(AppComponent, {
providers: [
provideNgSimpleState({
enableDevTool: isDevMode(),
plugins: [undoRedoPlugin({ maxHistory: 50 })]
})
]
});The undoRedoPlugin is automatically registered as an injectable token (NG_SIMPLE_STATE_UNDO_REDO).
Use inject() and forStore():
import { Component, inject, Signal } from '@angular/core';
import { NG_SIMPLE_STATE_UNDO_REDO, NgSimpleStateUndoRedoPlugin } from 'ng-simple-state';
import { CounterState, CounterStore } from './counter-store';
@Component({
selector: 'app-counter',
template: `
<h1>Counter: {{ counter() }}</h1>
<button (click)="store.increment()">+</button>
<button (click)="store.decrement()">-</button>
<hr>
<button [disabled]="!canUndo()" (click)="history.undo()">Undo</button>
<button [disabled]="!canRedo()" (click)="history.redo()">Redo</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
store = inject(CounterStore);
counter = this.store.selectCount();
// Inject via token and bind to the store, no store name strings needed
private readonly undoRedo = inject(NG_SIMPLE_STATE_UNDO_REDO) as NgSimpleStateUndoRedoPlugin<CounterState>;
private readonly history = this.undoRedo.forStore(this.store);
// Reactive signals (work with OnPush / zoneless)
canUndo: Signal<boolean> = this.history.selectCanUndo();
canRedo: Signal<boolean> = this.history.selectCanRedo();
}forStore(store) returns a NgSimpleStateUndoRedoForStore<S> helper bound to the store.
undo() and redo() call replaceState automatically, no manual wiring needed.
const history = undoRedo.forStore(store);
// Reactive signals (Signal<boolean>)
history.selectCanUndo();
history.selectCanRedo();
// Undo/Redo, applies state automatically, returns true if successful
history.undo();
history.redo();
// Plain boolean checks
history.canUndo();
history.canRedo();
// Clear history
history.clearHistory();You can use the built-in persistPlugin to implement custom storage logic (e.g., saving to a backend API or custom storage mechanism):
import { persistPlugin } from 'ng-simple-state';
const myPersistPlugin = persistPlugin({
save: (storeName, state) => myCustomStorage.save(storeName, state),
load: (storeName) => myCustomStorage.load(storeName),
// Optional: filter which stores should be persisted
filter: (storeName) => storeName === 'MyStore'
});save runs after every state change, while load runs once when the store is
created: returning a state from it hydrates the store, returning null keeps the
state provided by initialState().
If you need to register plugins outside of the initial provideNgSimpleState call, you can use provideNgSimpleStatePlugins:
import { provideNgSimpleStatePlugins } from 'ng-simple-state';
bootstrapApplication(AppComponent, {
providers: [
provideNgSimpleStatePlugins([myCustomPlugin])
]
});You can create your own plugins implementing the NgSimpleStatePlugin interface:
import { NgSimpleStatePlugin, NgSimpleStatePluginContext } from 'ng-simple-state';
const myCustomPlugin: NgSimpleStatePlugin = {
name: 'myPlugin',
onBeforeStateChange(context: NgSimpleStatePluginContext): boolean | void {
// Return false to prevent state change
console.log(`Before: ${context.actionName}`);
},
onAfterStateChange(context: NgSimpleStatePluginContext): void {
console.log(`After: ${context.actionName}`, context.nextState);
},
// return a state to hydrate the store with it, or nothing to leave it untouched
onStoreInit(storeName: string, initialState: unknown): void | unknown {
console.log(`Store ${storeName} initialized`);
},
onStoreDestroy(storeName: string): void {
console.log(`Store ${storeName} destroyed`);
}
};Execute operations with automatic rollback on error using withTransaction:
import { withTransaction } from 'ng-simple-state';
// Transaction with automatic rollback on error
await withTransaction(store, async (tx) => {
store.setState({ step: 1 });
await apiCall(); // If this fails, state rolls back automatically
store.setState({ step: 2 });
tx.commit(); // Explicit commit (optional - auto-commits if not called)
});
// Manual rollback example
await withTransaction(store, async (tx) => {
store.setState({ processing: true });
const result = await riskyOperation();
if (!result.success) {
tx.rollback(); // Manually rollback to initial state
return;
}
store.setState({ data: result.data });
tx.commit();
});Rate-limit state updates with createDebouncedUpdater. Only the last update within the time window is applied:
import { createDebouncedUpdater } from 'ng-simple-state';
// Create a debounced updater with 300ms delay
const { update, flush, cancel } = createDebouncedUpdater<MyState>(
(state) => store.setState(state),
300
);
// Rapid calls - only the last one is applied after 300ms
update({ searchQuery: 'a' });
update({ searchQuery: 'ab' });
update({ searchQuery: 'abc' }); // Only this is applied after 300ms
// Force immediate update
flush();
// Cancel any pending update
cancel();Limit update frequency with createThrottledUpdater. At most one update per time window:
import { createThrottledUpdater } from 'ng-simple-state';
// Create a throttled updater with 100ms delay
const { update, cancel } = createThrottledUpdater<MyState>(
(state) => store.setState(state),
100
);
// First call executes immediately, subsequent calls are throttled
update({ scrollPosition: 100 }); // Executes immediately
update({ scrollPosition: 150 }); // Queued
update({ scrollPosition: 200 }); // Merged into the queued update
// After 100ms, { scrollPosition: 200 } is applied
// Cancel pending throttled update
cancel();Write mutable-looking code that produces immutable updates. First, install Immer and configure it:
npm install immer// In your app bootstrap
import { produce } from 'immer';
provideNgSimpleState({
immerProduce: produce
});Store example with Immer:
import { Injectable, Signal } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface User {
id: number;
name: string;
email: string;
}
export interface UsersState {
users: User[];
selectedId: number | null;
}
@Injectable({ providedIn: 'root' })
export class UsersStore extends NgSimpleStateBaseSignalStore<UsersState> {
storeConfig(): NgSimpleStateStoreConfig<UsersState> {
return { storeName: 'UsersStore' };
}
initialState(): UsersState {
return { users: [], selectedId: null };
}
// Selectors
selectUsers(): Signal<User[]> {
return this.selectState(state => state.users);
}
selectSelectedUser(): Signal<User | undefined> {
return this.selectState(state =>
state.users.find(u => u.id === state.selectedId)
);
}
// Actions using Immer produce - looks mutable but is immutable!
addUser(user: User): void {
this.produce(draft => {
draft.users.push(user); // Looks mutable, but creates immutable update
});
}
updateUserName(id: number, newName: string): void {
this.produce(draft => {
const user = draft.users.find(u => u.id === id);
if (user) {
user.name = newName; // Direct mutation syntax, but immutable result
}
});
}
removeUser(id: number): void {
this.produce(draft => {
const index = draft.users.findIndex(u => u.id === id);
if (index !== -1) {
draft.users.splice(index, 1); // Array mutation syntax
}
});
}
selectUser(id: number): void {
this.setState({ selectedId: id });
}
}Usage in component:
@Component({
selector: 'app-users',
template: `
<ul>
@for (user of users(); track user.id) {
<li [class.selected]="user.id === selectedUser()?.id">
{{ user.name }} ({{ user.email }})
<button (click)="store.selectUser(user.id)">Select</button>
<button (click)="store.removeUser(user.id)">Remove</button>
</li>
}
</ul>
<button (click)="addNewUser()">Add User</button>
`
})
export class UsersComponent {
store = inject(UsersStore);
users = this.store.selectUsers();
selectedUser = this.store.selectSelectedUser();
addNewUser(): void {
this.store.addUser({
id: Date.now(),
name: 'New User',
email: 'new@example.com'
});
}
}Full integration with Redux DevTools browser extension for time-travel debugging:
// Enable DevTools in your app configuration
provideNgSimpleState({
enableDevTool: isDevMode()
});Features available in Redux DevTools:
- Time-travel debugging - Jump to any previous state
- Action history - See all dispatched actions with timestamps
- State inspection - Explore state tree at any point
- Diff visualization - See what changed between states
- Export/Import - Save and restore state
ng-simple-state can be used as a reactive storage backend for ng-http-caching. This integration allows you to manage the HTTP cache through ng-simple-state stores, providing full visibility and control over the cache state within your DevTools or application state.
- Install
ng-http-caching:
npm i ng-http-caching- Configure
ng-http-cachingto use theng-simple-stateadapter in yourbootstrapApplication:
import { provideNgHttpCaching } from 'ng-http-caching';
import { withNgHttpCachingNgSimpleState } from 'ng-http-caching/ng-simple-state';
bootstrapApplication(AppComponent, {
providers: [
provideNgHttpCaching({
store: withNgHttpCachingNgSimpleState(),
}),
// ... other providers
]
});You can customize the store configuration (e.g., enable persistence or change the store name) by passing a configuration object to the adapter:
provideNgHttpCaching({
store: withNgHttpCachingNgSimpleState({
storeName: 'MyCacheStore',
// ... other NgSimpleStateStoreConfig options
}),
})Enable experimental WebMCP (only read current state)
import { Injectable, inject, Signal } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface User {
id: number;
name: string;
email: string;
}
export interface UsersState {
users: User[];
selectedId: number | null;
}
@Injectable()
export class UsersStore extends NgSimpleStateBaseSignalStore<UsersState> {
storeConfig(): NgSimpleStateStoreConfig<UsersState> {
return { storeName: 'UsersStore', webMcp: true };
}
initialState(): UsersState {
return { users: [], selectedId: null };
}
}The library runs as is on the server: every browser only feature it can use is skipped when there is no DOM, so nothing throws and nothing leaks from one rendered page to the next.
| Option | Server side behaviour |
|---|---|
| persistentStorage | An in-memory storage is used instead of localStorage/sessionStorage: the store starts from initialState() and writes go nowhere. |
| enableDevTool | Disabled: the Redux DevTools extension only exists in the browser. |
| webMcp | Disabled: the WebMCP registry is a browser API. |
Stores keep working as usual, so anything the server renders comes from initialState() (or from the state your resolvers/effects produce during the render).
Set transferState: true to hand the state used by the server over to the browser through Angular TransferState. During hydration the store starts from the state the page was rendered with, instead of recomputing it:
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseSignalStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface UsersState {
users: User[];
}
@Injectable({ providedIn: 'root' })
export class UsersStore extends NgSimpleStateBaseSignalStore<UsersState> {
storeConfig(): NgSimpleStateStoreConfig<UsersState> {
return { storeName: 'UsersStore', transferState: true };
}
initialState(): UsersState {
return { users: [] };
}
}The transferred state is consumed once, by the first store built in the browser, and then dropped: it describes the page that has just been hydrated. It also wins over persistentStorage, otherwise the hydrated state would disagree with the server rendered DOM. In an application without server-side rendering the option does nothing.
Side note: a plugin instance passed to provideNgSimpleState is created once per server process, not once per request (undoRedoPlugin keeps its history there, and a custom plugin may keep other state). Give the plugins per-request state only if you build them in a per-request provider factory.
This is an example for a counter store in a src/app/counter-store.ts file.
Obviously, you can create every store you want with every complexity you need.
- Define your state interface, eg.:
export interface CounterState {
count: number;
}- Define your store service by extending
NgSimpleStateBaseRxjsStore, eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseRxjsStore } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseRxjsStore<CounterState> {
}- Implement
initialState()andstoreConfig()methods and provide the initial state of the store, eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseRxjsStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseRxjsStore<CounterState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
}- Implement one or more selectors of the partial state you want, in this example
selectCount()eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseRxjsStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
import { Observable } from 'rxjs';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseRxjsStore<CounterState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
selectCount(): Observable<number> {
return this.selectState(state => state.count);
}
}- Implement one or more actions for change the store state, in this example
increment()anddecrement()eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseRxjsStore, NgSimpleStateStoreConfig } from 'ng-simple-state';
import { Observable } from 'rxjs';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseRxjsStore<CounterState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
selectCount(): Observable<number> {
return this.selectState(state => state.count);
}
increment(increment: number = 1): void {
this.setState(state => ({ count: state.count + increment }));
}
decrement(decrement: number = 1): void {
this.setState(state => ({ count: state.count - decrement }));
}
}import { Component } from '@angular/core';
import { CounterStore } from './counter-store';
@Component({
selector: 'app-root',
imports: [CounterStore]
})
export class AppComponent {
}import { Component, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { CounterStore } from './counter-store';
@Component({
selector: 'app-root',
imports: [CounterStore],
template: `
<h1>Counter: {{ counter$ | async }}</h1>
<button (click)="counterStore.decrement()">Decrement</button>
<button (click)="counterStore.resetState()">Reset</button>
<button (click)="counterStore.increment()">Increment</button>
`,
})
export class AppComponent {
public counterStore = inject(CounterStore);
public counter$: Observable<number> = this.counterStore.selectCount();
}If you want manage just a component state without make a new service, your component can extend directly NgSimpleStateBaseRxjsStore:
import { Component } from '@angular/core';
import { NgSimpleStateBaseRxjsStore } from 'ng-simple-state';
import { Observable } from 'rxjs';
export interface CounterState {
count: number;
}
@Component({
selector: 'app-counter',
template: `
{{counter$ | async}}
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
`
})
export class CounterComponent extends NgSimpleStateBaseRxjsStore<CounterState> {
public counter$: Observable<number> = this.selectState(state => state.count);
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'CounterComponent'
};
}
initialState(): CounterState {
return {
count: 0
};
}
increment(): void {
this.setState(state => ({ count: state.count + 1 }));
}
decrement(): void {
this.setState(state => ({ count: state.count - 1 }));
}
}If you need to override the global configuration provided by provideNgSimpleState() you can implement storeConfig() and return a specific configuration for the single store, eg.:
import { Injectable } from '@angular/core';
import { NgSimpleStateStoreConfig } from 'ng-simple-state';
@Injectable()
export class CounterStore extends NgSimpleStateBaseRxjsStore<CounterState> {
override storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
persistentStorage: 'session', // persistentStorage can be 'session' or 'local' (default is localStorage)
storeName: 'CounterStore2', // set a specific name for this store (must be be unique)
}
}
}The options are defined by NgSimpleStateStoreConfig interface:
| Option | Description | Default |
|---|---|---|
| enableDevTool | if true enable Redux DevTools browser extension for inspect the state of the store. |
false |
| storeName | The store name. | undefined |
| persistentStorage | Set the persistent storage local, session or instance of NgSimpleStateStorage. |
undefined |
| comparator | A function used to compare the previous and current state for equality. | a === b |
| serializeState | A function used to serialize the state to a string. | JSON.stringify |
| deserializeState | A function used to deserialize the state from a string. | JSON.parse |
| plugins | Array of plugins to extend store functionality. | [] |
| immerProduce | Custom Immer produce function for immutable updates. | undefined |
| webMcp | Enable experimental WebMCP tool integration. | false |
ng-simple-state is simple to test. Eg.:
import { TestBed } from '@angular/core/testing';
import { provideNgSimpleState } from 'ng-simple-state';
import { CounterStore } from './counter-store';
describe('CounterStore', () => {
let counterStore: CounterStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideNgSimpleState({
enableDevTool: false
}),
CounterStore
]
});
counterStore = TestBed.inject(CounterStore);
});
it('initialState', () => {
expect(counterStore.getCurrentState()).toEqual({ count: 0 });
});
it('increment', () => {
counterStore.increment();
expect(counterStore.getCurrentState()).toEqual({ count: 1 });
});
it('decrement', () => {
counterStore.decrement();
expect(counterStore.getCurrentState()).toEqual({ count: -1 });
});
it('selectCount', (done) => {
counterStore.selectCount().subscribe(value => {
expect(value).toBe(0);
done();
});
});
});This is an example for a todo list store in a src/app/todo-store.ts file.
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseRxjsStore } from 'ng-simple-state';
import { Observable } from 'rxjs';
export interface Todo {
id: number;
name: string;
completed: boolean;
}
export type TodoState = Array<Todo>;
@Injectable()
export class TodoStore extends NgSimpleStateBaseRxjsStore<TodoState> {
storeConfig(): NgSimpleStateStoreConfig<CounterState> {
return {
storeName: 'TodoStore'
};
}
initialState(): TodoState {
return [];
}
add(todo: Omit<Todo, 'id'>): void {
this.setState(state => [...state, {...todo, id: Date.now()}]);
}
delete(id: number): void {
this.setState(state => state.filter(item => item.id !== id) );
}
setComplete(id: number, completed: boolean = true): void {
this.setState(state => state.map(item => item.id === id ? {...item, completed} : item) );
}
}usage:
import { Component, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Todo, TodoStore } from './todo-store';
@Component({
selector: 'app-root',
template: `
<input #newTodo> <button (click)="todoStore.add({name: newTodo.value, completed: false})">Add todo</button>
<ol>
@for(todo of todoList$ | async; track todo.id) {
<li>
@if(todo.completed) {
✅
}
{{ todo.name }}
<button (click)="todoStore.setComplete(todo.id, !todo.completed)">Mark as {{ todo.completed ? 'Not completed' : 'Completed' }}</button>
<button (click)="todoStore.delete(todo.id)">Delete</button>
</li>
}
</ol>
`,
providers: [TodoStore]
})
export class AppComponent {
public todoStore = inject(TodoStore);
public todoList$: Observable<Todo[]> = this.todoStore.selectState();
}@Injectable()
@Directive()
export abstract class NgSimpleStateBaseRxjsStore<S extends object | Array<any>> implements OnDestroy {
/**
* Return the observable of the state
* @returns Observable of the state
*/
public get state(): Observable<S>;
/**
* Reset store to first loaded store state:
* - the last saved state
* - otherwise the initial state provided from `initialState()` method.
*/
resetState(): boolean;
/**
* Restart the store to initial state provided from `initialState()` method
*/
restartState(): boolean;
/**
* Override this method for set a specific config for the store
* @returns NgSimpleStateStoreConfig
*/
storeConfig(): NgSimpleStateStoreConfig<S>;
/**
* Set into the store the initial state
* @returns The state object
*/
initialState(): S;
/**
* Select a store state
* @param selectFn State selector (if not provided return full state)
* @param comparator A function used to compare the previous and current state for equality. Defaults to a `===` check.
* @returns Observable of the selected state
*/
selectState<K>(selectFn?: (state: Readonly<S>) => K, comparator?: (previous: K, current: K) => boolean): Observable<K>;
/**
* Return the current store state (snapshot)
* @returns The current state
*/
getCurrentState(): Readonly<S>;
/**
* Return the first loaded store state:
* the last saved state
* otherwise the initial state provided from `initialState()` method.
* @returns The first state
*/
getFirstState(): Readonly<S> | null;
/**
* Set a new state
* @param newState New state
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
setState(newState: Partial<S>, actionName?: string): boolean;
/**
* Set a new state
* @param selectFn State reducer
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
setState(stateFn: NgSimpleStateSetState<S>, actionName?: string): boolean;
/**
* Replace state
* @param newState New state
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
replaceState(newState: S, actionName?: string): boolean;
/**
* Replace state
* @param selectFn State reducer
* @param actionName The action label into Redux DevTools (default is parent function name)
* @returns True if the state is changed
*/
replaceState(stateFn: NgSimpleStateReplaceState<S>, actionName?: string): boolean;
/**
* Create an effect that reacts to state changes
* @param name Unique effect name
* @param effectFn Effect function that receives current state
*/
createEffect(name: string, effectFn: (state: S) => void): void;
/**
* Create an effect that reacts to selected state changes
* @param name Unique effect name
* @param selector State selector
* @param effectFn Effect function that receives selected value
*/
createSelectorEffect<K>(name: string, selector: (state: S) => K, effectFn: (selected: K) => void): void;
/**
* Set state using Immer-style producer function for immutable updates
* @param producer Producer function that receives draft state
* @param actionName The action label into Redux DevTools
* @returns True if the state is changed
*/
produce(producer: NgSimpleStateProducer<S>, actionName?: string): boolean;
/**
* Destroy a specific effect by name
* @param name Effect name to destroy
*/
destroyEffect(name: string): void;
/**
* Destroy all registered effects
*/
destroyAllEffects(): void;
/**
* Get all registered effect names
* @returns Array of effect names
*/
getEffectNames(): string[];
}Aren't you satisfied? there are some valid alternatives:
This is an open-source project. Star this repository, if you like it, or even donate. Thank you so much!
I have published some other Angular libraries, take a look:
- NgHttpCaching: Cache for HTTP requests in Angular application
- NgGenericPipe: Generic pipe for Angular application for use a component method into component template.
- NgLet: Structural directive for sharing data as local variable into html component template
- NgForTrackByProperty: Angular global trackBy property directive with strict type checking
