Aug 19, 2019

SPFx - scoped context services

Hi SharePoint devs,

In this post, I would like to share with you a pattern I like to use in my SPFx solutions and the concerns I want to address.

SPFx Services and ServiceScope

SharePoint Framework has a service locator implementation through the ServiceScope class, it allows the IoC (Inversion of Control) in your code, that means that you delegate the creation of your service instances to a dedicated part of your code (commonly on startup) and you only consume these service instances wherever you need them, you can then centralize their initialization and configuration and avoid the need of to re-instantiate them anywhere you might need.

Why would it be a problem?

Well, it would work to recreate the service instances every time you need them! But besides the fact that, at runtime, you will most likely consume extra memory for exact object duplicates, at design time, you will need to pass the references to all the dependencies your services might require. For instance, information from the page context will commonly be used in a SPFx solution. If you develop your solution with a library like React, (It might be a bit different if you use Redux...) you will need to pass the references to every child component. It means that your components would be overloaded with a ton of misc properties you could simply ignore until the very one to use them. While I'll focus on a React sample here, the same concern applies to almost any kind of library or framework or architecture using multiple layers you might want to use. Instead, with this approach, the only property your component will need (for that service purpose) is the ServiceScope instance !

What about simply passing the WebPartContext/ComponentContext instance ?

One can disagree with me about that, but I really don't like this approach of passing the whole context as argument to services or as property to components for the following reasons

  1. It makes our component or units of code too dependent on the SharePoint API
  2. It becomes more tricky to unit test and mock quite big object with many properties such as the WebPartContext or ComponentContext, especially when you don't have full control on.
  3. It makes the required dependencies of our unit of codes too abstract, meaning that if, what we use to consume from the Context object suddenly is moved to another "location", we have to refactor the interface of the services and all the calls to it
  4. Whenever we need a value computed on several component-context properties, we need to rebuild it and then redo exactly the same thing, duplicates or codes, more redundant and error-prone... I'd rather do it once, and reuse it anywhere needed.

ServiceScope: root Scope and child scopes

A few months ago, I posted an article explaining the availability of root scopes and child scopes in SPFx solutions, The root scope is the default instance existing by default at scopes the whole page and has the reference to various built-in services. In SPFx, when you declare a service (e.g. create a service key), it gets instantiated in the root scope by default. It is a reason why any SPFx service should have a default implementation. You can override these default instances in child scopes.

Child / Parent Scopes

Child scopes are actually "inheriting" everything from their parent and also have their own overridden specifics. That means that all services we don't specifically override will be the instances from the parent scope. So far, I've not seen any relevant case for having 2 levels of child scopes, but, it should work... In practice, in my solutions, the parent scope have always been the root scope !

Consume services from services

In order to keep your code maintainable and testable, ideally, you should implement your services in a way that that they are responsible only for their own relatively small and generic tasks. It probably requires a bit more work and structure in the first place, but you will be grateful to yourself and your team whenever you have to debug it or modify it later because a single block is not doing a big amount of various unrelated actions. That way, the services you'll use in your client code will most likely be built on several lower-level tasks wired up together and can then focus only on what it is responsible for. An SPFx service instance always receives the reference to its service scope in its constructor. Every service can then consume any other service available in the same scope! All that said, let's now talk about the real focus of this post.

Page-scoped and Component-scoped contexts

Indeed, some of the context information belongs to the page globally:

  • The current URL
  • The current user
  • ...

Some other "context" information belongs to the component (WebPart or Extension)

  • Instance Id
  • Component properties
  • ...

One way or another you would need to pass these kind of information in order to implement properly some features (Calling REST API, make some grouping, filtering,...). If you define these as arguments to your service method, your code will become more complex to maintain because every method have then a lot of arguments, then you need to pass along making sure there is no mistake in every calls! Moreover, that will make your code much less readable... A solution to that can be to wrap all our required dependencies in dedicated service instances we will consume.  That will help avoid spreading the same values all over our solution as arguments to almost all methods !

