NGXS Persistence API (@ngxs-labs/data)
🚀 See it in action on Stackblitz
NGXS Persistence API is an extension based the Repository Design Pattern that offers a gentle introduction to NGXS by simplifying management of entities or plain data while reducing the amount of explicitness.
The main purpose of this extension is to provide the necessary layer of abstraction for states. Automates the creation of actions, dispatchers, and selectors for each entity type.
Benefits:
- No breaking changes (support NGXS 3.6+)
- Angular-way (service abstraction)
- Immutable state context out-of-the-box
- Persistence state out-of-the-box
- Automatic action naming by service methods
- Improved debugging (
@payload
by arguments) - Automatic type inference for selection
- Support debounce for throttling dispatch
- Easy testable states
app.module.ts
import { NgxsModule } from '@ngxs/store';
import { NgxsDataPluginModule } from '@ngxs-labs/data';
@NgModule({
imports: [
// ..
NgxsModule.forRoot([AppState]),
NgxsDataPluginModule.forRoot()
]
// ..
})
export class AppModule {}
count.state.ts
import { action, NgxsDataRepository, StateRepository } from '@ngxs-labs/data';
import { State } from '@ngxs/store';
// ..
export interface CountModel {
val: number;
}
@StateRepository()
@State({
name: 'count',
defaults: { val: 0 }
})
@Injectable()
export class CountState extends NgxsDataRepository<CountModel> {
public readonly values$ = this.state$.pipe(map((state) => state.val));
@action()
public increment(): void {
this.ctx.setState((state) => ({ val: state.val + 1 }));
}
@action()
public decrement(): void {
this.ctx.setState((state) => ({ val: state.val - 1 }));
}
@action({ async: true, debounce: 300 })
public setValueFromInput(val: string | number): void {
this.ctx.setState({ val: parseFloat(val) || 0 });
}
}
app.component.ts
...
@Component({
selector: 'app',
template: `
<b class="title">Selection:</b>
counter.state$ = {{ counter.state$ | async | json }} <br />
counter.values$ = {{ counter.values$ | async }} <br />
<b class="title">Actions:</b>
<button (click)="counter.increment()">increment</button>
<button (click)="counter.decrement()">decrement</button>
<button (click)="counter.reset()">reset</button>
<b class="title">ngModel:</b>
<input
[ngModel]="counter.values$ | async"
(ngModelChange)="counter.setValueFromInput($event)"
/>
(delay: 300ms)
`
})
export class AppComponent {
constructor(public counter: CountState) {}
}
Need provide logger-plugin
import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin';
@NgModule({
imports: [
// ..
NgxsLoggerPluginModule.forRoot()
]
// ..
})
export class AppModule {}
Here the main description of the functionality of this plugin will be collected.
@StateRepository
- This is a decorator that provides an extension of the functionality of NGXS states, thanks to which
you get access to the internal mechanism of the NGXS.
@StateRepository()
@State({
name: 'app',
defaults: {}
})
@Injectable()
export class AppState extends NgxsDataRepository<AppModel> {}
For correct behavior you always need to inherited from an abstract NgxsDataRepository class. The basic NGXS methods are
defined in the DataRepository<T>
interface:
export type StateValue<T> = T | Immutable<T> | ((state: Immutable<T>) => Immutable<T> | T);
export interface DataRepository<T> {
name: string;
initialState: Immutable<T>;
state$: Observable<Immutable<T>>;
getState(): Immutable<T>;
dispatch(actions: ActionType | ActionType[]): Observable<void>;
patchState(val: Partial<T | Immutable<T>>): void;
setState(stateValue: StateValue<T>): void;
reset(): void;
}
export class MyEntityRepository<T> implements DataRepository<T> {
// ..
public set(..): void { ... }
public add(..): void { ... }
public update(..): void { ... }
public delete(..): void { ... }
public upsert(..): void { ... }
public upsertMany(..): void { ... }
// Also you can override
@action()
public reset(): void {
// my logic
}
}
@StateRepository()
@State({
name: 'myEntityState',
defaults: { ... }
})
@Injectable()
export class MyEntityState extends MyEntityRepository<AppModel> {}
@action
- This decorator emulates the execution of asynchronous or synchronous actions. Actions can either be thought
of as a command which should trigger something to happen.
export class AddTodo {
public static type = '[Add todo]';
constructor(public payload: string) {}
}
@State<string[]>({
name: 'todo',
defaults: []
})
@Injectable()
export class TodoState extends NgxsDataRepository<string[]> {
@Action(AddTodo)
public add({ setState }: StateContext<string[]>, { payload }: AddTodo): void {
setState((state) => state.concat(payload));
}
}
@Component({
selector: 'app',
template: `
<input #inputElement />
<button (click)="addTodo(inputElement.value)">Add todo</button>
`
})
class AppComponent {
constructor(private store: Store) {}
public addTodo(value: string): void {
this.store.dispatch(new AddTodo(value));
}
}
@StateRepository()
@State<string[]>({
name: 'todo',
defaults: []
})
@Injectable()
export class TodoState extends NgxsDataRepository<string[]> {
@action()
public add(todo: string): void {
this.ctx.setState((state) => state.concat(todo));
}
}
@Component({
selector: 'app',
template: `
<input #inputElement />
<button (click)="todo.add(inputElement.value)">Add todo</button>
`
})
class AppComponent {
constructor(private todo: TodoState) {}
}
The method todo.add(payload)
is the same as store.dispatch({ type: '@todo.add', todo: payload })
.
What are the benefits?
- No need to create action classes
- Typing improvements for state context
- Explicit interaction with states
JavaScript defines two overarching groups of data types:
Primitives
: low-level values that are immutable (e.g. strings, numbers, booleans etc.)References
: collections of properties, representing identifiable heap memory, that are mutable (e.g. objects, arrays, Map etc.)
Let’s contrast the behavior of primitives with references. Let’s declare an object with a couple of properties:
const me = {
name: 'James',
age: 29
};
Given that JavaScript objects are mutable, we can change its existing properties and add new ones:
me.name = 'Rob';
me.isTall = true;
console.log(me); // Object { name: "Rob", age: 29, isTall: true };
Unlike primitives, objects can be directly mutated without being replaced by a new reference. We can prove this by sharing a single object across two declarations:
const me = {
name: 'James',
age: 29
};
const rob = me;
rob.name = 'Rob';
console.log(me); // { name: 'Rob', age: 29 }
If you want to freeze an entire object hierarchy then this requires a traversal of the object tree and for the Object.freeze operation to be applied to all objects. This is commonly known as deep freezing. When the developmentMode option is enabled in the NGXS forRoot module configuration a deep freeze operation is applied to any new objects in the state tree to help prevent side effects. Since this imposes some performance costs it is only enabled in development mode.
Immutable
- There are no built-in utility types to handle deep immutability, but given that TypeScript introduces
better support for recursive types by deferring their resolution, we can now express an infinitely recursive type to
denote properties as readonly across the entire depth of an object.
@StateRepository()
@State<string[]>({
name: 'todo',
defaults: []
})
@Injectable()
export class TodoState extends NgxsDataRepository<string[]> {
reversed$ = this.state$.pipe(
map( state => state.reverse() )
); ^
|
|_____ TS Compile error: property 'reverse' does not exist on type
}
Thus, the developer will not be able to make his own mistake. If he will mutate the state directly or use mutational methods. If you need to use states for set input property:
import { Immutable } from '@ngxs-labs/data';
@Component({ .. })
class TodoComponent {
@Input() public data: Immutable<string[]>;
}
@Component({
selector: 'app',
template: '<todo [data]="todos.state$ | async"></todo>'
})
class AppComponent {
constructor(public todos: TodosState) {}
}
However, if you really need to cast to mutable, you can do this in several ways:
Into state
:
@StateRepository()
@State<string[]>({
name: 'todo',
defaults: []
})
@Injectable()
export class TodoState extends NgxsDataRepository<string[]> {
mutableState$ = this.state$.pipe(
map( state => state as string[] )
);
or Into template
without creating mutableState$
:
import { NgxsDataUtilsModule } from '@ngxs-labs/data/utils';
@NgModule({
imports: [
// ..
NgxsDataUtilsModule
]
})
export class AppModuleOrMyLazyModule {}
@Component({ .. })
class TodoComponent {
@Input() public data: string[];
}
@Component({
selector: 'app',
template: '<todo [data]="todos.state$ | async | mutable"></todo>'
})
class AppComponent {
constructor(public todos: TodosState) {}
}
@Persistence()
@StateRepository()
@State<string[]>({
name: 'todo',
defaults: []
})
@Injectable()
export class TodoState extends NgxsDataRepository<string[]> {
// ..
}
@Persistence()
- If you add current decorator without options, then the todo
state will synchronize with
LocalStorage by default.
In more complex cases, when you need to use other storage, or you want to save part of the state, you can use the complex options:
export interface ParentCountModel {
val: number;
deepCount?: CountModel;
}
export interface CountModel {
val: number;
}
const options: PersistenceProvider[] = [
{
path: 'count.deepCount.val', // path to slice
existingEngine: sessionStorage, // storage instance
prefixKey: '@mycompany.store.', // custom prefix
ttl: 60 * 60 * 24 * 1000 // 24 hour for time to live
}
];
@Persistence(options)
@StateRepository()
@State<CountModel>({
name: 'deepCount',
defaults: { val: 100 }
})
@Injectable()
export class DeepCountState {}
@StateRepository()
@State<ParentCountModel>({
name: 'count',
defaults: { val: 0 },
children: [DeepCountState]
})
@Injectable()
export class CountState extends NgxsDataRepository<CountModel> {}
interface CommonPersistenceProvider {
/**
* Path for slice
* default: state.name
*/
path: string;
/**
* Version for next migrate
* default: 1
*/
version?: number;
/**
* Time to live in ms
* default: -1
*/
ttl?: number;
/**
* decode/encoded
*/
decode?: 'base64' | 'none';
/**
* prefix for key
* default: '@ngxs.store.'
*/
prefixKey?: string;
/**
* sync with null value from storage
* default: false
*/
nullable?: boolean;
}
interface ExistingEngineProvider extends CommonPersistenceProvider {
/**
* Storage container
* default: window.localStorage
*/
existingEngine: DataStorageEngine;
}
interface UseClassEngineProvider extends CommonPersistenceProvider {
/**
* Storage class from DI
*/
useClass: Type<unknown>;
}
@Persistance([{ path: 'secureState', useClass: SecureStorageService }])
@StateRepository()
@State<SecureModel>({
name: 'secureState',
defaults: {
login: null,
credential: null,
password: null
}
})
@Injectable()
export class SecureState extends NgxsDataRepository<SecureModel> {}
@Injectable({ provideIn: 'root' })
export class SecureStorageService implements DataStorageEngine {
constructor(@Inject(SECURE_SALT) public salt: string, private secureMd5: SecureMd5Service) {}
public getItem(key: string): string | null {
const value: string = sessionStorage.getItem(key) || null;
if (value) {
return this.secureMd5.decode(this.salt, value);
}
return null;
}
public setItem(key: string, value: string): void {
const secureData: string = this.secureMd5.encode(this.salt, value);
sessionStorage.setItem(key, secureData);
}
// ...
}