How To Write Material UI Components Like Radix UI And Why Component Composition Matters?
Guilherme Ananias
Posted on January 17, 2024
One of the core strengths of React is its components. Components are just functions, and like them, you can abstract them into a way that you can couple it matching multiple scenarios in your codebase.
Having components with the ability to be composed in different scenarios gives you a powerful way to write your front-end kinds of stuff.
Think that you have a state that handles the opening state of a modal, but your entire modal scope has written in multiple, different components, and has 5 or 6 levels of nesting between the children. How many components you will need to pass the setOpen
and open
props, or even write a context that shares the state between all the component trees?
It won't scale and will be too boring to write and maintain it. It's the kind of code that does not need to be overcomplicated, right? Then, why not handle these primitive components more easily?
Radix UI and The Composition Flow
In my opinion, Radix UI is one of the best component libraries in the React environment at the moment. It's really cool what they're doing around this library and the approach to improving the developer experience with the components.
The example that we will use for today comes from the Dialog, as you can see accessing their documentation, the Dialog component is composed of some useful components: Root
, Trigger
Content
, Title
, Close
and others.
Right, but why does it matter? Because these components give you the ability to compose the Dialog tree. Like this image below:
In this case, we will have two different dialogs: A and B. Both have their own actions with their own props like each one has a different color, but both share the same content. You just need to import this component into the tree and everything will work as expected.
This is the magic of composing components.
Material UI And Radix UI, Not Enemies, But Friends
Here, at Woovi, our design system has been wrote using [MUI](https://mui.com/. But, in my opinion, I have some pain points considering how MUI built their components, most focusing on the fact of how they expose their component APIs and how they handle the component structure.
As a reference, see how they built the Dialog component, it would fail down into the same problem that I mentioned before, you would need a way to handle the entire logic for handling open state by your own hand, and you don't have a way to escape from that.
Thinking about that, so why not combine business with pleasure? It's when I reached the idea of writing our components following the same philosophy from Radix, that it should have the ability of being composed.
It's All About Context
At that moment, I opened the Radix UI repository and started to understand how things work under the hood and how could I reimplement it into our scenario. In this case, the magic behind Radix is just contexts and how they use it in a cool way.
As you can see in the code here, what they have a function called createContextScope
that will provide for you a function that creates your context and another function that will give a composed scope for your new component. It's an approach to avoid collision between contexts if you have two or more components nested.
What matters in fact for us is just the context for now. You can see how they're building it here: createContextScope. Now, using this function, you'll reach this behavior:
const [DialogProvider, useDialogContext] = createDialogContext<DialogContextValue>('Dialog');
In this case, it gives you the DialogProvider
component which will be what we want here. The useDialogContext
is just a simple abstraction over the useContext
, we'll assume this:
const useDialogContext = () => {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error("Should be used only inside DialogContext scope");
}
return context;
}
Now, that you know everything that happens under the hood in Radix, what we will do is:
- Share every state that you want to be accessed by the children in the provider;
- write the components that will be consumed by this dialog;
Let's see how I implemented it in our codebase.
Dialoguing For Us
The first step should be to write our context provider, in our case, we will call this the Dialog
, right?
// Dialog.tsx
type DialogContext = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const DialogContext = createContext<DialogContext | null>(
null,
);
export const useDialogContext = () => {
const context = useContext(DialogContext);
if (!context) {
throw new Error('Only should be used on the Dialog scope');
}
return context;
};
const Dialog = (props: DialogProviderProps) => {
const { children, isOpen = false } = props;
const [open, setOpen] = useState(isOpen);
const value: DialogContext = {
open,
setOpen,
};
return (
<DialogContext.Provider value={value}>
{children}
</DialogContext.Provider>
);
};
This will be our core component, it's where all the logic around their state will work.
Now, we'll need three things that are missing, right? The title of the dialog, the button to open, and the content. Let's start writing the trigger:
// DialogTrigger.tsx
import React from 'react';
import { useDialogContext } from './Dialog';
type DialogTriggerProps = {
children: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLElement>;
};
export const DialogTrigger = (props: DialogTriggerProps) => {
const { setOpen } = useDialogContext();
const { children, ...triggerProps } = props;
const handleToggleOpen = () => {
setOpen(true);
};
const handleOnClick = () => {
triggerProps.onClick();
handleToggleOpen();
}
// the cloneElement here is just a simplified way of the `asChild` similar behavior, I rewrote the same component injecting the new props
return React.cloneElement(children as React.ReactElement, {
...triggerProps,
onClick: handleOnClick,
});
};
With the DialogTrigger
written, now we can be able to open the dialog, but we'll need to display both the title and content yet:
// DialogContent.tsx
import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Paper from '@mui/material/Paper';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import { useTheme } from '@mui/material/styles';
import type { SxProps } from '@mui/material/styles';
import React from 'react';
import { useDialogContext } from './Dialog';
import { composeStyles } from '../utils/composeStyles';
type DialogModalProps = {
children: React.ReactNode;
sx?: SxProps;
};
// this is the core component, it's where all the modal content will be wrapped into
export const DialogModal = ({
children,
sx,
}: DialogModalProps) => {
const theme = useTheme();
const { open, setOpen } = useDialogContext();
const handleCloseModal = () => {
setOpen(false);
};
return (
<Modal
open={open}
onClose={handleCloseModal}
closeAfterTransition
>
<Paper>
{children}
</Paper>
</Modal>
);
};
type DialogContentProps = {
children: React.ReactNode;
dividers?: boolean;
sx?: SxProps;
};
// the root component for the dialog data area
export const DialogContent = (props: DialogContentProps) => {
const theme = useTheme();
const { children, dividers = true, sx } = props;
return (
<Box sx={{ display: 'flex' }}>
<DialogContent dividers={dividers}>{children}</DialogContent>
</Box>
);
};
// the title for the dialog modal
export const DialogTitle = (props: DialogTitleProps) => {
const { children } = props;
return <DialogTitle id='dialog-title'>{children}</DialogTitle>;
};
Cool. So now, we have almost everything, it's just missing the last component: the actions of each dialog, where the buttons that will do something will appear.
// DialogActions.tsx
import DialogActions from '@mui/material/DialogActions';
type DialogActionsProps = {
children: React.ReactNode;
};
export const DialogActions = (props: DialogActionsProps) => {
const { children } = props;
return <DialogActions>{children}</DialogActions>;
};
Cool. With it, we will have a similar behavior to that found in Radix UI components. As an example, you can write one dialog component like this:
import { Dialog } from './Dialog';
import { DialogActions } from './DialogActions';
import { DialogContent, DialogTitle, DialogModal } from './DialogContent';
import { DialogTrigger } from './DialogTrigger';
export const FeatureDialog = () => {
return (
<Dialog>
<DialogTrigger>
<button>Open</button>
</DialogTrigger>
<DialogModal>
<DialogTitle>Any title here because I don't have a cool idea</DialogTitle>
<DialogContent>
<p>Cool modal!</p>
</DialogContent>
<DialogActions>
{/* insert some actions here */}
</DialogActions>
</DialogModal>
</Dialog>
);
}
Without handling any state, just a clean JSX with all things handled under the hood. I'm biased, but I think that it's beautiful.
Conclusion
Composition is a good pattern around your codebase, it's easier to maintain and scale in your front-end components.
As I said, Radix is one of the best component libraries in the React environment at the moment, they have a lot of cool philosophies that you can share into your own UI library or replicate for the library that you want like I do with MUI.
In case, if you are curious, I suggest you do a deep dive into the Radix components, it's really cool to see how they implemented some kinds of stuff, they REALLY did a great job on that.
Woovi is a Startup that enables shoppers to pay as they like. To make this possible, Woovi provides instant payment solutions for merchants to accept orders.
If you want to work with us, we are hiring!
Photo by Julia Kadel on Unsplash
Posted on January 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 8, 2024