-
Notifications
You must be signed in to change notification settings - Fork 404
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(selector-utils): add advanced selector utils (#1824)
* 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
1 parent
c0b40ba
commit 81a817d
Showing
13 changed files
with
982 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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! |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`, | ||
}) | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]>); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.