DEV Community

Cover image for How To Write Material UI Components Like Radix UI And Why Component Composition Matters?
Guilherme Ananias for Woovi

Posted on

How To Write Material UI Components Like Radix UI And Why Component Composition Matters?

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:

Component tree

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');

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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,
  });
};
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)