It was a Monday morning.
I had to add a simple "mark as read" feature to a notification dropdown.
NgRx task list:
- Create
notification.actions.ts - Update
notification.reducer.ts - Create a new selector in
notification.selectors.ts - Wire up an effect in
notification.effects.ts - Update the barrel
index.ts - Register in the feature module
- Add
StoreModule.forFeature(...) - Write tests for actions, reducer, effects, and selectors separately
I closed my laptop and went for a walk.
That afternoon, I started building ngStato.
What if state management was just... JavaScript?
No reducers. No dispatch. No action creators. No pipe(switchMap(concatMap(catchError()))).
Just a JavaScript object that holds your state, with functions that change it.
// The whole counter. Yes, the whole thing.
import { createStore } from '@ngstato/core'
const counterStore = createStore({
count: 0,
computed: {
doubled: (s) => s.count * 2
},
actions: {
increment(state) { state.count++ },
decrement(state) { state.count-- },
reset(state) { state.count = 0 }
}
})
That's it. One file. 15 lines. The NgRx version is 4 files and ~60 lines before you write a single selector.
The "but what about..." wall
Every time I show this to someone, they hit the same wall of questions. Let me demolish each one.
"But what about async operations?"
// ❌ NgRx — you need this ritual every time
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(loadUsers),
switchMap(() =>
this.userService.getAll().pipe(
map(users => loadUsersSuccess({ users })),
catchError(err => of(loadUsersFailure({ error: err.message })))
)
)
)
)
// ✅ ngStato — it's just JavaScript
actions: {
async loadUsers(state) {
state.loading = true
state.error = null
try {
state.users = await http.get('/users')
} catch (e) {
state.error = e.message
} finally {
state.loading = false
}
}
}
If you can write a try/catch, you know ngStato.
"But what about concurrency? (switchMap, concatMap...)"
Real talk: RxJS operators are not the only way to control concurrency. They're just the NgRx way.
ngStato ships concurrency primitives that read like English:
import { exclusive, queued, retryable, optimistic } from '@ngstato/core'
actions: {
// ⚡ Like switchMap — cancels the previous call
search: exclusive(async (state, query: string) => {
state.results = await http.get(`/search?q=${query}`)
}),
// 📋 Like concatMap — queues every call
saveNote: queued(async (state, note: Note) => {
await http.post('/notes', note)
state.notes.push(note)
}),
// 🔁 Auto-retry with exponential backoff
syncData: retryable(async (state) => {
state.data = await http.get('/sync')
}, { attempts: 3, delay: 1000, backoff: 'exponential' }),
// ✨ Optimistic UI + automatic rollback on error
deleteItem: optimistic(
(state, id: number) => {
state.items = state.items.filter(i => i.id !== id) // runs instantly
},
async (_, id) => {
await http.delete(`/items/${id}`) // server call, rollback if it fails
}
)
}
Optimistic updates with rollback in one call. NgRx requires multiple actions, a custom reducer, and an effect for this. We do it in 5 lines.
"But what about selectors / memoization?"
const store = createStore({
todos: [] as Todo[],
filter: 'all' as 'all' | 'active' | 'done',
// These are automatically memoized — recompute only when deps change
computed: {
activeTodos: (s) => s.todos.filter(t => !t.done),
completedTodos: (s) => s.todos.filter(t => t.done),
progress: (s) => s.todos.length
? Math.round((s.todos.filter(t => t.done).length / s.todos.length) * 100)
: 0,
filteredTodos: (s) => {
if (s.filter === 'active') return s.todos.filter(t => !t.done)
if (s.filter === 'done') return s.todos.filter(t => t.done)
return s.todos
}
}
})
No createSelector. No createFeatureSelector. No props<>(). Just functions.
"But what about testing?"
// NgRx — setup ceremony before every test
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot(reducers),
EffectsModule.forRoot([UserEffects])
],
providers: [
provideMockStore({ initialState }),
provideMockActions(() => actions$),
]
})
store = TestBed.inject(Store)
actions$ = TestBed.inject(Actions)
})
// ngStato — one line
const store = createMockStore(UsersStore, {
users: [{ id: 1, name: 'Alice' }],
loading: false,
})
// Then just test actions and state
it('should mark user as admin', async () => {
await store.makeAdmin(1)
expect(store.users()[0].isAdmin).toBe(true)
})
No TestBed. No Actions. No ceremony. Just test the behavior.
The feature that made people stop scrolling
I showed this at a local Angular meetup and there was a visible "wait, what?" moment.
Time-travel DevTools. Built directly into your app.
<!-- Add to your app.component.html -->
<stato-devtools />
That's the entire setup. No Chrome extension. No browser-specific tool. No Redux remote bridge.
You get a floating panel with:
- ⏮️ Undo any action — step backwards through your entire state history
- ⏭️ Redo — come back forward
- 🎯 Jump to any moment — click any past action to restore that exact state
- 🔄 Replay — re-run an action with its original args
- 📸 Export state snapshots — as JSON, importable later
- 🐛 Reproduce bugs — export the state from a bug report, import it in dev
Entity adapter — same DX as @ngrx/entity
If you're using NgRx entities, the migration is nearly mechanical:
import { createEntityAdapter } from '@ngstato/core'
type User = { id: number; name: string; email: string }
const adapter = createEntityAdapter<User>({ selectId: u => u.id })
const usersStore = createStore({
...adapter.getInitialState(), // { ids: [], entities: {} }
loading: false,
computed: {
all: (s) => adapter.selectAll(s),
total: (s) => adapter.selectTotal(s),
byId: (s) => (id: number) => adapter.selectById(s, id),
},
actions: {
async loadAll(state) {
state.loading = true
adapter.setAll(state, await http.get('/users'))
state.loading = false
},
add(state, user: User) { adapter.addOne(state, user) },
update(state, id: number, changes: Partial<User>) {
adapter.updateOne(state, { id, changes })
},
remove(state, id: number) { adapter.removeOne(state, id) },
}
})
Angular Signals — zero plumbing
@Component({
template: `
<div *ngIf="store.loading()">Loading...</div>
<ul>
@for (user of store.all(); track user.id) {
<li>{{ user.name }} ({{ store.byId()(user.id)?.email }})</li>
}
</ul>
<p>Total: {{ store.total() }}</p>
`
})
export class UsersComponent {
store = injectStore(UsersStore)
// Everything is a Signal — no subscribe, no async pipe, no destroy$
ngOnInit() {
this.store.loadAll() // fire and forget
}
}
Honest comparison
| NgRx | ngStato | |
|---|---|---|
| Bundle size | 37 KB | 3 KB |
| Files per feature | 4 | 1 |
| Counter boilerplate | ~60 lines | 15 lines |
| Async pattern | RxJS operators | async/await |
| Learning curve | Weeks | Hours |
| DevTools | Chrome extension | Built-in |
| Time-travel | ❌ | ✅ |
| Optimistic updates | ~40 lines | 5 lines |
| Entity adapter | @ngrx/entity | Built-in |
| Schematics | ✅ | ✅ |
| ESLint plugin | ✅ | ✅ |
| Testing helpers | ✅ | ✅ |
"Should I migrate?"
Honest answer: it depends.
Migrate if:
- Your team spends more time writing NgRx boilerplate than features
- New devs struggle with the RxJS learning wall
- You want DevTools without managing a Chrome extension
- You're on a new project or feature
Stay with NgRx if:
- Your team is deep in NgRx and it's working
- You rely heavily on custom RxJS operators in effects
- You have compliance/audit requirements around proven enterprise tools
ngStato is designed to coexist with NgRx — you can migrate one feature at a time.
Migration cheat sheet
// NgRx BEFORE
// 4 files: actions + reducer + effects + selectors
// @ngrx/store dispatch
this.store.dispatch(loadUsers())
// select
this.users$ = this.store.select(selectAllUsers)
// ngStato AFTER
// 1 file: users.store.ts
// Direct call
await this.store.loadUsers()
// Signal
this.users = this.store.users // Signal<User[]>
📖 Full migration guide: becher.github.io/ngStato/migration
The full ecosystem
ngStato isn't just a store. It ships with:
-
@ngstato/core— the engine (3KB) -
@ngstato/angular— Angular adapter with Signals -
@ngstato/testing—createMockStore()for clean tests -
@ngstato/schematics—ng generate @ngstato/schematics:storescaffold -
@ngstato/eslint-plugin— 3 rules: no mutations outside actions, require DevTools in dev, no async without error handling
Get started in 2 minutes
npm install @ngstato/core @ngstato/angular
// app.config.ts
import { provideNgStato } from '@ngstato/angular'
export const appConfig = {
providers: [
provideNgStato({ devtools: true })
]
}
// feature.store.ts
import { createStore } from '@ngstato/core'
import { StatoStore, connectDevTools } from '@ngstato/angular'
import { isDevMode } from '@angular/core'
function createFeatureStore() {
const store = createStore({
items: [] as Item[],
loading: false,
actions: {
async load(state) {
state.loading = true
state.items = await http.get('/items')
state.loading = false
}
}
})
if (isDevMode()) connectDevTools(store, 'FeatureStore')
return store
}
export const FeatureStore = StatoStore(() => createFeatureStore())
🚀 Live Demo — Todo app with DevTools, filters, optimistic deletes. Click the 🛠 icon.
💻 GitHub — 156 tests, 100% coverage, MIT license
If you've ever rage-closed your laptop because of NgRx boilerplate — this one's for you.
Drop a comment: What's the most painful part of NgRx for you? 👇
⭐ Star ngStato on GitHub — if it saves you from writing another actions file
Top comments (0)