ngStato

April 6, 2026 · View on GitHub

ngStato

Stop writing 14 lines of NgRx for a 5-line API call.

State management for Angular — async/await instead of RxJS, ~3 KB instead of ~50 KB.

npm gzip tests Angular license

Start in 5 min · Documentation · API Reference · NgRx Migration


NgRx vs ngStato — same result, different experience

NgRx — 9 concepts, 14 lines:

load: rxMethod<void>(pipe(
  tap(() => patchState(store, { loading: true })),
  switchMap(() => from(service.getAll()).pipe(
    tapResponse({
      next:  (users) => patchState(store, { users, loading: false }),
      error: (e)     => patchState(store, { error: e.message, loading: false })
    })
  ))
))

ngStato — 1 concept, 5 lines:

async load(state) {
  state.loading = true
  state.users   = await http.get('/users')
  state.loading = false
}

Same behavior. Same Signals. Same Angular DI. 87% less code.


Get started in 60 seconds

npm install @ngstato/core @ngstato/angular
// app.config.ts
import { provideStato } from '@ngstato/angular'
import { isDevMode }    from '@angular/core'

export const appConfig = {
  providers: [
    provideStato({
      http: { baseUrl: 'https://api.example.com' },
      devtools: isDevMode()
    })
  ]
}
// users.store.ts
import { createStore, http, connectDevTools } from '@ngstato/core'
import { StatoStore, injectStore }            from '@ngstato/angular'

export const UsersStore = StatoStore(() => {
  const store = createStore({
    users:   [] as User[],
    loading: false,
    error:   null as string | null,

    selectors: {
      total:      (s) => s.users.length,
      activeUsers: (s) => s.users.filter(u => u.active)
    },

    actions: {
      async loadUsers(state) {
        state.loading = true
        state.error   = null
        try {
          state.users = await http.get('/users')
        } catch (e) {
          state.error = (e as Error).message
          throw e
        } finally {
          state.loading = false
        }
      },

      async createUser(state, payload: Omit<User, 'id'>) {
        const user = await http.post<User>('/users', payload)
        state.users = [...state.users, user]
      },

      async deleteUser(state, id: string) {
        await http.delete(`/users/${id}`)
        state.users = state.users.filter(u => u.id !== id)
      }
    },

    hooks: {
      onInit:  (store) => store.loadUsers(),
      onError: (err, action) => console.error(`[UsersStore] ${action}:`, err.message)
    }
  })

  connectDevTools(store, 'UsersStore')
  return store
})
// users.component.ts — all state properties are Angular Signals
@Component({
  template: `
    @if (store.loading()) {
      <div class="spinner">Loading...</div>
    }

    <h2>Users ({{ store.total() }})</h2>

    @for (user of store.users(); track user.id) {
      <div class="user-card">
        <span>{{ user.name }}</span>
        <button (click)="store.deleteUser(user.id)">Delete</button>
      </div>
    }

    <button (click)="store.loadUsers()">Refresh</button>
  `
})
export class UsersComponent {
  store = injectStore(UsersStore)
}

Done. State is Signals. Actions are functions. No boilerplate.


Why teams switch

NgRx v21ngStato
Bundle~50 KB gzip~3 KB gzip
Concepts for async action9 (rxMethod, pipe, tap, switchMap…)1 (async/await)
Lines for a CRUD store~90~45
RxJS requiredYesNo
DevToolsChrome extension onlyBuilt-in panel, all browsers, mobile
Time-travel✅ via extensionbuilt-in with fork-on-dispatch
Action replayre-execute any action
State export/importVia extensionJSON file for bug reports
Prod safetyManual logOnlyAuto isDevMode()
Entity adaptercreateEntityAdapter + withEntities
Feature compositionsignalStoreFeaturemergeFeatures()
Service injectionwithPropswithProps() + closures
Concurrency controlVia RxJS operators✅ Native helpers
TestingprovideMockStorecreateMockStore()
PersistenceCustom meta-reducerswithPersist() built-in
Schematics CLIng generateng generate @ngstato/schematics:store
ESLint plugin@ngrx/eslint-plugin@ngstato/eslint-plugin

