{"slug": "announcing-ngrx-traits-22", "title": "Announcing NgRx Traits 22", "summary": "The NgRx team released NgRx Traits version 22, compatible with Angular 22 and NgRx Signals 22, headlined by a new `withLink` feature that connects a store to component signals, models, inputs, outputs and Angular Signal Forms without hand-written sync logic. `withLink` generates a `link[StateProp]()` method returning a `linkedSignal` that reads from and writes to the store, with options including `set` to route writes through a store method, `storeEditsWhen` to buffer edits until validation passes, and `syncWith` with `readMap`/`writeMap` for two-way model synchronization. The release also adds an Angular Resource view of calls, new state setter features, shorter `withEntities*` calls, a refreshed docs site, and an AI agent skill that teaches coding agents to use the library.", "body_md": "Hey everyone! We're excited to announce NgRx Traits version 22, fully compatible with Angular 22 and NgRx Signals 22! Along with the version bump, this release brings the biggest new feature the library has had in a while: `withLink`, a way to connect your store to component signals, models, inputs, outputs and Angular Signal Forms, without writing sync logic by hand.\n\nThere is also an Angular Resource view of your calls, new state setter features, a shorter way to call the `withEntities*` features, a refreshed docs site, and an AI agent skill that teaches coding agents how to use the library.\n\nWhen a store has to work with a form, or with a parent component's model(), input() or output(), you usually end up writing glue code: a linkedSignal to follow the store, sometimes effects to copy changes back and forth, and guards so the two sides don't keep triggering each other in an echo loop. withLink generates that for you.\n\nGiven a state prop, `withLink` generates a `link[StateProp]()` method that returns a `linkedSignal`: reading it reads the store prop, and setting it writes to the store.\n\n``` js\nconst Store = signalStore(\n  withState({ filter: { search: '' } }),\n  // 👇 generates linkFilter()\n  withLink('filter'),\n);\n\n// In component\nstore = inject(Store);\nfilter = this.store.linkFilter();\n\n// filter() => { search: '' }\nfilter.set({ search: 'shoes' });\n// store.filter() => { search: 'shoes' }\n```\n\nBy default writes go through `patchState`, but you can route them through a store method instead with `set`:\n\n``` js\nconst ProductsStore = signalStore(\n  withEntities(productEntityConfig),\n  withEntitiesLocalFilter(productEntityConfig, {\n    defaultFilter: { search: '' },\n    filterFn: (entity, filter) =>\n      !filter?.search ||\n      entity.name.toLowerCase().includes(filter.search.toLowerCase()),\n  }),\n  // 👇 generates linkProductEntitiesFilter()\n  withLink('productEntitiesFilter', {\n    set: (value, store) =>\n      store.filterProductEntities({ filter: value, debounce: 0 }),\n  }),\n);\n```\n\nBecause the returned value is just a `WritableSignal`, it can be handed straight to Signal Forms:\n\n```\nexport class ProductListComponent {\n  store = inject(ProductsStore);\n\n  // typing in the form filters the entities,\n  // resetting the filter in the store updates the form\n  filterForm = form(this.store.linkProductEntitiesFilter(), (value) => {\n    required(value.search);\n  });\n}\n```\n\nThat form writes to the store on every keystroke, valid or not. If you only want validated data to land in the store, pass `storeEditsWhen`: the returned signal becomes a buffer, and edits are held back until the gate opens, then pushed automatically.\n\n```\nexport class ProductListComponent {\n  store = inject(ProductsStore);\n\n  // buffers the form value, only valid data reaches the store.\n  // the `: boolean` annotation is needed because filterForm is declared below,\n  // without it typescript reports a circular inference\n  formData = this.store.linkProductEntitiesFilter({\n    storeEditsWhen: (): boolean => this.filterForm().valid(),\n  });\n\n  filterForm = form(this.formData, (value) => {\n    required(value.search);\n  });\n}\n```\n\nIf you need to keep the store in sync with a component's `model()`, pass it as `syncWith`:\n\n```\nexport class ProductSearchComponent {\n  store = inject(ProductsStore);\n\n  // when the parent writes the model, the store updates;\n  // when the store changes, the model (and the parent) updates\n  filter = model<{ search: string }>({ search: '' });\n  linked = this.store.linkProductEntitiesFilter({ syncWith: this.filter });\n\nfilterForm = form(this.linked);\n}\n```\n\nIf the model's type doesn't match the store's, `readMap` and `writeMap` convert between the two:\n\n```\nexport class ProductSearchComponent {\n  store = inject(ProductsStore);\n\n  // the parent works with a plain string, the store filter is { search: string }\n  search = model<string>('');\n  linked = this.store.linkProductEntitiesFilter({\n    syncWith: this.search,\n    readMap: (search) => ({ search }),\n    writeMap: (filter) => filter.search,\n  });\n}\n```\n\nAnd if the component uses an `input()` and an `output()` instead of a model, `readFrom` and `writeTo` handle each direction separately — or use just one of them for a one-way sync:\n\n```\nexport class ProductSearchComponent {\n  store = inject(ProductsStore);\n\n  filter = input<{ search: string }>({ search: '' });\n  filterChange = output<{ search: string }>();\n\n  // input changes update the store, store changes are emitted on the output\n  linked = this.store.linkProductEntitiesFilter({\n    readFrom: this.filter,\n    writeTo: this.filterChange,\n  });\n}\n```\n\nBoth sync directions are guarded by `equal`: a write equal to what the store already holds is skipped, and a store value equal to what the external signal already has is not pushed out. That is what stops a two-way link from echoing forever.\n\nBy default it compares by content, picking the comparison from the value at hand:\n\n`Object.is`.` JSON.stringify`.` Date`, `Map`, `Set` or class instance) falls back to When the default isn't what you want, pass your own `(a, b) => boolean`, or the name of a premade comparison: `'reference'`, `'array'`, `'set'` (same elements in any order), `'stringify'`, or a property to compare by, like `'id'` on an object and `'array.id'` / `'set.id'` on an array of them. All of them are type-checked, and the property names autocomplete from the linked value:\n\n``` js\nconst Store = signalStore(\n  withState({\n    ids: [] as string[],\n    selectedProduct: undefined as Product | undefined,\n    products: [] as Product[],\n  }),\n  // a selection is a set, order doesn't matter\n  withLink('ids', { equal: 'set' }),\n  // only changed when the id changes, whatever else the product carries\n  withLink('selectedProduct', { equal: 'id' }),\n  // an array whose elements are rebuilt on every read is the one shape\n  // the default can't settle, so compare it by the ids it holds\n  withLink('products', { equal: 'array.id' }),\n);\n```\n\n`withLink` also generates a private `_set<Name>()` method that writes through the same path, so other methods inside the store can reuse it.\n\nThere are more use cases in the docs. To learn more, check the [withLink docs here](https://ngrx-traits.dev/docs/traits/with-link).\n\n`withLink` is marked experimental: it's ready to use, but the API may still change in response to feedback, so please [open an issue](https://github.com/gabrielguerrero/ngrx-traits/issues) if something feels awkward.\n\nIf you use the `withEntities*` store features, you don't need to wire `withLink` yourself. There are four prebuilt versions that write through the entity traits' own methods, and pick the right equality for each case (for example, multi selection compares ids as a set, so order doesn't matter):\n\n``` js\nexport const ProductsStore = signalStore(\n  { providedIn: 'root' },\n  withEntities(productEntityConfig),\n  withEntitiesLocalFilter(productEntityConfig, {\n    defaultFilter: { search: '' },\n    filterFn: (entity, filter) =>\n      !filter?.search ||\n      entity.name.toLowerCase().includes(filter.search.toLowerCase()),\n  }),\n  // 👇 generates linkProductEntitiesFilter()\n  withLinkEntitiesFilter(productEntityConfig),\n);\n```\n\nFor each call that stores its result, `withCalls` now also generates a resource method: a factory of a read-only view of the call with the shape of Angular's `Resource` — `value`, `status`, `error`, `isLoading`, `snapshot`, `hasValue()` and `destroy()` — for components that prefer the resource API. `withEntitiesLoadingCall` generates one too, as `<collection>EntitiesResource()`.\n\nEvery signal in the view reads the store, nothing is copied, so all views of the same call agree with each other and with the generated signals.\n\n``` js\nconst ProductsStore = signalStore(\n  withCalls(() => ({\n    loadProductDetail: callConfig({\n      call: ({ id }: { id: string }) =>\n        inject(ProductService).getProductDetail(id),\n      resultProp: 'productDetail',\n      mapError: (error) => error as HttpErrorResponse,\n    }),\n  })),\n);\n\n// In component\nstore = inject(ProductsStore);\n// typed as CallResource<ProductDetail | undefined, HttpErrorResponse>\ndetail = this.store.productDetailResource();\n@if (detail.isLoading()) {\n  <mat-spinner />\n} @else if (detail.hasValue()) {\n  <!-- hasValue() narrows value() to ProductDetail -->\n  <product-detail [product]=\"detail.value()\" />\n} @else if (detail.status() === 'error') {\n  {{ detail.error()?.message }}\n}\n```\n\nThe view can also drive the call. Pass `params` a signal, a function or an observable, and the call runs every time it emits, with `undefined` skipping the call, so it can be driven by an input that isn't set yet:\n\n```\nproductId = input.required<string>();\ndetail = this.store.productDetailResource({\n  params: () => ({ id: this.productId() }),\n});\n```\n\nThis one is experimental too — [check the docs here](https://ngrx-traits.dev/docs/traits/with-calls).\n\nTwo small traits that can help reduce boilerplate in your store. [`withStateSetter`](https://ngrx-traits.dev/docs/traits/with-state-setter) generates a `set<Prop>()` for each state prop you name:\n\n``` js\nconst Store = signalStore(\n  withState({ a: { b: '' }, c: 1, d: 12 }),\n  // 👇 generates setA() and setD()\n  withStateSetter('a', 'd'),\n);\n\nstore.setA({ b: 'hello' });\nstore.setD((d) => d + 1); // updater fn for partial updates\n```\n\nThey are `signalMethods`, so they also accept a signal or a reactive function and keep the state in sync with it. [`withStatePrivateSetter`](https://ngrx-traits.dev/docs/traits/with-state-private-setter) does the same but generates `_set<Prop>()`, for props that should be publicly readable but only written from inside the store.\n\nUntil now every `withEntities*` feature took a single argument, so whenever you needed to pass it some options you had to spread the entity config in alongside them:\n\n```\nwithCallStatus({ ...productEntityConfig, initialValue: 'loading' }),\nwithEntitiesRemotePagination({ ...productEntityConfig, pageSize: 10 }),\n```\n\nNow the entity config has its own parameter and the options go in the second one. It's easier to write, and makes the code cleaner and easier to read.\n\n``` js\nconst productEntityConfig = entityConfig({\n  entity: type<Product>(),\n  collection: 'product',\n});\n\nconst ProductsStore = signalStore(\n  withEntities(productEntityConfig),\n  withCallStatus(productEntityConfig, { initialValue: 'loading' }),\n  withEntitiesRemoteFilter(productEntityConfig, {\n    defaultFilter: { search: '' },\n  }),\n  withEntitiesRemotePagination(productEntityConfig, { pageSize: 10 }),\n  withEntitiesRemoteSort(productEntityConfig, {\n    defaultSort: { field: 'name', direction: 'asc' },\n  }),\n  withEntitiesLoadingCall(\n    productEntityConfig,\n    ({ productEntitiesFilter }) => ({\n      fetchEntities: () =>\n        inject(ProductService).getProducts(productEntitiesFilter()),\n    }),\n  ),\n);\n```\n\nThe single-object form still works, so nothing breaks. If you want to switch, there's an optional migration that rewrites your code to the new form. Interactive `ng update` pre-selects it (it's skipped in CI), or you can run it on its own:\n\n```\nng update @ngrx-traits/signals --name update-22-0-0\n```\n\nTo learn more, check the [migration guide](https://ngrx-traits.dev/docs/getting-started/migrating-to-v22).\n\n**Readable errors for missing features.** Forgetting a required trait used to surface as a structural mismatch ending in something like `Property 'productEntitiesCallStatus' is missing`, which never told you what to add. Now TypeScript reports `Missing store feature: withEntitiesLoadingCall requires withCallStatus({ collection: 'product' }) to be present in the store before it`, across the 17 features that have requirements.\n\n**`getFilterQueryMapper` and `getQueryMapperForState`.** Syncing state to route query params no longer needs a hand-written mapper for the common cases. Declare the types of your fields and you get one param per field, typed and autocompleted, instead of a JSON blob in the url:\n\n```\nwithEntitiesSyncToRouteQueryParams(productEntityConfig, {\n  prefix: 'p',\n  // 👇 p-search=tv&p-range.from=2026-08-11&p-range.to=2026-08-31\n  filterMapper: getFilterQueryMapper<ProductFilter>({\n    search: 'string',\n    range: { from: 'date', to: 'date' },\n  }),\n});\n```\n\n`filterState` accepts an array.`withSyncToWebStorage` and `withServerStateTransfer` now take a list of state prop names as well as a function: `filterState: ['productEntityMap', 'productIds']` for easier use.\n\n`withLogger` takes the name first.`withLogger('MyStore', { filter: ['idsSelected'] })`, and the filter now supports sub-signals.\n\nThe docs got a light theme with a toggle in the navbar, an \"on this page\" menu for navigating long pages, and a rebuilt home page. Have a look at [the new docs](https://ngrx-traits.dev/).\n\nNgRx Traits now ships an [Agent Skill](https://code.claude.com/docs/en/skills) that teaches coding agents how to use the library: which store feature solves which problem, the order the features have to be composed in, and the exact names of the signals and methods each one generates. Without it, agents tend to invent names that look right (`productsLoading()`, `setFilter()`) or put features in an order that compiles but never fetches.\n\nThe skill is released together with the library, so it always matches the current API. You can install it with the [skills CLI](https://skills.sh/):\n\n```\nnpx skills add gabrielguerrero/ngrx-traits\n```\n\nAfter that there's nothing to call: the agent loads it on its own whenever you mention NgRx Traits or one of its store features. For other ways to install it, check the [AI Agent Skill docs](https://ngrx-traits.dev/docs/getting-started/ai-agent-skill).\n\nTwo APIs deprecated in v21 are now removed:\n\n`typedCallConfig` — renamed to `callConfig`.` withRouteParams` — replaced by `withRoute`, which also reads query params.\nBoth are straight renames, so the fix is a find and replace. If you're coming from an older version, `withRoute` is covered in the [withRoute docs](https://ngrx-traits.dev/docs/traits/with-route).\n\nMake sure to have the following minimum versions installed:\n\nThen run:\n\n```\nng update @ngrx-traits/signals\n```\n\nOr for a fresh install:\n\n```\nnpm i @ngrx-traits/signals --save\n```\n\nA huge thank you to all the contributors who make this library better every day. Big thanks as well to everyone in the Discord channel — from those who share feature ideas, report bugs, test new features early, or simply jump in to discuss best practices.\n\nAnd of course, a special thanks to the NgRx and Angular teams — without their amazing work, this library wouldn't exist.", "url": "https://wpnews.pro/news/announcing-ngrx-traits-22", "canonical_source": "https://dev.to/gabrielguerrero/announcing-ngrx-traits-22-50i7", "published_at": "2026-10-07 22:10:16+00:00", "updated_at": "2026-10-07 22:17:08.586426+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["NgRx", "NgRx Traits", "Angular", "NgRx Signals", "Angular Signal Forms"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/announcing-ngrx-traits-22", "markdown": "https://wpnews.pro/news/announcing-ngrx-traits-22.md", "text": "https://wpnews.pro/news/announcing-ngrx-traits-22.txt", "jsonld": "https://wpnews.pro/news/announcing-ngrx-traits-22.jsonld"}}