Skip to content

Commit

Permalink
feat(selector-utils): add advanced selector utils (#1824)
Browse files Browse the repository at this point in the history
* feat(selector-utils): Impl. selector utils

* test(selector-utils): Initial unit tests

* refactor(select-utils): Rename createMappedSelector to createModelSelector

* docs: fix selector util code examples

* docs: fix description of pick selector

* docs: add note about performance benefit of pick selector

* docs: add index to sections for selector utils

* refactor(store): rename selector util files

* refactor(store): fix internal type references

* feat(store): export selector utils in public api

* test(store): move selector util spec files

* chore(store): add error handling to createPropertySelectors

* test(store): clean up createPropertySelectors tests

* test: remove the .only usage in test

* test(store): add more createPropertySelectors tests

* test(store): fix issue in createPropertySelectors tests

* test(store): add some createPickSelector tests

* test(store): add more createPickSelector tests

* docs: more docs improvements

* test: fix broken test snapshots

* test(store): add tests for createModelSelector

* refactor: extract checks for selector map

* docs: more docs improvements

* chore: update CHANGELOG.md

* chore: update CHANGELOG.md

* docs(store): fix issue

---------

Co-authored-by: carniatto <[email protected]>
Co-authored-by: markwhitfeld <[email protected]>
  • Loading branch information
3 people authored Mar 9, 2023
1 parent c0b40ba commit 81a817d
Show file tree
Hide file tree
Showing 13 changed files with 982 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### For next major version

- Feature: Build packages in Ivy format [#1945](https://github.com/ngxs/store/pull/1945)
- Feature: Add advanced selector utilities [#1824](https://github.com/ngxs/store/pull/1824)
- Feature: Expose `ActionContext` and `ActionStatus` [#1766](https://github.com/ngxs/store/pull/1766)
- Feature: `ofAction*` methods should have strong types [#1808](https://github.com/ngxs/store/pull/1808)
- Feature: Improve contextual type inference for state operators [#1806](https://github.com/ngxs/store/pull/1806) [#1947](https://github.com/ngxs/store/pull/1947)
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- [Monitoring Unhandled Actions](advanced/monitoring-unhandled-actions.md)
- [Optimizing Selectors](advanced/optimizing-selectors.md)
- [Options](advanced/options.md)
- [Selector Utils](advanced/selector-utils.md)
- [Shared State](advanced/shared-state.md)
- [State Token](advanced/token.md)
- [State Operators](advanced/operators.md)
Expand Down
175 changes: 175 additions & 0 deletions docs/advanced/selector-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# State Selector Utils

## Why?

Selectors are one of the most powerful features in `NGXS`. When used in a correct way they are very performant due to the built-in memoization. However, in order to use selectors correctly we usually need to break down the state into smaller selectors that, in turn, will be used by other selectors. This approach is important to guarantee that selectors are only run when a change of interest has happened.
The process of breaking down your state into simple selectors for each property of the state model can be tedious and usually comes with a lot of boilerplate. The objective the selector utils is to make it easy to generate these selectors, combine selectors from multiple states, and create a selector based on a subset of properties of your state.

These are the provided utils:

- [createPropertySelectors](#create-property-selectors) - create a selector for each property of an object returned by a selector.
- [createModelSelector](#create-model-selector) - create a selector that returns an object which is composed from values returned by multiple selectors.
- [createPickSelector](#create-pick-selector) - create a selector that returns a subset of an object's properties, and changes only when those properties change.

## Create Property Selectors

Let's start with a common example. Here we have a small state containing animals. Check the snippet below:

```ts
import { Injectable } from '@angular/core';
import { State, Action, StateContext } from '@ngxs/store';

export interface AnimalsStateModel {
zebras: string[];
pandas: string[];
monkeys?: string[];
}

@State<AnimalsStateModel>({
name: 'animals',
defaults: {
zebras: [],
pandas: [],
monkeys: [],
},
})
@Injectable()
export class AnimalsState {}

export class AnimalsSelectors {
@Selector(AnimalsState)
static zebras(state: AnimalStateModel) {
return state.zebras;
}

@Selector(AnimalsState)
static pandas(state: AnimalStateModel) {
return state.pandas;
}

@Selector(AnimalsState)
static monkeys(state: AnimalStateModel) {
return state.monkeys;
}
}
```

Here we see how verbose the split of a state into selectors can look. We can use the `createPropertySelectors` to cleanup this code a bit. See the snippet below:

```ts
import { Selector, createPropertySelectors } from '@ngxs/store';

export class AnimalsSelectors {
// creates map of selectors for each state property
static slices = createPropertySelectors<AnimalStateModel>(AnimalSate);

// slices can be used in other selectors
@Selector([AnimalsSelectors.slices.zebras, AnimalsSelectors.slices.pandas])
static countZebrasAndPandas(zebras: string[], pandas: string[]) {
return zebras.length + pandas.length;
}
}

@Component({
selector: 'my-zoo'
template: `
<h1> Zebras </h1>
<ol>
<li *ngFor="zebra in zebras$ | async"> {{ zebra }} </li>
</ol>
`,
style: ''
})
export class MyZooComponent {
// slices can be use directly in the components
zebras$ = this.store.select(AnimalsSelectors.slices.zebras);

constructor(private store: Store) {}
}
```

Here we see how the `createPropertySelectors` is used to create a map of selectors for each property of the state. The `createPropertySelectors` takes a state class and returns a map of selectors for each property of the state. The `createPropertySelectors` is very useful when we need to create a selector for each property of the state.

> **TYPE SAFETY:** Note that, in the `createPropertySelectors` call above, the model type was provided to the function as a type parameter. This was only necessary because the state class (`AnimalSate`) was provided and the class does not include model information. The `createPropertySelectors` function will not require a type parameter if a typed selector or a `StateToken` that includes the type of the model is provided to the function.
## Create Model Selector

Sometimes we need to create a selector simply groups other selectors. For example, we might want to create a selector that maps the state to a map of pandas and zoos. We can use the `createModelSelector` to create such a selector. See the snippet below:

```ts
import { Selector, createModelSelector } from '@ngxs/store';

export class AnimalsSelectors {
static slices = createPropertySelectors<AnimalStateModel>(AnimalSate);

static pandasAndZoos = createModelSelector({
pandas: AnimalsSelectors.slices.pandas,
zoos: ZoosSelectors.slices.zoos
});
}

@Component({
selector: 'my-zoo'
template: `
<h1> Pandas and Zoos </h1>
<ol *ngIf="pandasAndZoos$ | async as model">
<li> Panda Count: {{ model.pandas?.length || 0 }} </li>
<li> Zoos Count: {{ model.zoos?.length || 0 }} </li>
</ol>
`,
style: ''
})
export class MyZooComponent {
pandasAndZoos$ = this.store.select(AnimalsSelectors.pandasAndZoos);

constructor(private store: Store) {}
}
```

Here we see how the `createModelSelector` is used to create a selector that maps the state to a map of pandas and zoos. The `createModelSelector` takes a map of selectors and returns a selector that maps the state to a map of the values returned by the selectors. The `createModelSelector` is very useful when we need to create a selector that groups other selectors.

> **TYPE SAFETY:** Note that it is always best to use typed selectors in the selector map provided to the `createModelSelector` function. The output model is inferred from the selector map. A state class (eg. `AnimalSate`) does not include model information and this causes issues with the type inference. It is also questionable why an entire state would be included in a model, because this breaks encapsulation and would also cause change detection to trigger more often.
## Create Pick Selector

Sometimes we need to create a selector that picks a subset of properties from the state. For example, we might want to create a selector that picks only the `zebras` and `pandas` properties from the state. We can use the `createPickSelector` to create such a selector. See the snippet below:

```ts
import { Selector, createPickSelector } from '@ngxs/store';

export class AnimalsSelectors {
static fullAnimalsState = createSelector([AnimalState], (state: AnimalStateModel) => state);

static zebrasAndPandas = createPickSelector(fullAnimalsState, [
'zebras',
'pandas'
]);
}

@Component({
selector: 'my-zoo'
template: `
<h1> Zebras and Pandas </h1>
<ol *ngIf="zebrasAndPandas$ | async as zebrasAndPandas">
<li> Zerba Count: {{ zebrasAndPandas.zebras?.length || 0 }} </li>
<li> Panda Count: {{ zebrasAndPandas.pandas?.length || 0 }} </li>
</ol>
`,
style: ''
})
export class MyZooComponent {
zebrasAndPandas$ = this.store.select(AnimalsSelectors.zebrasAndPandas);

constructor(private store: Store) {}
}
```

The `zebrasAndPandas` object above would only contain the `zebras` and `pandas` properties, and not have the `monkeys` property.

Here we see how the `createPickSelector` is used to create a selector that picks a subset of properties from the state, or from any other selector that returns an object for that matter. The `createPickSelector` takes a selector which returns an object and an array of property names and returns a selector that returns a copy of the object, with only the properties that have been picked. The `createPickSelector` is very useful when we need to create a selector that picks a subset of properties from the state.

> **TYPE SAFETY:** The `createPickSelector` function should only be provided a strongly typed selector or a `StateToken` that includes the type of the model. This is so that type safety is maintained for the picked properties.
**Noteable Performance win!**

One of the most useful things about the `createPickSelector` selector (versus rolling your own that creates a trimmed object from the provided selector), is that it will only emit a new value when a picked property changes, and will not emit a new value if any of the other properties change. An Angular change detection performance enthusiasts dream!
14 changes: 11 additions & 3 deletions packages/store/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export {
getSelectorMetadata,
getStoreMetadata,
ensureStoreMetadata,
ensureSelectorMetadata
ensureSelectorMetadata,
} from './public_to_deprecate';
export {
ofAction,
Expand All @@ -28,7 +28,7 @@ export {
ofActionCanceled,
ofActionErrored,
ofActionCompleted,
ActionCompletion
ActionCompletion,
} from './operators/of-action';
export {
StateContext,
Expand All @@ -37,7 +37,7 @@ export {
NgxsAfterBootstrap,
NgxsOnChanges,
NgxsModuleOptions,
NgxsSimpleChange
NgxsSimpleChange,
} from './symbols';
export { Selector } from './decorators/selector/selector';
export { getActionTypeFromInstance, actionMatcher } from './utils/utils';
Expand All @@ -50,3 +50,11 @@ export { StateToken } from './state-token/state-token';
export { NgxsDevelopmentOptions } from './dev-features/symbols';
export { NgxsDevelopmentModule } from './dev-features/ngxs-development.module';
export { NgxsUnhandledActionsLogger } from './dev-features/ngxs-unhandled-actions-logger';

export {
createModelSelector,
createPickSelector,
createPropertySelectors,
PropertySelectors,
TypedSelector,
} from './selectors';
53 changes: 53 additions & 0 deletions packages/store/src/selectors/create-model-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createSelector } from '../utils/selector-utils';
import { ensureValidSelector, ensureValueProvided } from './selector-checks.util';
import { TypedSelector } from './selector-types.util';

interface SelectorMap {
[key: string]: TypedSelector<any>;
}

type ModelSelector<T extends SelectorMap> = (...args: any[]) => MappedResult<T>;

type MappedResult<TSelectorMap> = {
[P in keyof TSelectorMap]: TSelectorMap[P] extends TypedSelector<infer R> ? R : never;
};

export function createModelSelector<T extends SelectorMap>(selectorMap: T): ModelSelector<T> {
const selectorKeys = Object.keys(selectorMap);
const selectors = Object.values(selectorMap);
ensureValidSelectorMap<T>({
prefix: '[createModelSelector]',
selectorMap,
selectorKeys,
selectors,
});

return createSelector(selectors, (...args) => {
return selectorKeys.reduce((obj, key, index) => {
(obj as any)[key] = args[index];
return obj;
}, {} as MappedResult<T>);
}) as ModelSelector<T>;
}

function ensureValidSelectorMap<T extends SelectorMap>({
prefix,
selectorMap,
selectorKeys,
selectors,
}: {
prefix: string;
selectorMap: T;
selectorKeys: string[];
selectors: TypedSelector<any>[];
}) {
ensureValueProvided(selectorMap, { prefix, noun: 'selector map' });
ensureValueProvided(typeof selectorMap === 'object', { prefix, noun: 'valid selector map' });
ensureValueProvided(selectorKeys.length, { prefix, noun: 'non-empty selector map' });
selectors.forEach((selector, index) =>
ensureValidSelector(selector, {
prefix,
noun: `selector for the '${selectorKeys[index]}' property`,
})
);
}
22 changes: 22 additions & 0 deletions packages/store/src/selectors/create-pick-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createSelector } from '../utils/selector-utils';
import { ensureValidSelector } from './selector-checks.util';
import { TypedSelector } from './selector-types.util';

type KeysToValues<T, Keys extends (keyof T)[]> = {
[Index in keyof Keys]: Keys[Index] extends keyof T ? T[Keys[Index]] : never;
};

export function createPickSelector<TModel, Keys extends (keyof TModel)[]>(
selector: TypedSelector<TModel>,
keys: [...Keys]
) {
ensureValidSelector(selector, { prefix: '[createPickSelector]' });
const validKeys = keys.filter(Boolean);
const selectors = validKeys.map((key) => createSelector([selector], (s: TModel) => s[key]));
return createSelector([...selectors], (...props: KeysToValues<TModel, Keys>) => {
return validKeys.reduce((acc, key, index) => {
acc[key] = props[index];
return acc;
}, {} as Pick<TModel, Keys[number]>);
});
}
34 changes: 34 additions & 0 deletions packages/store/src/selectors/create-property-selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createSelector } from '../utils/selector-utils';
import { ensureValidSelector } from './selector-checks.util';
import { SelectorDef } from './selector-types.util';

export type PropertySelectors<TModel> = {
[P in keyof NonNullable<TModel>]-?: (
model: TModel
) => TModel extends null | undefined ? undefined : NonNullable<TModel>[P];
};

export function createPropertySelectors<TModel>(
parentSelector: SelectorDef<TModel>
): PropertySelectors<TModel> {
ensureValidSelector(parentSelector, {
prefix: '[createPropertySelectors]',
noun: 'parent selector',
});
const cache: Partial<PropertySelectors<TModel>> = {};
return new Proxy<PropertySelectors<TModel>>(
{} as unknown as PropertySelectors<TModel>,
{
get(_target: any, prop: keyof TModel) {
const selector =
cache[prop] ||
(createSelector(
[parentSelector],
(s: TModel) => s?.[prop]
) as PropertySelectors<TModel>[typeof prop]);
cache[prop] = selector;
return selector;
},
} as ProxyHandler<PropertySelectors<TModel>>
);
}
4 changes: 4 additions & 0 deletions packages/store/src/selectors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './create-model-selector';
export * from './create-pick-selector';
export * from './create-property-selectors';
export * from './selector-types.util';
26 changes: 26 additions & 0 deletions packages/store/src/selectors/selector-checks.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getSelectorMetadata, getStoreMetadata } from '../internal/internals';
import { SelectorDef } from './selector-types.util';

export function ensureValidSelector(
selector: SelectorDef<any>,
context: { prefix?: string; noun?: string } = {}
) {
const noun = context.noun || 'selector';
const prefix = context.prefix ? context.prefix + ': ' : '';
ensureValueProvided(selector, { noun, prefix: context.prefix });
const metadata = getSelectorMetadata(selector) || getStoreMetadata(selector as any);
if (!metadata) {
throw new Error(`${prefix}The value provided as the ${noun} is not a valid selector.`);
}
}

export function ensureValueProvided(
value: any,
context: { prefix?: string; noun?: string } = {}
) {
const noun = context.noun || 'value';
const prefix = context.prefix ? context.prefix + ': ' : '';
if (!value) {
throw new Error(`${prefix}A ${noun} must be provided.`);
}
}
3 changes: 2 additions & 1 deletion packages/store/src/selectors/selector-types.util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { StateToken } from '@ngxs/store';
import { StateClass } from '@ngxs/store/internals';

import { StateToken } from '../state-token/state-token';

export type SelectorFunc<TModel> = (...arg: any[]) => TModel;

export type TypedSelector<TModel> = StateToken<TModel> | SelectorFunc<TModel>;
Expand Down
Loading

0 comments on commit 81a817d

Please sign in to comment.