If you’re using React to build a web app or a React Native mobile application, chances are your app has a handful of initialization tasks: things that always need to happen before the main application mounts and runs.

Some examples of these responsibilities include:

  • Loading previously-saved settings.
  • Creating global-ish objects, like a single shared API_CLIENT instance.
  • Deciding whether the user is logged in, and fetching their current profile if so.
  • Checking whether there is a newer version of the application available.

For this type of work, there’s a handy pattern I always end up using, which leverages React Context Providers to streamline things. I call it the “bootloader pattern” because it reminds me of what low-level hardware bootloaders do to start up real machines, but that’s just my cute name for it.

What makes this pattern awesome? You get two very nice properties basically for free:

  1. Clear linear dependencies. It’s very natural to express and see linear dependencies between tasks, such as “figure out the environment before anything can make an API call”.
  2. Easily provide strong, simple guarantees to the “main” app. Your initialization tasks can make sure they’re ready before any child component mounts.

Let’s look at a contrived example.

An example app

Here’s a small contrived example of a web app.

/** Main app entrypoint. */
function Root() {
    return (
        <SettingsProvider>
            <ApiClientProvider>
                <CurrentUserProvider>
                    <App />
                </CurrentUserProvider>
            </ApiClientProvider>
        </SettingsProvider>
    );
}

In the example above, pretend <App /> is the main application: a rich client-side web application or React Native app. We’re going to focus on everything that must happen before that component—the main app—is mounted.

Let’s imagine that in this app, there are a few critical initialization tasks:

  1. Load optional settings from device-local storage.
  2. Create a shared API client instance, configured for the right environment.
  3. Load the currently-active user from the API, and expose it for all other components.

Hopefully you can see how each task depends on the previous one, both from the prose above and from looking at the JSX itself.

Some of these tasks might be able to complete synchronously, but others (such as fetching the current user) may be unavoidably asynchronous. This reveals a powerful tool of Context Providers and this pattern: each provider can prevent children from mounting, and/or mount something else, depending on the contract it wants to expose.

An example implementation should help illustrate this. Let’s dig in!

<SettingsProvider/>: Loading saved settings

I like it when my apps let me toggle between environments during development. Of course, changing environments may have far-ranging consequences within the app: we’ll probably need to use a different set of API endpoints, we might need to scope or access local storage differently, and so on.

This kind of core and foundational setup makes “choose the environment” an ideal early initialization task and well-suited for our pattern. Let’s create a SettingsProvider that takes care of managing this state.

import { createContext, ReactNode, useState, useEffect, useMemo } from 'react';

export enum Environment {
  Staging = 'staging',
  Production = 'production'
}

type SettingsContextType = {
  environment: Environment;
  setEnvironment: (env: Environment) => void;
};

export const SettingsContext = createContext<SettingsContextType>({} as SettingsContextType);

export function SettingsProvider({ children }: { children: ReactNode }) {
  const [environment, doSetEnvironment] = useState<Environment>(
    (localStorage.getItem('environment') as Environment) || Environment.Production
  );

  const setEnvironment = useMemo((env: Environment) => {
    if (env !== environment) {
      localStorage.setItem('environment', env);
      doSetEnvironment(env);
    }
  }, [environment]);

  return (
    <SettingsContext.Provider value={{ environment, setEnvironment }}>
      {children}
    </SettingsContext.Provider>
  );
}

With that provider mounted, a child component anywhere in the app can always fetch the current environment:

const { environment } = useContext(SettingsContext);

Similarly, any child component can change the environment, for example in a developers-only popup, using the exported setEnvironment callback function.

Finally, and most powerfully, you can write all of your components with the assurance that they will be re-rendered if and when the environment is ever changed.

Let’s look at our next provider to see how simple it is to take advantage of this.

<ApiClientProvider />: Expose a shared API client

We know the app has multiple environments (staging and production). Let’s now assume that we have more than a few call sites within the app that need to make API calls.

