SPFx – Working with React Router and React Context

2020-05-04

As your web part project grows you might start looking into routing as well as an easier way to manage global properties, without passing props. The concepts of routing and context go well hand in hand which is why I will do a short introduction to both in this post. You can read more on React Router here and React Context here.

React Context

First, we declare the global state object and context. Instead of passing around service classes and/or context we will keep a repository instance globally. It is initiated at our App start and then accessed from various components, via React Context. Find complete sample code on Github. Start with setting up an interface for our demo item as well as a simple repository from which we fetch our data.

IDemoItem.ts:

export interface IDemoItem {
    Id: number;
    Title: string;
    Description: string;
}

AppRepository.ts:

import { WebPartContext } from '@microsoft/sp-webpart-base';
import {IDemoItem} from './IDemoItem';

export class AppRepository {        
    private _context: WebPartContext;
    private _dummyItems: IDemoItem[];

    constructor(context: WebPartContext) {
    // Context to fetch from SharePoint. 
    // In demo we just use dummy data.
        this._context = context;
        this._dummyItems = [];
        this.buildDummyData();
    }

    private buildDummyData () : void {
        for (let i = 0; i < 10; i++) {
            this._dummyItems.push({
                Id: i,
                Title: "Test" + i,
                Description: "Description" + i
            });
        }
    }

    public getAllItems () : IDemoItem[] {
        return this._dummyItems;
    }

    public getItemById (id: number) : IDemoItem {
        return this._dummyItems.filter(x => x.Id === id)[0];
    }
}

Moving on we setup the global state and context.

GlobalState.ts:

import * as React from 'react';
import {AppRepository} from './AppRepository';

interface IAppGlobalState {    
    appRepository: AppRepository;
}

export const appGlobalState : IAppGlobalState = {    
    appRepository: null
};

export const appGlobalStateContext = React.createContext(appGlobalState);

The only property, appRepository, will be set in our main app initiation and then to be consumed from our components.

RoutingAndContextApp.tsx:

import * as React from 'react';
import { HashRouter, Redirect, Route, Switch } from 'react-router-dom';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {appGlobalState, appGlobalStateContext} from './GlobalState';
import {AppRepository} from './AppRepository';
import FormComponent from './FormComponent';
import ListViewComponent from './ListViewComponent';

interface IRoutingAndContextAppProps {
    context: WebPartContext;
}
const RoutingAndContextApp: React.FC<IRoutingAndContextAppProps> = ({context}) => {
    const [loading, setLoading] = React.useState(true);

    React.useEffect(() => {
        appGlobalState.appRepository = new AppRepository(context);
        setLoading(false);
    }, []);

    if (loading) {
        return <div>Loading...</div>;
    }

    return (
        <div>
            <appGlobalStateContext.Provider value={appGlobalState}>
            </appGlobalStateContext.Provider>   
        </div>
    );
};
export default RoutingAndContextApp;

The repository is instantiated and kept in state. Components inside the provider can consume the state, so this is where the routing will be setup.

React Router

Add react router and TypeScript support to the project:

npm install react-router-dom
npm install @types/react-router-dom

The app will consist of two main components:

  • ListView - lists all items which is implemented via the route /items
  • Form - view a single item which is implemented via the route /items/:id

The routes are added inside of the context provider:

<appGlobalStateContext.Provider value={appGlobalState}>
    <HashRouter>
        <Switch>
            <Route sensitive exact path="/">
                <Redirect to={`/items`}></Redirect>
            </Route>
            <Route path="/items" sensitive exact component={ListViewComponent} />
            <Route path="/items/:id" exact sensitive component={FormComponent} /> 
        </Switch>
    </HashRouter>
</appGlobalStateContext.Provider>  

The first route declares that if we have an exact match of “/” then continue to “/items”. This is typically a landing route. The next route declares that if we have an exact match of “/items” then continue to the component ListViewComponent. While the third says that if something is found after /items/ then continue to the FormComponent. We append the keywords sensitive and exact to avoid conflicts between “items”.

Now the global state can be consumed from the components by adding a reference to the state object and then using the React hook useContext.

import {appGlobalStateContext} from './GlobalState';

const {appRepository} = React.useContext(appGlobalStateContext);

ListViewComponent.tsx

import * as React from 'react';
import {IDemoItem} from './IDemoItem';
import {appGlobalStateContext} from './GlobalState';
import { Link } from 'react-router-dom';

interface IListViewComponentProps {}

const ListViewComponent: React.FC<IListViewComponentProps> = () => {
    const {appRepository} = React.useContext(appGlobalStateContext);
    const [items, setItems] = React.useState<IDemoItem[]>([]);

    React.useEffect(() => {
        const getItems = async () => {            
            const allItems = appRepository.getAllItems();            
            setItems(allItems);
        };

        getItems();
    }, []);

    if (items.length === 0) {
        return <div>Loading...</div>;
    }

    return (
        <div>
            {items.map(item => {
                return (
                    <p>
                        <Link to={`/items/${item.Id}`}>{item.Title}</Link>
                    </p>
                );
            })}
        </div>
    );
};
export default ListViewComponent;

The Link element is included in react router and will redirect us to /items/:id which in turn leads to the FormComponent. The hook useParams is used to retrieve the id:

interface IFormComponentRouterProps {
    id: string;
}

const {id} = useParams<IFormComponentRouterProps>();

FormComponent.tsx

import * as React from 'react';
import {IDemoItem} from './IDemoItem';
import {appGlobalStateContext} from './GlobalState';
import { useParams } from 'react-router-dom';

interface IFormComponentProps {}
interface IFormComponentRouterProps {
    id: string;
}
const FormComponent: React.FC<IFormComponentProps> = () => {
    const {id} = useParams<IFormComponentRouterProps>();
    const {appRepository} = React.useContext(appGlobalStateContext);
    const [item, setItem] = React.useState<IDemoItem>(null);

    React.useEffect(() => {
        const getItem = async () => {
            const result = appRepository.getItemById(Number(id));
            setItem(result);
        };

        getItem();
    }, []);
    
    if (!item) return <div>Loading...</div>;

    return (
        <div>
            <p><b>Title: </b>{item.Title}</p>
            <p><b>Description: </b>{item.Description}</p>
        </div>
    );
};
export default FormComponent;

Serve your project and you should be able to navigate using the links or by modifying the URL. Native browser navigation is supported by default. To programmatically change routes, look into the hook useHistory from react router. Full code on Github.