Skip to content

antischematic/angular-state-library

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Angular State Library

Manage state in your Angular applications. Status: in development

Read the Intro

Try it on StackBlitz

API

Version: 0.7.0
Bundle size: ~20kb min. ~6kb gzip

This API is experimental.

Table of Contents

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);