Enough Muggle language ! I want some code !

Let's remember we'll need to take care of the scope, any service instance that will hold component specific should be created in a child scope whilst the more generic or global services can be defined in the root scope. Here is an example of 4 small services I will use in a WebPart built with React

  1. PageContextService - responsible for holding all the required properties relevant for the whole page
  2. ComponentContextService - responsible for holding all the required properties relevant for the current component ONLY
  3. ListsService - responsible for fetching generic information from SharePoint lists and libraries
  4. DocumentsService - responsible for fetching my business specific information (for this example, it will be nothing more than the count of documents of a library)

 

PageContextService

This service has a configure() method that should be called at startup of the component, the other are only the properties from the page context we want to "expose". This service is global to the page, so it can remain located in the root scope and we will ensure that it is configured properly.

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

export interface IPageContextService {
    configure(spfxComponentContext: BaseComponentContext): void;
    webAbsoluteUrl: string;
    // TODO Expose any needed page scoped property
    // NOTE Avoid simply exposing the whole page context
    // Exposing only the needed information in that service allows to have better control
    // and better understanding of what's really page specific or not
    // It also mitigates risk of unexpected behavior in OTB API
}

export class PageContextService implements IPageContextService {

    private _webAbsoluteUrl: string;
    private _configured: boolean = false;

    constructor(private serviceScope: ServiceScope) {

    }

    public configure(spfxComponentContext: BaseComponentContext): void {
       // Note this service should be reconfigured at each call of the configure() method
       // Because the SPA context of SharePoint might involve changes in page context

        if (!spfxComponentContext) {
            throw new Error("The SPFx component context is not specified.");
        }


        const webAbsoluteUrlFromContext = spfxComponentContext.pageContext && spfxComponentContext.pageContext.web && spfxComponentContext.pageContext.web.absoluteUrl;
        if (webAbsoluteUrlFromContext && webAbsoluteUrlFromContext != this._webAbsoluteUrl) {
            this._webAbsoluteUrl = webAbsoluteUrlFromContext;
        }

        this._configured = (this._webAbsoluteUrl && true) || false;
    }

    public get webAbsoluteUrl(): string {
        if (!this._configured) {
            throw new Error("The Page Context Service has not been properly configured.");
        }

        return this._webAbsoluteUrl;
    }
}

export const PageContextServiceKey = ServiceKey.create<IPageContextService>("ypcode::PageContextService", PageContextService);

ComponentContextService

As the previous, this service has a configure() method that should be called at startup of the component, the other are only the properties specific to the component instance we want to "expose", it includes here the instance Id as well as the WebPart properties (we also could have mapped each single property to only expose the one we want). Notice in the configure() method I set a watch guard to avoid that the current instance of the service is configured more than once by mistake, that will ensure that we use a component-specific instance from the child scope.

import { BaseComponentContext } from "@microsoft/sp-component-base";
import { ServiceKey, ServiceScope } from "@microsoft/sp-core-library";
import { ISpContextServiceLabWebPartProps } from "../webparts/spContextServiceLab/SpContextServiceLabWebPart";

export interface IComponentContextService {
    configure(spfxComponentContext: BaseComponentContext, properties: ISpContextServiceLabWebPartProps,force?:boolean): void;
    instanceId: string;
    properties: ISpContextServiceLabWebPartProps;
    // TODO Expose any needed component specific property
    // NOTE Avoid simply exposing the whole WebPart/Component context
    // Exposing only the needed information in that service allows to have better control
    // and better understanding of what's really component specific or not
    // It also mitigates risk of unexpected behavior in OTB API
}

export class ComponentContextService implements IComponentContextService {

    private _instanceId: string = null;
    private _properties: ISpContextServiceLabWebPartProps = null;
    private _configured: boolean = false;

    constructor(private serviceScope: ServiceScope) { }

