@antischematic/angular-state-library
v0.7.8
Published
Reactive state without boilerplate
Downloads
23
Maintainers
Readme
Angular State Library
Manage state in your Angular applications. Status: in development
API
Version: 0.7.0 Bundle size: ~20kb min. ~6kb gzip
This API is experimental.
Core
Angular State Library is built around class decorators.
Store
Note:
@Store
only works on classes decorated with@Component
or@Directive
Marks the decorated directive as a store. This decorator is required for all other decorators to function.
Basic usage
@Store()
@Component()
export class UICounter {}
Action
Marks the decorated method as an action. Each action runs in its own EnvironmentInjector
context. When the action is
called it automatically schedules a Dispatch
event for the next change detection cycle.
Example: Basic action
@Store()
@Component()
export class UICounter {
@Input() count = 0
@Action() increment() {
this.count++
}
}
Example: Action with dependency injection
@Store()
@Component()
export class UITodos {
todos = []
@Action() loadTodos() {
const endpoint = "https://jsonplaceholder.typicode.com/todos"
const loadTodos = inject(HttpClient).get(endpoint)
dispatch(loadTodos, (todos) => {
this.todos = todos
})
}
}
Invoke
See Action
. The method receives a reactive this
context that tracks dependencies. The action is called automatically
during ngDoCheck
on the first change detection cycle and again each time its reactive dependencies change.
Example: Reactive actions
This example logs the value of count
whenever it changes via @Input
or increment
.
@Store()
@Component()
export class UICounter {
@Input() count = 0
@Action() increment() {
this.count++
}
@Invoke() logCount() {
console.log(this.count)
}
}
Before
See Invoke
. Dependencies are checked during ngAfterContentChecked
. Use this when an action depends on ContentChild
or ContentChildren
.
Example: Reactive content query
This example creates an embedded view using ContentChild
.
@Store()
@Component()
export class UIDynamic {
@ContentChild(TemplateRef)
template?: TemplateRef
@Before() createView() {
const viewContainer = inject(ViewContainerRef)
if (this.template) {
viewContainer.createEmbeddedView(this.template)
}
}
}
Layout
See Invoke
. Dependencies are checked during ngAfterViewChecked
. Use this when an action depends on ViewChild
or ViewChildren
.
Example: Reactive view query
This example logs when the number of child components change.
@Store()
@Component()
export class UIParent {
@ViewChildren(UIChild)
viewChildren?: QueryList<UIChild>
@Layout() countElements() {
const {length} = $(this.viewChildren)
console.log(`There are ${length} elements on the page`)
}
}
Select
Marks the decorated property, accessor or method as a selector. Use selectors to derive state from other stores or class properties. Can be
chained with other selectors. Selectors receive a reactive this
context that tracks dependencies. Selectors are
memoized until their dependencies change. Selectors are not evaluated until its value is read. The memoization cache is
purged each time reactive dependencies change.
For method selectors, arguments must be serializable with JSON.stringify
.
For property selectors, they must implement the OnSelect
or Subscribable
interface.
Example: Computed properties
@Store()
@Component()
export class UICounter {
@Input() count = 0
@Select() get double() {
return this.count * 2
}
}
Example: Computed methods
@Store()
@Component()
export class UITodos {
todos = []
@Select() getTodosByUserId(userId: string) {
return this.todos.filter(todo => todo.userId === userId)
}
}
Example: Select theme from a template provider
@Store()
@Component()
export class UIButton {
@select(UITheme) theme = get(UITheme)
@HostBinding("style.color") get color() {
return this.theme.color
}
}
Example: Select parent store
@Store()
@Component()
export class UIComponent {
@Select() uiTodos = inject(UITodos)
@Select() get todos() {
return this.uiTodos.todos
}
}
Example: Select a transition
@Store()
@Component()
export class UIComponent {
@Select() loading = new Transition()
}
Caught
Marks the decorated method as an error handler. Unhandled exceptions inside @Action
, @Invoke
, @Before
, @Layout
and @Select
are forwarded to the first error handler. Unhandled exceptions from dispatched effects are also captured.
If the class has multiple error handlers, rethrown errors will propagate to the next error handler in the chain from top
to bottom.
Example: Handling exceptions
@Store()
@Component()
export class UITodos {
@Action() loadTodos() {
throw new Error("Whoops!")
}
@Caught() handleError(error: unknown) {
console.debug("Error caught", error)
}
}
TemplateProvider
Provide values from a component template reactively. Template providers are styled with display: contents
so they
don't break grid layouts. Only use template providers with an element selector on a @Directive
. Use with Select
to
keep dependant views in sync.
Example: Theme Provider
export interface Theme {
color: string
}
@Directive({
standalone: true,
selector: "ui-theme"
})
export class UITheme extends TemplateProvider {
value: Theme = {
color: "red"
}
}
<ui-theme>
<ui-button>Red button</ui-button>
<ui-theme [value]="{ color: 'green' }">
<ui-button>Green button</ui-button>
</ui-theme>
</ui-theme>
configureStore
Add configuration for all stores, or override configuration for a particular store.
interface StoreConfig {
root?: boolean // default: false
actionProviders?: Provider[]
}
root
Set to true so stores inherit the configuration. Set to false to configure a specific store.
actionProviders
Configure action providers. Each method decorated with @Action
, @Invoke
, @Before
, @Layout
or @Caught
will
receive a unique instance of each provider.
Observables
Every store can be observed through its event stream.
events
Returns an observable stream of events emitted from a store. Actions automatically dispatch events when they are called. The next, error and complete events from dispatched effects can also be observed. Effects must be returned from an action for the type to be correctly inferred. This method must be called inside an injection context.
Example: Observe store events
events(UITodos).subscribe(event => {
switch (event.name) {
case "loadTodos": {
switch (event.type) {
case EventType.Next: {
console.log('todos loaded!', event.value)
}
}
}
}
})
EVENTS
Injects the global event observer. Use this to observe all store events in the application.
Example: Log all store events in the application
@Component()
export class UIApp {
constructor() {
inject(EVENTS).subscribe((event) => {
console.log(event)
})
}
}
store
Emits the store instance when data has changed due to an action, including changes to parent stores if selected.
Example: Observe store changes
const uiTodos = store(UITodos)
uiTodos.subscribe(current => {
console.log("store", current)
})
slice
Select a slice of a store's state, emitting the current state on subscribe and each time the state changes due to an action.
Example: Observe a single property from a store
const todos = slice(UITodos, "todos")
todos.subscribe(current => {
console.log("todos", current)
})
Example: Observe multiple properties from a store
const state = slice(UITodos, ["userId", "todos"])
state.subscribe(current => {
console.log("state", current.userId, current.todos)
})
inputs
Returns an observable stream of TypedChanges
representing changes to a store's @Input
bindings.
Example: Observable inputs
@Store()
@Component()
export class UITodos {
@Input() userId!: string
@Invoke() observeChanges() {
dispatch(inputs(UITodos), (changes) => {
console.log(changes.userId?.currentValue)
})
}
}
Selector
Creates an injectable selector that derives a value from the event stream. Selectors can return an Observable
or WithState
object. If a WithState
object is returned, the selector state can be mutated by calling next
. The mutation action can be intercepted by providing the subject as the first argument to the selector.
Example: Selector with observable
const Count = new Selector(() => action(UICounter, "increment").pipe(
scan(count => count + 1, 0)
))
@Store()
@Directive()
export class UICounter {
@Select(Count) count = 0
@Action() increment!: Action<() => void>
}
Example: Selector with state mutation
const Count = new Selector(() => withState(0))
@Store()
@Directive()
export class UICounter {
@Select(Count) count = get(Count) // 0
@Action() increment() {
inject(Count).next(this.count + 1)
}
}
Example: Selector with debounced state
const Count = new Selector((state) => withState(0, {
from: state.pipe(debounce(1000))
}))
@Store()
@Directive()
export class UICounter {
@Select(Count) count = get(Count) // 0
@Action() increment() {
inject(Count).next(this.count + 1)
}
}
Example: Selector with state from events
const Count = new Selector(() =>
withState(0, {
from: action(UICounter, "setCount")
})
)
@Store()
@Directive()
export class UICounter {
@Select(Count) count = get(Count) // 0
@Action() setCount: Action<(count: number) => void>
}
actionEvent
Returns a DispatchEvent
stream. Use action
if you only want the value.
Example: Get a DispatchEvent
stream from an action
@Store()
@Directive()
export class UIStore {
action(value: number) {}
}
actionEvent(UIStore, "action") // Observable<DispatchEvent>
action(UIStore, "action") // Observable<number>
nextEvent
Returns a NextEvent
stream. Use next
if you only want the value.
Example: Get a NextEvent
stream from an action
@Store()
@Directive()
export class UIStore {
action(value: number) {
return dispatch(of(number.toString()))
}
}
nextEvent(UIStore, "action") // Observable<NextEvent>
next(UIStore, "action") // Observable<string>
errorEvent
Returns an ErrorEvent
stream. Use error
if you only want the error.
Example: Get an ErrorEvent
stream from an action
@Store()
@Directive()
export class UIStore {
action(value: number) {
return dispatch(throwError(() => new Error("Oops!")))
}
}
errorEvent(UIStore, "action") // Observable<ErrorEvent>
error(UIStore, "action") // Observable<unknown>
completeEvent
Returns a CompleteEvent
stream.
Example: Get a CompleteEvent
stream from an action
@Store()
@Directive()
export class UIStore {
action(value: number) {
return dispatch(EMPTY)
}
}
completeEvent(UIStore, "action") // Observable<ErrorEvent>
complete(UIStore, "action") // Observable<void>
Action Hooks
Use action hooks to configure the behaviour of actions and effects. Action hooks can only be called inside a method
decorated with @Action
, @Invoke
, @Before
, @Layout
or @Caught
.
dispatch
Dispatch an effect from an action. Observer callbacks are bound to the directive instance.
Example: Dispatching effects
@Store()
@Component()
export class UITodos {
@Input() userId: string
todos: Todo[] = []
@Invoke() loadTodos() {
const endpoint = "https://jsonplaceholder.typicode.com/todos"
const loadTodos = inject(HttpClient).get(endpoint, {
params: {userId: this.userId}
})
dispatch(loadTodos, (todos) => {
this.todos = todos
})
}
}
loadEffect
Creates an action hook that lazy loads an effect. The effect is loaded the first time it is called inside an action.
Example: Lazy load effects
// load-todos.ts
export default function loadTodos(userId: string) {
const endpoint = "https://jsonplaceholder.typicode.com/todos"
return inject(HttpClient).get(endpoint, {
params: {userId}
})
}
const loadTodos = loadEffect(() => import("./load-todos"))
@Store()
@Component()
export class UITodos {
@Input() userId: string
todos: Todo[] = []
@Invoke() loadTodos() {
dispatch(loadTodos(this.userId), (todos) => {
this.todos = todos
})
}
}
addTeardown
Adds a teardown function or subscription to be executed the next time an action runs or when the component is destroyed.
Example: Using third party DOM plugins
@Store()
@Component()
export class UIPlugin {
@Layout() mount() {
const {nativeElement} = inject(ElementRef)
const teardown = new ThirdPartyDOMPlugin(nativeElement)
addTeardown(teardown)
}
}
useInputs
Returns a reactive SimpleChanges
object for the current component. Use this to track changes to input values.
Example: Reacting to @Input
changes
@Store()
@Component()
export class UITodos {
@Input() userId!: string
todos: Todo[] = []
@Invoke() loadTodos() {
const { userId } = useInputs<UITodos>()
dispatch(loadTodos(userId.currentValue), (todos) => {
this.todos = todos
})
}
}
useOperator
Sets the merge strategy for effects dispatched from an action. The default strategy is switchAll
. Once useOperator
is called, the operator is locked and cannot be changed.
Shortcuts for common operators such as useMerge
, useConcat
and useExhaust
are also available.
Example: Debounce effects
function useSwitchDebounce(milliseconds: number) {
return useOperator(source => {
return source.pipe(
debounceTime(milliseconds),
switchAll()
)
})
}
@Store()
@Component()
export class UITodos {
@Input() userId: string
todos: Todo[] = []
@Invoke() loadTodos() {
useSwitchDebounce(1000)
dispatch(loadTodos(this.userId), (todos) => {
this.todos = todos
})
}
}
Example: Compose hooks with effects
export default function loadTodos(userId: string) {
useSwitchDebounce(1000)
return inject(HttpClient).get(endpoint, {
params: {userId}
})
}
Proxies
Reactivity is enabled through the use of proxy objects. The reactivity API makes it possible to run actions and change detection automatically when data dependencies change.
track (alias: $
)
Track arbitrary objects or array mutations inside reactive actions and selectors.
Example: Track array mutations
@Component()
export class UIButton {
todos: Todo[] = []
@Select() remaining() {
return $(this.todos).filter(todo => !todo.completed)
}
@Action() addTodo(todo) {
this.todos.push(todo)
}
}
untrack (alias: $$
)
Unwraps a proxy object, returning the original object. Use this to avoid object identity hazards or when accessing private fields.
isTracked
Returns true
if the value is a proxy object created with track
Extensions
These APIs integrate with Angular State Library, but they can also be used on their own.
Transition
Transitions use Zone.js to observe the JavaScript event loop. Transitions are a drop in replacement for EventEmitter
.
When used as an event emitter,
any async activity is tracked in a transition zone. The transition ends once all async activity has settled.
Example: Button activity indicator
@Component({
template: `
<div><ng-content></ng-content></div>
<ui-spinner *ngIf="press.unstable"></ui-spinner>
`
})
export class UIButton {
@Select() @Output() press = new Transition()
@HostListener("click", ["$event"])
handleClick(event) {
this.press.emit(event)
}
}
Example: Run code inside a transition
const transition = new Transition()
transition.run(() => {
setTimeout(() => {
console.log("transition complete")
}, 2000)
})
TransitionToken
Creates an injection token that injects a transition.
const Loading = new TransitionToken("Loading")
@Component()
export class UITodos {
@Select() loading = inject(Loading)
}
useTransition
Runs the piped observable in a transition.
Example: Observe the loading state of todos
const endpoint = "https://jsonplaceholder.typicode.com/todos"
function loadTodos(userId: string, loading: Transition<Todo[]>) {
return inject(HttpClient).get<Todo[]>(endpoint, { params: { userId }}).pipe(
useTransition(loading),
useQuery({
key: [endpoint, userId],
refreshInterval: 10000,
refreshOnFocus: true,
refreshOnReconnect: true
})
)
}
@Store()
@Component({
template: `
<ui-spinner *ngIf="loading.unstable"></ui-spinner>
<ui-todo *ngFor="let todo of todos" [value]="todo"></ui-todo>
`
})
export class UITodos {
@Input() userId!: string
todos: Todo[] = []
@Select() loading = new Transition<Todo[]>()
@Action() setTodos(todos: Todo[]) {
this.todos = todos
}
@Invoke() loadTodos() {
dispatch(loadTodos(this.userId, this.loading), {
next: this.setTodos
})
}
}
useQuery
Caches an observable based on a query key, with various options to refresh data. Returns a shared observable with the query result.
Example: Fetch todos with a query
const endpoint = "https://jsonplaceholder.typicode.com/todos"
function loadTodos(userId: string) {
return inject(HttpClient).get<Todo[]>(endpoint, { params: { userId }}).pipe(
useQuery({
key: [endpoint, userId],
refreshInterval: 10000,
refreshOnFocus: true,
refreshOnReconnect: true
})
)
}
@Store()
@Component({
template: `
<ui-spinner *ngIf="loading.unstable"></ui-spinner>
<ui-todo *ngFor="let todo of todos" [value]="todo"></ui-todo>
`
})
export class UITodos {
@Input() userId!: string
todos: Todo[] = []
@Select() loading = new Transition<Todo[]>()
@Action() setTodos(todos: Todo[]) {
this.todos = todos
}
@Invoke() loadTodos() {
dispatch(loadTodos(this.userId, this.loading), {
next: this.setTodos
})
}
}
useMutation
Subscribes to a source observable and invalidates a list of query keys when the observable has settled. In-flight queries are cancelled.
Example: Create a todo and refresh the data
const endpoint = "https://jsonplaceholder.typicode.com/todos"
function loadTodos(userId: string) {
return inject(HttpClient).get<Todo[]>(endpoint, { params: { userId }}).pipe(
useQuery({
key: [endpoint, userId],
refreshInterval: 10000,
refreshOnFocus: true,
refreshOnReconnect: true,
resource: inject(ResourceManager) // optional when called from an action
})
)
}
function createTodo(userId: string, todo: Todo) {
return inject(HttpClient).post(endpoint, todo).pipe(
useMutation({
invalidate: [endpoint, userId],
resource: inject(ResourceManager) // optional when called from an action
})
)
}
@Store()
@Component({
template: `
<ui-spinner *ngIf="loading.unstable"></ui-spinner>
<ui-todo (save)="createTodo($event)"></ui-todo>
<hr>
<ui-todo *ngFor="let todo of todos" [value]="todo"></ui-todo>
`
})
export class UITodos {
@Input() userId!: string
todos: Todo[] = []
@Select() loading = new Transition<Todo[]>()
@Action() setTodos(todos: Todo[]) {
this.todos = todos
}
@Invoke() loadTodos() {
dispatch(loadTodos(this.userId, this.loading), {
next: this.setTodos
})
}
@Action() createTodo(todo: Todo) {
dispatch(createTodo(this.userId, todo))
}
}
Testing Environment
For Angular State Library to function correctly in unit tests, some additional setup is required. For a default Angular
CLI setup, import the initStoreTestEnvironment
from @antischematic/angular-state-library/testing
and call it just
after the test environment is initialized. Sample code is provided below.
// test.ts (or your test setup file)
import {initStoreTestEnvironment} from "@antischematic/angular-state-library/testing"; // <--------- ADD THIS LINE
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);
// Now setup store hooks
initStoreTestEnvironment() // <--------- ADD THIS LINE
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().forEach(context);