README.md
# NgSimpleState [![Build Status](https://app.travis-ci.com/nigrosimone/ng-simple-state.svg?branch=main)](https://app.travis-ci.com/nigrosimone/ng-simple-state) [![Coverage Status](https://coveralls.io/repos/github/nigrosimone/ng-simple-state/badge.svg?branch=main)](https://coveralls.io/github/nigrosimone/ng-simple-state?branch=main) [![NPM version](https://img.shields.io/npm/v/ng-simple-state.svg)](https://www.npmjs.com/package/ng-simple-state) [![Maintainability](https://api.codeclimate.com/v1/badges/1bfc363a95053ecc3429/maintainability)](https://codeclimate.com/github/nigrosimone/ng-simple-state/maintainability)
Simple state management in Angular with only Services and RxJS or Signal.
## Description
Sharing state between components as simple as possible and leverage the good parts of component state and Angular's dependency injection system.
See the demos:
- [Counter](https://stackblitz.com/edit/demo-ng-simple-state?file=src%2Fapp%2Fapp.component.ts)
- [Tour of heroes](https://stackblitz.com/edit/ng-simple-state-tour-of-heroes?file=src%2Fapp%2Fhero.service.ts)
- [To Do List](https://stackblitz.com/edit/ng-simple-state-todo?file=src%2Fapp%2Fapp.component.ts)
## Get Started
### Step 1: install `ng-simple-state`
```bash
npm i ng-simple-state
```
### Step 2: Import `provideNgSimpleState` into your providers
`provideNgSimpleState` 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` |
| *enableLocalStorage* | if `true` latest state of store is saved in local storage and reloaded on store initialization. | `false` |
| *persistentStorage* | Set the persistent storage `local` or `session`. | `local` |
| *comparator* | A function used to compare the previous and current state for equality. | `a === b` |
_Side note: each store can be override the global configuration implementing `storeConfig()` method (see "Override global config")._
```ts
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(),
enableLocalStorage: true,
persistentStorage: 'local'
})
]
});
```
### Step 3: Chose your store
There are two type of store `NgSimpleStateBaseRxjsStore` based on RxJS `BehaviorSubject` and `NgSimpleStateBaseSignalStore` based on Angular `Signal`:
- [RxJS Store](#rxjs-store)
- [Signal Store](#signal-store)
## RxJS Store
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.
1) Define your state interface, eg.:
```ts
export interface CounterState {
count: number;
}
```
2) Define your store service by extending `NgSimpleStateBaseRxjsStore`, eg.:
```ts
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseRxjsStore } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseRxjsStore<CounterState> {
}
```
3) Implement `initialState()` and `storeConfig()` methods and provide the initial state of the store, eg.:
```ts
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 {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
}
```
4) Implement one or more selectors of the partial state you want, in this example `selectCount()` eg.:
```ts
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 {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
selectCount(): Observable<number> {
return this.selectState(state => state.count);
}
}
```
5) Implement one or more actions for change the store state, in this example `increment()` and `decrement()` eg.:
```ts
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 {
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 }));
}
}
```
#### Step 3: Inject your store into the providers, eg.:
```ts
import { Component } from '@angular/core';
import { CounterStore } from './counter-store';
@Component({
selector: 'app-root',
imports: [CounterStore]
})
export class AppComponent {
}
```
#### Step 4: Use your store into the components, eg.:
```ts
import { Component } 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 counter$: Observable<number>;
constructor(public counterStore: CounterStore) {
this.counter$ = this.counterStore.selectCount();
}
}
```
#### That's all!
![alt text](https://github.com/nigrosimone/ng-simple-state/blob/main/projects/ng-simple-state-demo/src/assets/dev-tool.gif?raw=true)
### Manage component state without service
If you want manage just a component state without make a new service, your component can extend directly `NgSimpleStateBaseRxjsStore`:
```ts
import { Component } from '@angular/core';
import { NgSimpleStateBaseRxjsStore } from 'ng-simple-state';
import { Observable } from 'rxjs';
export interface CounterState {
count: number;
}
@Component({
selector: 'ng-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 {
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 }));
}
}
```
### Override global config
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.:
```ts
import { Injectable } from '@angular/core';
import { NgSimpleStateStoreConfig } from 'ng-simple-state';
@Injectable()
export class CounterStore extends NgSimpleStateBaseRxjsStore<CounterState> {
override storeConfig(): NgSimpleStateStoreConfig {
return {
enableLocalStorage: true, // enable local storage for this store
persistentStorage: 'session', // persistentStorage can be 'session' or 'local' (default is localStorage)
storeName: 'CounterStore2', // For default the store name is the class name, you can 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` |
| *enableLocalStorage* | if `true` latest state of store is saved in local storage and reloaded on store initialization. | `false` |
| *storeName* | The name used into `Redux DevTools` and local storage key. | Class name |
| *persistentStorage* | Set the persistent storage `local` or `session` | `local` |
| *comparator* | A function used to compare the previous and current state for equality. | `a === b` |
### Testing
`ng-simple-state` is simple to test. Eg.:
```ts
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,
enableLocalStorage: 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();
});
});
});
```
### Example: array store
This is an example for a todo list store in a `src/app/todo-store.ts` file.
```ts
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 {
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:
```ts
import { Component } 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>
<li *ngFor="let todo of todoList$ | async">
<ng-container *ngIf="todo.completed">✅</ng-container>
{{ 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 todoList$: Observable<Todo[]>;
constructor(public todoStore: TodoStore) {
this.todoList$ = this.todoStore.selectState();
}
}
```
### NgSimpleStateBaseRxjsStore API
```ts
@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(): BehaviorSubject<S>;
/**
* When you override this method, you have to call the `super.ngOnDestroy()` method in your `ngOnDestroy()` method.
*/
ngOnDestroy(): void;
/**
* Reset store to first loaded store state:
* - the last saved state, if `enableLocalStorage` config is `true`
* - 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;
/**
* 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, if `enableLocalStorage` config is `true`;
* otherwise the initial state provided from `initialState()` method.
* @returns The first state
*/
getFirstState(): Readonly<S> | null;
/**
* 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: (currentState: Readonly<S>) => Partial<S>, actionName?: string): boolean;
}
```
## Signal Store
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.
1) Define your state interface, eg.:
```ts
export interface CounterState {
count: number;
}
```
2) Define your store service by extending `NgSimpleStateBaseSignalStore`, eg.:
```ts
import { Injectable } from '@angular/core';
import { NgSimpleStateBaseSignalStore } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Injectable()
export class CounterStore extends NgSimpleStateBaseSignalStore<CounterState> {
}
```
3) Implement `initialState()` and `storeConfig()` methods and provide the initial state of the store, eg.:
```ts
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 {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
}
```
4) Implement one or more selectors of the partial state you want, in this example `selectCount()` eg.:
```ts
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 {
return {
storeName: 'CounterStore'
};
}
initialState(): CounterState {
return {
count: 0
};
}
selectCount(): Signal<number> {
return this.selectState(state => state.count);
}
}
```
5) Implement one or more actions for change the store state, in this example `increment()` and `decrement()` eg.:
```ts
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 {
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 }));
}
}
```
#### Step 3: Inject your store into the providers, eg.:
```ts
import { Component } from '@angular/core';
import { CounterStore } from './counter-store';
@Component({
selector: 'app-root',
imports: [CounterStore]
})
export class AppComponent {
}
```
#### Step 4: Use your store into the components, eg.:
```ts
import { Component, Signal } 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 counterSig: Signal<number>;
constructor(public counterStore: CounterStore) {
this.counterSig = this.counterStore.selectCount();
}
}
```
#### That's all!
![alt text](https://github.com/nigrosimone/ng-simple-state/blob/main/projects/ng-simple-state-demo/src/assets/dev-tool.gif?raw=true)
### Manage component state without service
If you want manage just a component state without make a new service, your component can extend directly `NgSimpleStateBaseSignalStore`:
```ts
import { Component, Signal } from '@angular/core';
import { NgSimpleStateBaseSignalStore } from 'ng-simple-state';
export interface CounterState {
count: number;
}
@Component({
selector: 'ng-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 {
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 }));
}
}
```
### Override global config
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.:
```ts
import { Injectable } from '@angular/core';
import { NgSimpleStateStoreConfig } from 'ng-simple-state';
@Injectable()
export class CounterStore extends NgSimpleStateBaseSignalStore<CounterState> {
override storeConfig(): NgSimpleStateStoreConfig {
return {
enableLocalStorage: true, // enable local storage for this store
persistentStorage: 'session', // persistentStorage can be 'session' or 'local' (default is localStorage)
storeName: 'CounterStore2', // For default the store name is the class name, you can 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` |
| *enableLocalStorage* | if `true` latest state of store is saved in local storage and reloaded on store initialization. | `false` |
| *storeName* | The name used into `Redux DevTools` and local storage key. | Class name |
| *persistentStorage* | Set the persistent storage `local` or `session` | `local` |
| *comparator* | A function used to compare the previous and current state for equality. | `a === b` |
### Testing
`ng-simple-state` is simple to test. Eg.:
```ts
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,
enableLocalStorage: 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);
});
});
```
### Example: array store
This is an example for a todo list store in a `src/app/todo-store.ts` file.
```ts
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 {
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:
```ts
import { Component, Signal } 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>
<li *ngFor="let todo of todoListSig()">
<ng-container *ngIf="todo.completed">✅</ng-container>
{{ 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 todoListSig: Signal<Todo[]>;
constructor(public todoStore: TodoStore) {
this.todoListSig = this.todoStore.selectState();
}
}
```
### NgSimpleStateBaseSignalStore API
```ts
@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>;
/**
* When you override this method, you have to call the `super.ngOnDestroy()` method in your `ngOnDestroy()` method.
*/
ngOnDestroy(): void;
/**
* Reset store to first loaded store state:
* - the last saved state, if `enableLocalStorage` config is `true`
* - 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;
/**
* 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, if `enableLocalStorage` config is `true`;
* otherwise the initial state provided from `initialState()` method.
* @returns The first state
*/
getFirstState(): Readonly<S> | null;
/**
* 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: (currentState: Readonly<S>) => Partial<S>, actionName?: string): boolean;
}
```
## Alternatives
Aren't you satisfied? there are some valid alternatives:
- [@tinystate](https://www.npmjs.com/package/@tinystate/core)
- [@ngxs](https://www.npmjs.com/package/@ngxs/store)
## Support
This is an open-source project. Star this [repository](https://github.com/nigrosimone/ng-simple-state), if you like it, or even [donate](https://www.paypal.com/paypalme/snwp). Thank you so much!
## My other libraries
I have published some other Angular libraries, take a look:
- [NgHttpCaching: Cache for HTTP requests in Angular application](https://www.npmjs.com/package/ng-http-caching)
- [NgGenericPipe: Generic pipe for Angular application for use a component method into component template.](https://www.npmjs.com/package/ng-generic-pipe)
- [NgLet: Structural directive for sharing data as local variable into html component template](https://www.npmjs.com/package/ng-let)
- [NgForTrackByProperty: Angular global trackBy property directive with strict type checking](https://www.npmjs.com/package/ng-for-track-by-property)