    public configure(spfxComponentContext: BaseComponentContext, properties: ISpContextServiceLabWebPartProps, force:boolean=false): void {

        if (this._configured && !force) {
            throw new Error("The ComponentContext Service has already been configured. Please review the configure() call");
        }

        this._instanceId = (spfxComponentContext && spfxComponentContext.instanceId) || null;
        this._properties = properties;
        this._configured = (this._instanceId && this._properties && true) || false;
    }

    public get instanceId(): string {
        if (!this._configured) {
            throw new Error("The Component Context Service has not been properly configured.");
        }

        return this._instanceId;
    }

    public get properties(): ISpContextServiceLabWebPartProps {
        if (!this._configured) {
            throw new Error("The Component Context Service has not been properly initialized.");
        }

        return this._properties;
    }
}

export const ComponentContextServiceKey = ServiceKey.create<IComponentContextService>("ypcode::ComponentContextService", ComponentContextService);

ListsService

This one is a generic service for fetching SharePoint list information, we can leave it at the root scope.

import { IList } from "../models/IList";
import { ServiceScope, ServiceKey } from "@microsoft/sp-core-library";
import { ComponentContextServiceKey } from "./ComponentContextService";
import { SPHttpClient } from "@microsoft/sp-http";
import { PageContextServiceKey } from "./PageContextService";

export interface IListService {
    getListByTitle(listTitle: string): Promise<IList>;
}

export class ListService implements IListService {

    constructor(private serviceScope: ServiceScope) {
    }

    public getListByTitle(listTitle: string): Promise<IList> {

        return new Promise<IList>((resolve, reject) => {
            // Ensure the service scope is completely configured before we can consume any service
            this.serviceScope.whenFinished(() => {
                const pageContext = this.serviceScope.consume(PageContextServiceKey);
                const spHttpClient = this.serviceScope.consume(SPHttpClient.serviceKey);
                const url = `${pageContext.webAbsoluteUrl}/_api/web/lists/getbytitle('${escape(listTitle)}')`;
                spHttpClient.get(url, SPHttpClient.configurations.v1)
                    .then(r => r.json())
                    .then(l => {
                        resolve({
                            id: l.Id,
                            title: l.Title,
                            itemsCount: l.ItemCount
                        } as IList);
                    });
            });
        });
    }
}

export const ListServiceKey = ServiceKey.create<IListService>("ypcode::ListService", ListService);

DocumentsService

This service will be responsible for what we want to display in our WebPart, since it is dependent on the component specific service, we will need to provide a specific instance of it in the child scope.

import { ServiceKey, ServiceScope } from "@microsoft/sp-core-library";
import { ListServiceKey } from "./ListsService";
import { ComponentContextServiceKey } from "./ComponentContextService";
import { WebPartContext } from "@microsoft/sp-webpart-base";

export interface IDocumentsService {
    getDocumentsCount(): Promise<number>;
}

export class DocumentsService implements IDocumentsService {
    constructor(private serviceScope: ServiceScope) { }

    public getDocumentsCount(): Promise<number> {
        return new Promise<number>((resolve, reject) => {

            // Ensure the service scope is completely configured before we can consume any service
            this.serviceScope.whenFinished(() => {
                const listService = this.serviceScope.consume(ListServiceKey);
                const componentContextService = this.serviceScope.consume(ComponentContextServiceKey);
                const docLibName = componentContextService.properties.documentLibraryName;
                listService.getListByTitle(docLibName).then(list => {
                    resolve(list.itemsCount);
                });
            });
        });
    }


}

export const DocumentsServiceKey = ServiceKey.create<IDocumentsService>("ypcode::DocumentsService", DocumentsService);

Let's make sure we configure all this properly, I use to create some AppStartup class or even simple configure() method, under src/ , in a startup/ folder I created here a configure.ts file

