Jan 25, 2019

SPFx WebPart scoped service

Hi SharePoint devs ! In this post, I will tackle something that you'd better keep in mind if, like me, you prefer to organize your code in layers and services. You may have read my post a few months ago about Dependency Injection in SPFx. This one explained quite clearly how to use the SPFx ServiceScope class but didn't really focused on an important part: Root scope and Child scope.  

ServiceScope ? What's that again ?

The ServiceScope class is acting as a container for service instances, it allows you to use the preconfigured instances of system services. This mean being more efficient rather than reinstantiate a new service everywhere is needed. It also allows to define your own services. ServiceScope is also really handy if you want to deal with dependency injection concerns. It allows, for example, using different implementations of a service contract according to a specific context. (TEST or PROD context, different data providers, ...)

Default implementation

The ServiceScope pattern built by SPFx engineers enforce a service contract to always have a default implementation. In the code exhibit below, I define a ListService that retrieves basic information about a SharePoint list

import { ServiceScope, ServiceKey } from "@microsoft/sp-core-library";
import { SPHttpClient } from "@microsoft/sp-http";

/**
 * The List service contract
 */
export interface IListService {
    configure(webUrl: string, listId: string);
    getListData(): Promise<IListData>;
}

export interface IListData {
    Title: string;
    ItemsCount: number;
}

const SERVICE_KEY_TOKEN = "ListService";

/**
 * List service default implementation
 */
export class ListService implements IListService {

    private _listId: string;
    private _webUrl: string;

    constructor(private serviceScope: ServiceScope) {}

    /**
     * Set the configuration of the service
     * @param webUrl The URL of the SharePoint web
     * @param listId THe ID of the list to work on
     */
    public configure(webUrl: string, listId: string) {
        this._webUrl = webUrl;
        this._listId = listId;
    }    

    /**
     * Gets basic information about the configured SharePoint list
     */
    public getListData(): Promise<IListData> {
        const apiUrl = `${this._webUrl}/_api/web/lists(guid'${this._listId}')?$select=Title,ItemCount`;
        const client = this.serviceScope.consume(SPHttpClient.serviceKey);
        return client.get(apiUrl, SPHttpClient.configurations.v1)
        .then(r => r.json())
        .then(r => ({
            Title: r.Title,
            ItemsCount: r.ItemCount
        } as IListData));
    }

    public static serviceKey: ServiceKey<IListService> = ServiceKey.create(SERVICE_KEY_TOKEN, ListService);
}

And that's all we need to do to have to our custom service available in our solution. Specifically, the line 53 where we create the service key. This instruction creates a unique identifier for our service and make sure the ServiceScope will know the default implementation for our service.

Root Scope

With the code above, we implemented a default implementation that will be available in the ServiceScope. In the WebPart code, to get an instance of our service, you can use

let service = this.context.serviceScope.consume(ListService.serviceKey);

But we need to consider one thing, the this.context.serviceScope is a reference to the default ServiceScope, more precisely the Root Scope, or in other words, a scope global to the whole page.

What does it mean ?

It means that the same scope is shared by all your components and all the service instances it contains ! This is not a problem at all if your service instance doesn't keep any state tied to your component instance. However, if that is the case, that might cause some issues! Why? Because if you have several instances of the same WebPart on the page, they will all share the same service instance ! If you change the state of the service, it will be changed for all your instances. Let's see an example; I created of small WebPart responsible for displaying the configured list title and its number of items. It is using the ListService described above through the this.context.serviceScope. In the property change handler, I use the service configure() method to pass the WebPart properties and needed context.

single-webpart-scope

That seems to work well, no big deal here :) Let's see if I add a second WebPart.

2wp-rootscope.gif

We can see that both WebParts are actually using the same configuration instance even if their properties remain different ! That must be addressed because, most likely, several instances of a same WebPart will be added to page with different configurations. In the case of our services will use a WebPart specific configuration, we will need a WebPart-scoped serviceScope. The ServiceScope API allows to do that and is smart enough so that we can only specified what is specific to a scope or pertains to the root scope: Child scopes.

Child Scopes

