Principle | Description | |
---|---|---|
Simple | Yes | Only a handful methods, no complex ngrx structures |
Small | Yes | Minified and compressed: 2KB |
Opinionated | Yes | Structured and opinionated way of state management |
No boilerplate | Yes | No selectors, reducers, actions, action types, effects, ... |
Easy to learn | Yes | Provides everything, but still very small |
Battle tested | Yes | Tested with big clients |
Type-safe | Yes | High focus on type-safety |
Examples | Yes | Working on tons of examples as we speak |
- ngx-signal-state is more opinionated
- Advanced selecting logic,
select()
,selectMany()
- Forces us to treat components as state machines
- Clean api
- Because we can patch multiple signals in one command
- Connect functionality
- Plays well with Observables too
- Retrigger producer functions of connected observables
- Pick functionality of external states
- Easy snapshot
- State initialization in one place
This state management library has 2 important goals:
- Simplifying state management: KISS always!!
- Opinionated state management
The principles are:
- Every ui component is treated as a state machine
- Every smart component is treated as a state machine
- Features (Angular lazy loaded chunks) can have state machines shared for that feature
- Application-wide there can be multiple global state machines
- State machines can be provided on all levels of the injector tree
- We can pick pieces of state from other state machines and add a one-way communication between them
The best practice here is to keep the state as low as possible.
We can start by installing ngx-signal-state
with npm or yarn.
After that we can import SignalState
like this:
import { SignalState } from "ngx-signal-state";
Creating a state machine for a component is simple. We just have to create a specific type
for the state and extend our component from SignalState<T>
;
type MyComponentState = {
firstName: string;
lastName: string;
};
export class MyComponent extends SignalState<MyComponentState> {
}
We can not consume SignalState
functionality before we have initialized the state
in the constructor with the initialize()
method:
export class MyComponent extends SignalState<MyComponentState> {
constructor(props) {
super(props);
this.initialize({
firstName: 'Brecht',
lastName: 'Billiet',
});
}
}
There are 3 ways to get the state as a signal.
this.state
will return the state as a signal.this.select('propertyName')
will return a signal for the property that we provide.this.selectMany(['firstName', 'lastName'])
will return a signal with multiple pieces of state in it.
export class MyComponent extends SignalState<MyComponentState> {
...
// Fetch the entire state as a signal
state = this.state;
// Only select one property and return it as a signal
firstName = this.select('firstName');
// Select multiple properties as a signal
firstAndLastName = this.selectMany(['firstName', 'lastName'])
}
It's possible to add mapping functions as the second argument of the select()
and selectMany()
methods:
export class MyComponent extends SignalState<MyComponentState> {
...
// Pass a mapping function
firstName = this.select('firstName', firstname => firstname + '!!');
// Pass a mapping function
fullName = this.selectMany(['firstName', 'lastName'], ({ firstName, lastName }) => `${firstName} ${lastName}`)
}
Sometimes we want an untracked snapshot. For that we can use the snapshot
getter that will not
keep track of its consumers.
export class MyComponent extends SignalState<MyComponentState> {
...
protected save(): void {
// Pick whatever we want from the snapshot of the state
const { firstName, lastName } = this.snapshot;
console.log(firstName, lastName);
}
}
Setting multiple signals at the same time can be a drag. The SignalState
offers a patch()
method where we can pass a partial of the entire state.
export class MyComponent extends SignalState<MyComponentState> {
...
protected userChange(user: User): void {
this.patch({ firstName: user.firstName, lastName: user.lastName });
}
}
Sometimes we want to calculate pieces of state and connect those to our state machine. Any signal that we have can be connected to the state. Some examples are:
- Pieces of other state machines (global state)
- Signals that are provided by Angular (Input signals, query signals, ...)
- Calculated pieces of signals that are calculated by the
selectMany()
method
To connect signals to the state we can use the connect()
method where we pass a partial object where
every property is a signal. In the following example we can see that we have a state for a component that has products with
client-side pagination and client-side filtering. We keep products
as state that we will load from the backend, but have 2
calculated pieces of state: filteredProducts
and pagedProducts
. filteredProducts
is calculated based on the products
and query
.
pagedProducts
is calculated based on filteredProducts
, pageIndex
and itemsPerPage
. It should be clear how pieces of state are
being calculated based on other pieces of state. In the connect()
method we can connect these signals and the state machine would
get automatically updated:
export class MyComponent extends SignalState<MyComponentState> {
constructor(props) {
super(props);
this.initialize({
pageIndex: 0,
itemsPerPage: 5,
query: '',
products: [],
filteredProducts: [],
pagedProducts: [],
});
// Calculate the filtered products and store them in a signal
const filteredProducts = this.selectMany(['products', 'query'], ({ products, query }) => {
return products.filter((p) => p.name.toLowerCase().indexOf(query.toLowerCase()) > -1);
});
// Calculate the paged products and store them in a signal
const pagedProducts = this.selectMany(['filteredProducts', 'pageIndex', 'itemsPerPage'],
({
filteredProducts,
pageIndex,
itemsPerPage
}) => {
const offsetStart = pageIndex * itemsPerPage;
const offsetEnd = (pageIndex + 1) * itemsPerPage;
return filteredProducts.slice(offsetStart, offsetEnd);
});
// Connect the calculated signals
this.connect({
filteredProducts,
pagedProducts,
});
}
}
While it is handy to connect signals to the state, it is also handy to connect Observables to the state.
These Observables can be derived from form valueChanges
, activatedRoute
or even http
Observables.
The connectObservables()
method will do 4 things for us:
- Subscribe to the observable and feed the results to the local state machine
- Only execute the producer function once ==> no more multicasting issues
- Clean up after itself ==> No memory leaks
- Register a trigger that can be called later with the
trigger()
method to re-execute the producer function of the Observable
export class MyComponent extends SignalState<MyComponentState> {
constructor(props) {
super(props);
...
this.connectObservables({
// Only execute the call once
products: this.productService.getProducts(),
// Adds a timer
time: interval(1000).pipe(map(() => new Date().getTime())),
})
}
}
Sometimes we want to re-execute the producer function of an observable that is connected to the state. The most recurring example is the
execution of an ajax call. In this example we see how we can refetch users with the trigger()
method.
export class MyComponent extends SignalState<MyComponentState> {
constructor(props) {
super(props);
...
// Connect products and register a trigger behind the scenes
this.connectObservables({
// Only execute the call once
products: this.productService.getProducts(),
})
}
protected refreshProducts(): void {
// Results in new a `this.productService.getProducts()` call
this.trigger('products');
}
}
Every component should be treated as a state machine. Every state class should be treated as a state machine. However, sometimes we want to pick state from other state machines. The principle of picking state is that we listen to that state in a one way communication. If we pick a state we will get notified of updates, but when we do changes to our local state it will not reflect in the state we are listening to:
export class AppComponent extends SignalState<AppComponentState> {
private readonly shoppingCartState = inject(ShoppingCartSignalState)
...
constructor() {
super();
this.initialize({
// set initial values
entries: this.shoppingCartState.snapshot.entries,
paid: this.shoppingCartState.snapshot.paid
});
this.connect({
// listen to pieces of state in the shoppingCartState and connect it to our local state
...this.shoppingCartState.pick(['entries', 'paid'])
})
}
}
We should treat all our components as state machines, but sometimes we also need to share state. For that we can create simple state classes. This is an example of a shopping cart state:
export type ShoppingCartState = {
entries: ShoppingCartEntry[];
};
@Injectable({
// Our provide anywhere in the injector tree
providedIn: 'root',
})
export class ShoppingCartSignalState extends SignalState<ShoppingCartState> {
constructor() {
super();
// initialize the state
this.initialize({
entries: [],
});
}
public addToCart(entry: ShoppingCartEntry): void {
// Update the state in an immutable way
const entries = [...this.snapshot.entries, entry];
this.patch({ entries });
}
public deleteFromCart(id: number): void {
// Update the state in an immutable way
const entries = this.snapshot.entries.filter((entry) => entry.productId !== id);
this.patch({ entries });
}
public updateAmount(id: number, amount: number): void {
// Update the state in an immutable way
const entries = this.snapshot.entries.map((item) => (item.productId === id ? { ...item, amount } : item));
this.patch({ entries });
}
}
This state is provided in the root of the application, so it will be a singleton.
However, we can also provide any signal state machine on all levels of the application by using the providers
property:
- root
- feature
- smart component
- ui component
When creating largescale applications, it's a good idea to abstract the feature libs from the rest of the application.
In projects/examples/src/app/products-with-facade/products-with-facade.component.ts
, we can find an example where all the
data access logic and global state management is abstracted behind a facade.
We should take those rules into account:
- A global state machine should never be injected into a smart component directly
- We never want to expose all the state
- We don't want to patch global state directly in a smart component
- The facade should expose data-access methods
- The facade should not contain logic
- Every feature lib should only contain one facade
A slimmed down version looks like this:
export class ProductsWithFacadeComponent extends SignalState<ProductOverviewState> {
private readonly productsFacade = inject(ProductsFacade)
...
constructor() {
super();
this.initialize({
...
entries: this.productsFacade.shoppingCartSnapshot.entries
});
this.connectObservables({
products: this.productsFacade.getProducts(),
categories: this.productsFacade.getCategories(),
...
})
this.connect({
filteredProducts: this.filteredProducts,
pagedProducts: this.pagedProducts,
...this.productsFacade.pickFromShoppingCartState(['entries'])
})
}
...
protected addToCard(product: Product): void {
this.productsFacade.addToCart({ productId: product.id, amount: 1 });
}
}
Let's create the facade:
@Injectable({ providedIn: 'root' })
export class ProductsFacade {
private readonly productService = inject(ProductService);
private readonly categoryService = inject(CategoryService);
private readonly shoppingCartState = inject(ShoppingCartSignalState)
public get shoppingCartSnapshot() {
// For pragmatic reasons, expose the snapshot
return this.shoppingCartState.snapshot;
}
public pickFromShoppingCartState(keys: (keyof ShoppingCartState)[]): PickedState<ShoppingCartState> {
// For pragmatic reasons, expose the pick method
return this.shoppingCartState.pick(keys);
}
public getProducts(): Observable<Product[]> {
return this.productService.getProducts();
}
public getCategories(): Observable<Category[]> {
return this.categoryService.getCategories();
}
// Don't expose the patch method
public addToCart(entry: ShoppingCartEntry): void {
this.shoppingCartState.addToCart(entry);
}
}
The goal of the facade is abstracting away tools and keeping the smart component ignorant.
Examples of the use of this library can be found in projects/examples
.
To start the backend api run npm run api
and to start the demo application run npm start
.
- 1.0.0 requires Angular ^16.0.0
Do you want to collaborate on this with me? Reach out at brecht@simplified.courses!