/ react

How I use React.Context

I get a lot of questions about how I'm using React.Context. A lot of people overuse it, and their applications become messy.

I've had several conversations like the following:

  • [someone]: I don't use React.Context. It makes my application quite disjointed (or some more colorful term), I just use Redux or Apollo.
  • [me]: They both use React.Context under the hood.
  • [someone]: Yes, but this is an implementation detail, I don't use the context directly.
  • [me]: That's exactly how you should use React.Context -- as an implementation detail. Then you build an API on top of it and don't touch the context directly.

One example is YourStack's toast system.

This is how it looks:

Toast Screenshot

As a developer, you are going to use it like this:

import { useToast } from '~/utils/toast'

function ShowToast() {
  const open = useToast();

  const onClick = () => open({
    icon: '🚨',
    title: 'This is the title for this prompt',
    content: <strong>Content</strong>,
  });

  return <button onClick={onClick}>open</button>;
}

The setup looks like this:

import { ToastProvider } from '~/utils/toast'

// the "Provider" pyramid
<ApolloProvider>
  <ToastProvider>
    <ModalProvider>
	    <Layout>
	      {children}
	    </Layout>
	    // notice those .Content components
	    // having those allow us to show toast message from modal and open modal from a toast message
        // (look below for implemenation)
	    <ModalProvider.Content />
	    <ToastProvider.Content />
    </ModalProvider>
	</ToastProvider>
</ApolloProvider>

Only openToast and ToastProvider are exposed in the public API of the toast system. There is no mention of React.Context.

Here is the implementation of the toast system:

interface IToastOptions {
  title: string;
  icon?: string | React.ReactNode;
  type?: 'notice' | 'success' | 'alert';
  // We support content that can be
  // - text
  // - React node
  // - any function with a "close" callback that returns a React node
  content?: string | React.ReactNode | ((close: () => void) => React.ReactNode);
}

interface IToast extends IToastOptions {
  id: number;
}

// the actual context contains
// not only the toast object, but
// also the helper functions to manage it
// (those aren't accessible outside the module)
interface IToastContext {
  toast: IToast | null;
  open: (toast: IToastOptions) => void;
  close: () => void;
}

const ToastContext = React.createContext<IToastContext>({
  toast: null,
  open() {},
  close() {},
});

// each toast get an unique ID, so key={toast.id} triggers re-render
let uid = 0;

export function ToastProvider({ children }: { children: React.ReactNode }) {
  // this is a popular pattern when using contexts
  // having a state of root component passed to the context
  const [toast, setToast] = React.useState<IToast | null>(null);

  // because the actual context value is not a simple object
  // we cache it, so it doesn't trigger re-renderings
  const contextValue = React.useMemo(
    () => ({
      toast,
      open(value: IToastOptions) {
        // this is the small "hack" to get unique ids
        setToast({ ...value, type: value.type || 'notice', id: uid += 1 });
      },
      close() {
        setToast(null);
      },
    }),
    [toast, setToast],
  );

  return (
    <ToastContext.Provider value={contextValue}>
      {children}
    </ToastContext.Provider>
  );
}


// initially this was just inlined in "ToastProvider"
// however, we needed to integrate with our modal system
// and we needed to be explicit about where the toasts are rendered
ToastProvider.Content = () => {
  const context = React.useContext(ToastContext);

  if (!context.toast) {
    return null;
  }

  return (
    <Toast
      key={context.toast.id}
      toast={context.toast}
      close={context.close}
    />
  );
};

export function useToast() {
  return React.useContext(ToastContext).open;
}

interface IToastProps {
  toast: IToast;
  close: () => void;
}

function Toast({ toast, close }: IToastProps) {
  // UI for the toast
  // just regular component
}

Couple of things to notice:

  • ToastProvider is managing the state
  • it passes helpers and state down the tree and hides the "real" context
  • the "real" context is inaccessible from outside
  • you can only show a toast via useToast

Now, imagine having to implement some of the following features:

  • New UI for the toast messages
  • Stacking of toast messages - showing multiple toasts on the screen
  • Hide toast messages after a timeout

Those would be quite easy to implement, barely an inconvenience, because everything is encapsulated.

In YourStack, we only have 3 instances of React.Context (written by my team) - toast, modal, moderation systems. Notice the word "systems". They are all isolated as if they were 3rd party libraries. ProductHunt is the same.

Our modal system has a similar API. It has many more features like code-split, GraphQL fetching, loading, error handling, themes, nesting, and URLs. It deserves its own blog post someday.

Conclusion

React.Context is useful and should be used with care. We shouldn't reach for it just because we're too lazy to pass properties around.
My advice is to encapsulate its uses as if they are 3rd party libraries and have clear APIs for this. Don't go overboard.

If you have any questions or comments, you can ping me on Twitter.