Built-in helpers — no RxJS needed

import { exclusive, retryable, optimistic, abortable, queued } from '@ngstato/core'

actions: {
  submit:  exclusive(async (s) => { ... }),           // ignore while running (exhaustMap)
  search:  abortable(async (s, q, { signal }) => {}), // cancel previous (switchMap)
  load:    retryable(async (s) => { ... }, { attempts: 3 }), // auto-retry
  delete:  optimistic((s, id) => { ... }, async () => { ... }), // instant + rollback
  send:    queued(async (s, msg) => { ... }),          // process in order (concatMap)
}

Plus: debounced · throttled · distinctUntilChanged · forkJoin · race · combineLatest · fromStream · pipeStream + 12 stream operators · createEntityAdapter · withEntities · withPersist · mergeFeatures · withProps · on() inter-store reactions

Full helpers API


DevTools — zero install, built-in time-travel

Built-in panel. Drag, resize, minimize. No Chrome extension. Auto-disabled in production via isDevMode().

import { connectDevTools, devTools } from '@ngstato/core'
connectDevTools(store, 'UsersStore')

// Time-travel programmatically
devTools.undo()                    // step backward
devTools.redo()                    // step forward
devTools.travelTo(logId)           // jump to any action
devTools.replay(logId)             // re-execute an action
devTools.resume()                  // resume live mode

// Export/import for bug reports
const snapshot = devTools.exportSnapshot()
devTools.importSnapshot(snapshot)
<!-- app.component.html -->
<stato-devtools />

Schematics — scaffold in seconds

# Generate a full CRUD store with tests
ng generate @ngstato/schematics:store users --crud --entity

# Generate a reusable feature
ng generate @ngstato/schematics:feature loading
Example generated store
// users.store.ts (auto-generated)
import { createStore, http, createEntityAdapter, withEntities, connectDevTools } from '@ngstato/core'
import { StatoStore } from '@ngstato/angular'

export interface User { id: string; name: string }
const adapter = createEntityAdapter<User>()

function createUserStore() {
  const store = createStore({
    ...withEntities<User>(),
    loading: false,
    error: null as string | null,

    selectors: { total: (s) => s.ids.length },

    actions: {
      async loadUsers(state) { /* ... */ },
      async createUser(state, payload) { /* ... */ },
      async updateUser(state, id, changes) { /* ... */ },
      async deleteUser(state, id) { /* ... */ }
    },

    hooks: {
      onInit: (store) => store.loadUsers(),
      onError: (err, action) => console.error(`[UserStore] ${action}:`, err.message)
    }
  })
  connectDevTools(store, 'UserStore')
  return store
}

export const UserStore = StatoStore(() => createUserStore())

ESLint — catch mistakes at dev time

npm install -D @ngstato/eslint-plugin
// eslint.config.js
import ngstato from '@ngstato/eslint-plugin'
export default [ngstato.configs.recommended]
RuleDefaultDescription
ngstato/no-state-mutation-outside-actionerrorPrevent direct state mutation
ngstato/no-async-without-error-handlingwarnRequire try/catch in async actions
ngstato/require-devtoolswarnSuggest connectDevTools()

Packages

PackageDescriptionSize
@ngstato/coreFramework-agnostic store engine + helpers~3 KB
@ngstato/angularAngular Signals + DI + DevTools~1 KB
@ngstato/testingcreateMockStore() test utilities< 1 KB
@ngstato/schematicsng generate — store & feature scaffoldingCLI
@ngstato/eslint-plugin3 ESLint rules for best practicesCLI

Documentation

📖 becher.github.io/ngStato

Start in 5 minCore concepts
Angular guideArchitecture
Testing guideNgRx migration
CRUD recipeAPI reference
EntitiesBenchmarks

Contributing

git clone https://github.com/becher/ngStato
cd ngStato && pnpm install && pnpm build && pnpm test

License

MIT — Copyright © 2025-2026 ngStato