1.0.5 • Published 4 years ago

@runopencode/stencil-state-store v1.0.5

Weekly downloads
11
License
MIT
Repository
-
Last release
4 years ago

Built With Stencil

Stencil state store

This is simplified version of state store, adjusted to the needs for the composition of several components that share and update common state.

Although there are other approaches, like property probing, or state tunneling, this approach is inspired by NGXS and based on RXJS.

While NGXS have concept of actions which is dispatched and accepted by store, in this simplified version, components have direct access to store and can invoke patch and set methods to mutate state.

Rationale behind this is that web components should be simple and communication lightweight. If something like NGXS is required to manage components communication, you should to reconsider using some application framework and/or more complex library for state management.

Web components should be used for building web components. For complex use case scenarios, perhaps application frameworks are more suitable.

Learn by example

Let's say that we have some state that needs to be shared among components:

export interface CarouselState {
    page: number;
} 

while our composition of components is:

<runopencode-carousel>
    
    <runopencode-carousel-stage>
        <img src="example-1.jpg"/>
        <img src="example-2.jpg"/>
        <img src="example-3.jpg"/>
    </runopencode-carousel-stage>
    
    <runopencode-carousel-pager>
    </runopencode-carousel-pager>
    
</runopencode-carousel>

Component runopencode-carousel-stage is in charge to render and slide those images, while runopencode-carousel-pager is in charge to display current page. Since those components are siblings, they can not use property probing approach. Component runopencode-carousel could provide for them a shared state store, that is, an instance of StoreInterface containing instance of CarouselState.

import {Component, ComponentInterface, Host, h}      from "@stencil/core";
import {Provide, Store}                              from "@runopencode/stencil-state-store";
import {CarouselState}                               from "./state";

@Component({
    tag:    'runopencode-carousel',
    shadow: true,
})
export class Carousel implements ComponentInterface {
    
        @Provide({
            name:     'runopencode-carousel-store',
            defaults: {
                page: 1
            }
        })
        private store: Store<CarouselState>;
        
        public render() {
            return (
                <Host>
                    <state-store-provider provider={this}>               
                        <slot/>
                    </state-store-provider>
                </Host>
            );
        }
}

Note that it is even possible not to wrap content within <state-store-provider> tag, you may just provide a component as direct child of <Host> within render() method. Example:

public render() {
    return (
        <Host>
            <state-store-provider provider={this}/>
            <slot/>
        </Host>
    );
}

Component runopencode-carousel-pager can consume that state store:

import {Component, ComponentInterface, Host, h}      from "@stencil/core";
import {Provide, Store}                              from "@runopencode/stencil-state-store";
import {CarouselState}                               from "./state";
import {Unsubscribable}                              from "rxjs";

@Component({
    tag:    'runopencode-carousel-pager',
    shadow: true,
})
export class CarouselPager {
    
    @Consume('runopencode-carousel-store')
    private store: Promise<Store<CarouselState>>;

    @State()
    private page: number;
        
    private subscription: Unsubscribable;
    
    public componentDidLoad(): void {
        let store: Store<CarouselState> = await this.store;
        this.subscription = store.subscribe((state: CarouselState) => {
            this.page = state.page;
        });
    }
    
    public disconnectedCallback(): void {
        this.subscription.unsubscribe();
    }
    
    public render() {
        return (
            <Host>
            <state-store-consumer consumer={this}/>
            <div>
                Current slide is {this.page}
            </div>           
            </Host>
        );
    }
}

So, there are few classes, decorators and interfaces involved here.

  1. You need to define your state interface. It is simple key, value pair, defined by following:
export interface CarouselState {
    page: number;
} 
  1. You have to provide your state store, with default values and unique name from parent component:
@Provide({
    name:     'runopencode-carousel-store',
    defaults: {
        page: 1
    }
})
public store: Store<CarouselState>;

Note that defaults may be a function which creates new default state.

That component must use state-store-provider component, with provider property referencing to this when rendering component.

public render() {
    return (
        <Host>
            <state-store-provider provider={this}/>
            <slot/>
        </Host>
    );
}
  1. You have to consume your state
@Consume('runopencode-carousel-store')
private store: Promise<Store<CarouselState>>;
  1. Trough subscription you can follow changes of state and update your component state:
public componentDidLoad(): void {
    let store: Store<CarouselState> = await this.store;
    this.subscription = store.subscribe((state: CarouselState) => {
        this.page = state.page;
    });
}    

IMPORTANT NOTES:

  • Consuming component (consumer) should subscribe for state change in componentDidLoad lifecycle method. This is stenciljs limitation, component must be rendered in order to require for shared state.
  • You might notice that Promise will be provided to consumer, not the state. There is no guarantee in component rendering order (event though there is clear parent-child relation), therefore, providing operation is async.
  • If Promise for store in consuming component is returned in componentWillLoad or componentWillRender lifecycle methods, child component will never be rendered, have that in mind.

State store implementation.

Do note that instance of your state store implements Store interface defined as follows:

import {Observable, PartialObserver, Subscribable, Subscription} from "rxjs";

export interface Store<T> extends Subscribable<T> {

    /**
     * Get observable.
     */
    observer: Observable<T>;

    /**
     * Select slice of state.
     */
    select(selector: (state: T | null) => void): Observable<T>;

    /**
     * Get current state.
     */
    snapshot(): T | null;

    /**
     * Set state.
     */
    set(state: T): void;

    /**
     * Patch state.
     */
    patch(state: Partial<T>): void;

    /**
     * Notify observers about error.
     */
    error(err: any): void;

    /**
     * Subscribe to state change.
     */
    subscribe(next?: PartialObserver<T> | ((value: T) => void)): Subscription;
}
    

See demo on YouTube: https://youtu.be/D07vAxlEUS0.

TODO

  • Write tests
  • Improvements with custom decorators - if custom class decorators become supported, implementation could be improved.
1.0.5

4 years ago

1.0.4

4 years ago

1.0.3

4 years ago

1.0.2

4 years ago

1.0.1

4 years ago

1.0.0

4 years ago

0.0.4

5 years ago

0.0.3

5 years ago

0.0.2

5 years ago

0.0.1

5 years ago