DEV Community

Cover image for NgRx killed my productivity. So I built a replacement in 3KB.
Henchiri
Henchiri

Posted on

NgRx killed my productivity. So I built a replacement in 3KB.

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 }
  }
})
Enter fullscreen mode Exit fullscreen mode

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 })))
      )
    )
  )
)
Enter fullscreen mode Exit fullscreen mode
// ✅ 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode
// 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)
})
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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) },
  }
})
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
// ngStato AFTER
// 1 file: users.store.ts

// Direct call
await this.store.loadUsers()
// Signal
this.users = this.store.users // Signal<User[]>
Enter fullscreen mode Exit fullscreen mode

📖 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/testingcreateMockStore() for clean tests
  • @ngstato/schematicsng generate @ngstato/schematics:store scaffold
  • @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
Enter fullscreen mode Exit fullscreen mode
// app.config.ts
import { provideNgStato } from '@ngstato/angular'

export const appConfig = {
  providers: [
    provideNgStato({ devtools: true })
  ]
}
Enter fullscreen mode Exit fullscreen mode
// 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())
Enter fullscreen mode Exit fullscreen mode

🚀 Live Demo — Todo app with DevTools, filters, optimistic deletes. Click the 🛠 icon.

📖 Full Documentation

💻 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)