import { ServiceScope } from "@microsoft/sp-core-library";
import { BaseComponentContext } from "@microsoft/sp-component-base";
import { ComponentContextServiceKey, ComponentContextService } from "../services/ComponentContextService";
import { DocumentsServiceKey, DocumentsService } from "../services/DocumentsService";
import { PageContextServiceKey } from "../services/PageContextService";
import { ISpContextServiceLabWebPartProps } from "../webparts/spContextServiceLab/SpContextServiceLabWebPart";

export const configure = (componentContext: BaseComponentContext, properties: ISpContextServiceLabWebPartProps): Promise<ServiceScope> => {
    const rootScope = componentContext.serviceScope;

    return new Promise((resolve, reject) => {
        try {
            // The default implementation of all services (built-in AND custom) are available at root scope
            // We should be extremely cautious of altering a root-scoped service 'state' from a specific component instance
            // This might be not that important in the context of an app-part page        
            // All services directly usable from root scope should not have any component-specific dependencies
            const pageContextService = rootScope.consume(PageContextServiceKey);
            pageContextService.configure(componentContext);

            const scopedService = rootScope.startNewChild();
            // TODO Here create and initialize the component scoped custom service instances
            // TODO Initialize and configure scoped services based on component configuration

            // The component-scoped context should be created here to ensure it will remain tied to the proper instance
            const componentContextService = scopedService.createAndProvide(ComponentContextServiceKey, ComponentContextService);
            componentContextService.configure(componentContext, properties);

            // Create and provide new instances of services that uses component specific context (configuration, instance id, ...)
            // (e.g. In this example the Documents service relies of the component configuration)
            scopedService.createAndProvide(DocumentsServiceKey, DocumentsService);

            // Finish the child scope initalization
            scopedService.finish();

            resolve(scopedService);

        } catch (error) {
            reject(error);
        }
    });
};

I'll let you read the comments in this code exhibit above to understand what we do. The idea is that we always create a child scope that will pertain to the component instance, all the services from the root scope will be accessible from it anyway. The "tricky" part is that all the services that depends on the component specific services should be instantiated in that new child scope. That is probably not the best since, some of them can be generic enough that they actually don't need to be duplicated, but since they are created with the service scope, it seems to be the only way. This configure() method has then to be called in the init event of the component (the WebPart) class, and a reference to the newly created child service scope should be kept. This child scope reference can then be passed as property to the React component we use as you can see in the render() method.

import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version, ServiceScope } from '@microsoft/sp-core-library';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import {
  IPropertyPaneConfiguration,
  PropertyPaneTextField
} from '@microsoft/sp-property-pane';

import { FirstLevelSubComponent, IComponentProps } from './components/componentsHierarchy';
import { configure } from '../../startup/configure';
import { ComponentContextServiceKey } from '../../services/ComponentContextService';

export interface ISpContextServiceLabWebPartProps {
  documentLibraryName: string;
}

export default class SpContextServiceLabWebPart extends BaseClientSideWebPart<ISpContextServiceLabWebPartProps> {

  private componentServiceScope: ServiceScope;

  public onInit(): Promise<void> {
    // Make sure to return a resolved promise when configuration is done
    // This will ensure all services are properly configured so we can safely call serviceScope.consume() in any component 
    return configure(this.context, this.properties)
      .then(serviceScope => {
        this.componentServiceScope = serviceScope;
      }).catch(error => {
        console.error('An error occured while trying to initialize WebPart', error);
      });
  }

  public render(): void {
    const element: React.ReactElement<IComponentProps> = React.createElement(
      FirstLevelSubComponent,
      {
        serviceScope: this.componentServiceScope
      }
    );

    ReactDom.render(element, this.domElement);
  }

  public onPropertyPaneFieldChanged(property: string, oldValue: any, newValue: any) {
    // Update the value from configuration when changed
    const componentContextService = this.componentServiceScope.consume(ComponentContextServiceKey);
    componentContextService.properties[property] = newValue;
    this.render();
  }

  protected onDispose(): void {
    ReactDom.unmountComponentAtNode(this.domElement);
  }