A child scope is, as its name tells, a child of the root scope, it means that it might hold specific instances of services, but all the other instances are fetched from the parent scope (the root scope in our case). We can create a child scope by using the startNewChild() method of a service scope instance, it has to be configured and its finish() method should have been executed for this new scope to be ready to use. Then we have to deal with the child scope referencing because the this.context.serviceScope will still refer to the root scope. Inspired by my dependency injection experience in other technologies, I came up with a class implementation that you need to use in the onInit() method of your SPFx component.


import { BaseComponentContext } from "@microsoft/sp-component-base";
import { ServiceScope, ServiceKey } from "@microsoft/sp-core-library";

interface IServiceConfiguration {
    serviceKey: any;
    config: (serviceInstance: any) => void;
}

export class ComponentServices {
    constructor(private serviceScope: ServiceScope) {

    }

    public registerScopedServiceInstance<TService>(serviceKey: ServiceKey<TService>, instance: TService): TService {
        this.serviceScope.provide(serviceKey, instance);
        return instance;
    }

    public registerScopedService<TService>(serviceKey: ServiceKey<TService>, serviceType: new (serviceScope: ServiceScope) => any): TService {
        return this.serviceScope.createAndProvide(serviceKey, serviceType);
    }

    private _serviceConfigurations: { [id: string] : IServiceConfiguration} = {};
    public configureService<TService>(serviceKey: ServiceKey<TService>, config: (serviceInstance: TService) => void) {
        this._serviceConfigurations[serviceKey.id] = {
            serviceKey,
            config
        };
    }

    public static init<TProps>(componentContext: BaseComponentContext,
        properties: TProps,
        configureServices: (startup: ComponentServices, ctx: BaseComponentContext, props: TProps) => Promise<void>): Promise<ServiceScope> {

        console.log('ComponentContext: ', componentContext);
        console.log('Properties: ', properties);

        if (!configureServices) {
            return Promise.resolve();
        }

        return new Promise((resolve, reject) => {
            try {
                const childScope = componentContext.serviceScope.startNewChild();
                const startup = new ComponentServices(childScope);
                configureServices(startup, componentContext, properties)
                    .then(() => {
                        console.log('Services are configured.');
                        childScope.finish();
                        childScope.whenFinished(() => {
                            ComponentServices.serviceScope = childScope;

                            for (let k in startup._serviceConfigurations) {
                                const configHandle = startup._serviceConfigurations[k];
                                const serviceInstance = childScope.consume(configHandle.serviceKey);
                                configHandle.config(serviceInstance);
                            }

                            resolve(childScope);
                        });
                    }).catch(err => reject(err));
            } catch (error) {
                reject(error);
            }
        });
    }

    public static serviceScope: ServiceScope;
}

Then, in the onInit() method of your component, you can write something like

// ...  
private listDataService: IListService;

  public onInit(): Promise<void> {
    return ComponentServices.init(this.context,
      this.properties, (startup, ctx, props) => {

        // // Register a new scoped instance of the service
        startup.registerScopedService(ListService.serviceKey, ListService);
        // Configure the service instance with the component specific properties
        startup.configureService(ListService.serviceKey, service => {
          service.configure(ctx.pageContext.web.absoluteUrl, props.listId);
        });
      
        // Must return a resolved promise 
        // (useless here but needed in case on async needs in the config process)
        return Promise.resolve();
      }).then(serviceScope => {

        // Consume the list service
        // Instead of keeping a service reference assigned here,
        // one can use ComponentServices.serviceScope.consume(ListService.serviceKey);
        this.listDataService = serviceScope.consume(ListService.serviceKey);
        // ...
      }).catch(error => {
        console.log('Error on init: ', error);
      });
  }

    // ...  

Let's see the output with that code in place

2wp-ownscope

Now, we can see each WebPart has its own service scope and work properly.

WebPart-scoped service scope

This is how we can make sure services we develop in our SPFx solutions that the proper instance is used in the right context. Somehow, if you are working on this kind of "WebPart-scoped" (or component-scoped) services intended to be used by others, make sure they are properly documented so the consumer developers will know what he/she should take care of! Hopefully, you liked this post, give your feedback ! You can find the shared repo of my lab here

Cheers,

Yannick

Other posts