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:
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.
Top comments (0)