  protected get dataVersion(): Version {
    return Version.parse('1.0');
  }

  protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
    return {
      pages: [
        {
          header: {
            description: "Settings"
          },
          groups: [
            {
              groupName: "Configuration",
              groupFields: [
                PropertyPaneTextField('documentLibraryName', {
                  label: "Name of the Documents library"
                }),
              ]
            }
          ]
        }
      ]
    };
  }
}

Let's check out what it looks like with a "deep" hierarchy of components

import * as React from "react";
import { ServiceScope } from "@microsoft/sp-core-library";
import { ListServiceKey } from "../../../services/ListsService";
import { IList } from "../../../models/IList";
import { DocumentsServiceKey } from "../../../services/DocumentsService";
import { ComponentContextServiceKey } from "../../../services/ComponentContextService";
import styles from "./component.module.scss";

export interface IComponentProps {
    serviceScope: ServiceScope;
}


export class FourthLevelSubComponent extends React.Component<IComponentProps, any> {

    constructor(props: IComponentProps) {
        super(props);

        this.state = {
            listInfo: null as IList,
            documentsCount: null,
        };
    }

    private get instanceId(): string {
        return this.props.serviceScope.consume(ComponentContextServiceKey).instanceId;
    }

    public componentWillMount() {

        const listService = this.props.serviceScope.consume(ListServiceKey);
        const componentContext = this.props.serviceScope.consume(ComponentContextServiceKey);

        listService.getListByTitle(componentContext.properties.documentLibraryName)
            .then(docLib => {
                this.setState({
                    listInfo: docLib
                });
            }).catch(error => {
                console.log("Error: ", error);
            });
    }

    private _getDocumentsCount() {
        const documentsService = this.props.serviceScope.consume(DocumentsServiceKey);
        documentsService.getDocumentsCount()
            .then(itemsCount => {
                this.setState({
                    documentsCount: itemsCount
                });
            });
    }

    public render() {
        if (!this.state.listInfo) {
            return <div>Loading...</div>;
        }

        return <div className={styles.component}>
            <div className={styles.title}>
                WebPart: 
                <span className={styles.instanceIdFromService}>
                    {this.instanceId}
                </span>
            </div>
            <br />
            <br />
            <h3>Loaded list</h3>
            ID: {this.state.listInfo.id}<br />Title: {this.state.listInfo.title}
            <br /><br />
            <button onClick={() => this._getDocumentsCount()}>Get count of documents</button>
            <br />
            {(this.state.documentsCount || this.state.documentsCount == 0) && <div>Count: {this.state.documentsCount}</div>}
        </div>;
    }
}

export class ThirdLevelSubComponent extends React.Component<IComponentProps, any> {
    public render() {
        return <FourthLevelSubComponent serviceScope={this.props.serviceScope} />;
    }
}


export class SecondLevelSubComponent extends React.Component<IComponentProps, any> {
    public render() {
        return <ThirdLevelSubComponent serviceScope={this.props.serviceScope} />;
    }

}


export class FirstLevelSubComponent extends React.Component<IComponentProps, any> {
    public render() {
        return <SecondLevelSubComponent serviceScope={this.props.serviceScope} />;
    }
}

As you can see, the only component property we need to pass all the way down to the fourth level component is the service scope. And even if the output itself is not much relevant without a look at the code, here is what it should display: workbench_webparts_instances

Each WebPart gets its own information from services. You can get the whole sample solution here

Conclusion

According to me, it makes everything more clear and maintainable to use only services like this. And I would prefer the services to be the only way of using all APIs in SPFx, that would make if much more consistent instead of having of context object and a properties object, and a HttpClient you can get a reference from the context etc, etc... Let's make it all services !!

But hey, that approach described here, I think would help to have a better structure for maintainable solutions! What do you think??

Please give your feed back on Twitter or here under in the comments!

Cheers,

Yannick

Other posts