Rather than force each callsite to look up and properly initialize the API client based on environment, something that would be repetitive and tedious, let’s create a shared instance that’s already ready to go.

import { createContext, useContext, ReactNode, useMemo } from 'react';
import { SettingsContext, Environment } from './SettingsProvider';

class ApiClient {
  constructor(private baseUrl: string) {}

  async getCurrentUser() {
    const response = await fetch(`${this.baseUrl}/me`);
    return response.json();
  }
}

type ApiClientContextType = {
  client: ApiClient;
};

const ApiClientContext = createContext<ApiClientContextType>({} as ApiClientContextType);

export function ApiClientProvider({ children }: { children: ReactNode }) {
  const { environment } = useContext(SettingsContext);
  
  const client = useMemo(() => {
    const baseUrl = environment === Environment.Production 
      ? 'https://api.example.com'
      : 'https://staging-api.example.com';
    return new ApiClient(baseUrl);
  }, [environment]);

  return (
    <ApiClientContext.Provider value={{ client }}>
      {children}
    </ApiClientContext.Provider>
  );
}

export const useApiClient = () => useContext(ApiClientContext);

Now any component can use our convenience method to grab an API client.

const { client } = useApiClient()

Once again, our use of context providers at the root of our application means this client will be reconfigured—and a new correctly-configured client will be initialized—any time an upstream provider changes.

<CurrentUserProvider />: Tying it all together

Finally, let’s build a provider that leverages the previous providers.

Most apps need access to the “current” user in a lot of places. Let’s make a context provider that does that. Let’s also create and enforce some additional guarantees for the benefit of all children:

  1. Children components will not render until we’ve tried to fetch the current user once.
  2. If the user is logged out, we’ll show <LoggedOutView /> instead of the main application.
import { createContext, useContext, ReactNode, useState, useMemo, useEffect } from 'react';
import { useApiClient } from './ApiClientProvider';

type CurrentUser = {
  id: string;
  name: string;
};

type CurrentUserContextType = {
  currentUser: CurrentUser | null;
  fetchCurrentUser: () => Promise<void>;
  logOut: () => Promise<void>;
};

const CurrentUserContext = createContext<CurrentUserContextType>({} as CurrentUserContextType);

export function CurrentUserProvider({ children }: { children: ReactNode }) {
  const [isLoading, setIsLoading] = useState(true);
  const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
  const { client } = useApiClient();

  const fetchCurrentUser = useMemo(async () => {
    setIsLoading(true);
    try {
      const user = await client.getCurrentUser();
      setCurrentUser(user);
    } finally {
      setIsLoading(false);
    }
  }, [client]);

  useEffect(() => {
    fetchCurrentUser();
  }, [fetchCurrentUser]);

  const logOut = useMemo(async () => {
    await client.logOut();
    setCurrentUser(null);
  }, [client]);

  return (
    <CurrentUserContext.Provider value={{ currentUser, fetchCurrentUser, logOut }}>
      {isLoading ? <div>Loading...</div> : (
        currentUser ? children : <LoggedOutView />
      )}
    </CurrentUserContext.Provider>
  );
}

export const useCurrentUser = () => useContext(CurrentUserContext);

Guidelines for builders

After many years of usage with this pattern, it’s been fantastic for simplifying and centralizing all the core responsibilities in my apps. It keeps initialization responsibilities nicely isolated from the “main” app’s structure, and provides guarantees which simplify downstream component logic.

Like any pattern, be cautious of the ways it can hurt you:

  • Try not to overdo it. You probably don’t need dozens of discrete context providers. Each new provider has cost: in rendering time (perhaps marginally so); in stack depth; and certainly in code cognitive load. Try to group logically similar responsibilities in the same provider.
  • Be mindful of rerendering. When your ContextProvider changes a provided property, it will cause a re-render of child components. This is a performance consideration for any component, but it’s especially important for these components which sit at the root of you app.

Thanks to Alec F. for reviewing a draft of this post.