In February I switched up my working situation. For more than 2 years, I've been working as a freelance developer, starting with some notable contract work with Deliveroo and latterly holoride in Munich.
Since leaving holoride in January last year, I've been working for several clients. Some, like Book A Street Artist and Organic Growth Team, were enjoyable and rewarding. Others, who will remain nameless, were not.
Juggling projects and dealing with ebbs and flows in workload proved challenging for me. I decided to start looking for permanent employment in the autumn. I landed a role at Opinary, having to complete some interviews and a coding challenge while away in Mexico for Christmas. I started my new role as a Senior Frontend Engineer at the start of February.
There are some great projects to work on, and currently, I'm building out a React component library. The components should be reusable across multiple other applications. The main ongoing project uses module federation (also known as micro frontends). The component library must be suitable for use across the many, small frontend applications.
That brings me to this week. I worked on building a reusable modal component in React, which reintroduced me to the topic of React Portals.
I had implemented a modal at holoride, so I was already familiar with the concept. This time, I thought I'd document the process and publish it here, so here goes.
I wanted to test drive my modal development where possible, so I started with my test file.
For this I'm using jest
and react-testing-library
. I'll omit the setup for brevity.
describe('<Modal />', () => {
const appendCustomRoot = (id: string) => {
const modalRoot = document.createElement('div')
modalRoot.setAttribute('id', id)
modalRoot.setAttribute('data-testid', 'root')
document.body.appendChild(modalRoot)
}
const removeCustomRoot = () => {
const root = document.querySelector('[data-testid="root"]')
root?.remove()
}
const renderComponent = ({
createRoot,
selectRoot,
rootId,
}: {
createRoot: boolean
selectRoot: boolean
rootId?: string
}) => {
createRoot && rootId && appendCustomRoot(rootId)
const selectedElement =
selectRoot && rootId ? document.getElementById(rootId) : null
return render(
<Modal
isOpen={true}
modalRoot={selectedElement}
data-testid={'modal'}
showCloseButton={true}
onClose={jest.fn()}
/>
)
}
afterEach(removeCustomRoot)
it('renders the modal in a custom root when a custom root is given', () => {
renderComponent({
createRoot: true,
selectRoot: true,
rootId: 'custom-root',
})
expect(screen.getByTestId('root')).toBeInTheDocument()
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
it('renders the modal in the default root if it is found and no root is given', () => {
renderComponent({
createRoot: true,
selectRoot: false,
rootId: 'modal-root',
})
expect(screen.getByTestId('root')).toHaveAttribute('id', 'modal-root')
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
it('creates a root and renders the modal when no root is given and the default root is not found', () => {
const { baseElement } = renderComponent({
createRoot: false,
selectRoot: false,
})
expect(baseElement.querySelector('#modal-root')).toBeInTheDocument()
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
Let's break this down. To make things simpler, this Modal component will be responsible only for determining how and where to create the React Portal. As a component library, I don't have control over the circumstances where it will be used. I want to make it as flexible as possible for the consumers (the developers and applications using it) to integrate. That's why the modal should simply work.
React portals need a root element to attach themselves to. Much like the application root component, which typically has an ID of root
in React, the modal needs a modal root.
But, what I can't know as the library author is what that will look like in the client application. I wrote 3 tests to cater for different situations.
- The client application defines and provides its own root element. This must be a DOM element, like what is returned from
document.querySelector
ordocument.getElementById
.document.getElementsByClass
wouldn't be suitable here because that returns a list of DOM nodes. - The client application has a root element with the ID of
modal-root
, but it hasn't specified it when calling the modal. The modal can find that element and use it as the modal root. - The client application doesn't provide its own root element and there is none with the
modal-root
ID. Here we'd like to create it on the client application and then create the portal within it.
Writing these descriptions was the easy part. Coding them was much more difficult and requires some fancy manipulation using react-testing-library
. Much of this was new to me.
What you see above is refactored. I didn't type it out like this the first time - it changed multiple times in the development process. Let's take a quick look at the more interesting parts.
appendCustomRoot
is a helper to append a new div
element into the document body to ensure that it exists for the test case. This was necessary for the first two test cases, where the modal root should already exist. In the first case it's found and passed in by the client application, in the second case it's found by our component library. Either way, it needs to exist in the client application. In the third test case, the point is that the modal root is missing, so we don't create one, and the library then is responsible for creating it.
removeCustomRoot
is another helper that undoes the first one. But wait, is this really necessary? Doesn't the testing framework clean up after itself automatically? Yes, but not completely. Since appending the custom root affects the document body, this is actually persisted between test cases. A curious side effect that was causing my test cases to subtly depend on each other. If there's anything we must strive for in our tests, it's that they're compltely independent from each other.
Running removeCustomRoot
in an afterEach
makes them independent.
renderComponent
is a simple abstraction that I often employ in such tests. It takes a lot of boilerplate and concentrates it in one function call. It makes each test case easier to read and isolates the data that's changing between them. With a nice, simple interface, with 3 attributes that need to be defined, it's trivial to reason about these test cases without reading dozens of lines of useless code.
The only other interesting point in these tests is the use of baseElement
and baseElement.querySelector
. I hadn't used or even come across baseElement
prior to this, so I had to do a bit of reading. Again, it comes back to the underlying DOM tree that is used to render the components. When using screen
directly from react-testing-library
or even the container
that is returned from render
, I didn't have access to the underlying DOM. That's what I really needed, because I had to verify that the modal root had been added to the DOM.
Tests done, let's look at the implementation.
export type Props = ModalScreenProps & {
modalRoot?: Element | null,
};
const Modal = ({ modalRoot, ...props }: Props) => {
let root = modalRoot ?? document.getElementById("modal-root");
if (!root) {
root = document.createElement("div");
root.setAttribute("id", "modal-root");
document.body.appendChild(root);
}
return createPortal(<ModalScreen {...props} />, root);
};
Now that we've already seen the tests, it's pretty easy to see what's going on here. (We'll come back to our ModalScreen
and ModalScreenProps
shortly.)
First we'll get the modal root that the client passed in. If that's undefined or if the client passed in null by trying to find a non-existent element, we'll find it ourselves. We use document.getElementById
with the ID we've chosen is suitable. If that's still not there, we'll go ahead and create it and append it to the body. Very simple, very clean.
Finally, we'll create a portal and render it in the root. From a TypeScript perspective, we don't have to check for the root's existence. Either it's defined, found or created. There's no doubt that it's a real DOM element.
The portal part of our component is done. We have a nice mechanism to allow the client to pass their own modal in. This could be useful for styling reasons, or if there are restrictions based on other technologies they might be using. There's also a fallback in place in case the client application isn't fully aware of the usage of the modal. It would be frustrating to add a modal component to your application, only for nothing to show up! This way, we're always going to be rendering the modal, if it's open. This brings us nicely to the next part - the so-called modal screen. Let's take a look.
We have specific criteria for this to provide flexibility to the client application. Let's take a look at what they are.
- The modal should show a close button (a cross in the corner) by default, but this can be removed if the client application wants to.
- The modal should be closed by clicking outside of it. This is normal behaviour that any user would expect. However, the client should have the ability to turn this off too.
- The modal receives the
onClose
function. This means the modal won't actually control its own state, the parent or container code will take care of that. This makes sense, but the modal also needs to be aware of that, in case we want to provide some content that also has the close function, like a custom button, or something else. - The modal receives the
isOpen
prop. This boolean will simply tell the modal whether or not it's open. Why? Well, I considered leaving this entirely up to the parent. After all, with React's conditional rendering, if the parent knows when it should or should not show the modal, it can do that, right? Yes, but again I wanted some flexibility here. For example, if we want to add some custom animation to the opening or closing, then conditional in the parent won't do the trick. The modal itself will need to know its own open state to act accordingly. This is a look to the future, because it's not required initially. Often that's a bad idea, but I kind of know this sort of feature will come, so it was a safe bet in this case.
There are some other bits and pieces that I implemented, but I'm going to skip over those because they're not super important right now.
So, let's have a look. The test cases are going to be a bit simpler here, even if there will be more of them. There's no funky business with manipulating the DOM, so it should be back to good old component testing with react-testing-library
, the way we know and love.
const renderComponent = ({
isOpen = true,
onClose = jest.fn(),
closeOnClickOutside = false,
children,
...props
}: Partial<Props> = {}) => {
render(
<ModalScreen
isOpen={isOpen}
onClose={onClose}
closeOnClickOutside={closeOnClickOutside}
{...props}
>
{children}
</ModalScreen>
);
};
describe("<ModalScreen />", () => {
beforeEach(jest.clearAllMocks);
it("renders the modal when isOpen is true", () => {
renderComponent({ isOpen: true });
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("does not render is isOpen is false", () => {
renderComponent({ isOpen: false });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders a close button when showCloseButton is true", () => {
renderComponent({ showCloseButton: true });
const closeButton = screen.getByRole("button", { name: "close" });
expect(closeButton).toBeInTheDocument();
});
it("does not render a close button showCloseButton is false", () => {
renderComponent({ showCloseButton: false });
const closeButton = screen.queryByRole("button", { name: "close" });
expect(closeButton).not.toBeInTheDocument();
});
it("closes when the close button is clicked", async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderComponent({ showCloseButton: true, onClose });
const closeButton = screen.getByRole("button", { name: "close" });
await user.click(closeButton);
void waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("can be closed by clicking outside when closeOnClickOutside is true", async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderComponent({ closeOnClickOutside: true, onClose });
await user.click(screen.getByTestId("modal-background"));
void waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("cannot be closed by clicking outside when closeOnClickOutside is false", async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderComponent({ closeOnClickOutside: false, onClose });
await user.click(screen.getByTestId("modal-background"));
expect(onClose).not.toHaveBeenCalled();
});
it("renders the children", () => {
const children = <div>Modal content</div>;
renderComponent({ children });
expect(screen.getByText("Modal content")).toBeInTheDocument();
});
});
The nice thing about this test suite is that every test is simple. Again, we've abstracted away the component rendering. This makes the setup a single line in most cases, and it's quick and easy to see what the test is about both from the description and the component setup. The first couple are so trivial, I'm not going into them. The last is just about rendering the children. The only snippet that might be considered interesting here is the modal closing functionality. Since we're passing in the close functionality, we don't need any expectations about the modal really being closed. The parent component will handle the isOpen
prop and if it doesn't well then that's too bad for them - the interface (the props) is clear enough that such an error should be impossible.
There's a test to make sure the onClose
function is called when the background is clicked and the closeOnClickOutside
is true. And also one that expects the onClose
not to be called if it's false. It's all pretty standard stuff.
Originally, I wanted to create one single component which encapsulated all of this logic, but breaking it down between the portal functionality (which can be used for things other than modals) and the modal functionality ended up being a much cleaner implementation. I like the way the tests are separated and deal with very different aspects of what makes a modal a modal in React.
Let's see how the implementation looks. This isn't quite as straightforward as the implementation of the Modal component itself.
export type Props = PropsWithChildren<{
isOpen: boolean
onClose: () => void
showCloseButton?: boolean
closeOnClickOutside?: boolean
}>
const ModalScreen = ({
isOpen,
onClose,
showCloseButton = true,
closeOnClickOutside = true,
children,
...props
}: Props) => {
if (!isOpen) return null
const handleBodyClick = (e: MouseEvent<HTMLElement>) => {
e.stopPropagation()
}
return (
<ModalBackground
data-testid="modal-background"
{...(closeOnClickOutside && { onClick: onClose })}
>
<ModalBody
{...props}
aria-modal="true"
role="dialog"
onClick={handleBodyClick}
>
{showCloseButton && (
<CloseButton onClick={onClose}>
<CloseIcon />
</CloseButton>
)}
<Content>{children}</Content>
</ModalBody>
</ModalBackground>
)
}
A quick note about the Props
type. The type is imported as ModalScreenProps
in our Modal component in order to keep the same interface but with the addition of the modalRoot
. It makes sense for the Modal
to accept those props, so the client can easily pass in the correct ones, but to not declare them on the modal, since it's only taking the modalRoot
and passing the rest on to the ModalScreen
. This keeps the interface nice and clean.
Now on to the ModalScreen
component itself.
First things first: if the modal isn't open, it shouldn't render anything. As discussed above, this could be the responsibility of the parent, but I wanted the modal to know about its own openness, so I added it here.
Next I have a click handler which handles a click to the modal body as opposed to the modal background. This is so that clicking on the modal won't close it in the case that closeOnClickOutside
is true. The way clicks bubble up through their ancestors in the browser means that a click on a child also registers as a click on all its ancestors. Since the background is the parent of the body, we need to prevent that bubbling up, also known as propagation. This is done with the event's own stopPropagation
method. A function that you probably know well if you worked with JavaScript and the DOM before React and friends became popular.
Then we have our markup. The background, which covers the entire screen - we'll see some CSS a bit later. I've added a test ID to make the testing a bit simpler and because a background doesn't typically have an accessible role, as far as I could see from a quick search online. We needed this to test the background click functionality above.
The next line is a little bit unusual. I confess I haven't written many props like this before. To break it down, I'm adding a click handler to the background by destructing the object { onClick: onClose }
, but only in the case that closeOnClickOutside
is true. If it's false, I didn't want to add onClick={null}
or something weird like that but rather assign no click handler whatsoever.
Then we have our body with some sensible accessible attributes like role="modal
and aria-modal="true"
Finally our close button, which is conditionally rendered and is assigned the close handler when clicked.
For now, we're passing the onClose
prop directly into where it's needed, but in the future, this might be wrapped into a function to add some additional functionality.
OK, we've got a good and flexible implementation here. Let's have a look at a few snippets of CSS that might be interesting. First, our modal background.
.modal-background {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: center;
padding: 30px;
overflow: scroll;
&:before {
content: "";
position: absolute;
inset: 0;
background-color: grey;
opacity: 0.95;
}
}
.modal-body {
...
margin-top: auto;
margin-bottom: auto;
}
We use position fixed
to make it always cover the whole screen and inset: 0
which is a shorthand for setting top
, bottom
, left
and right
to 0. z-index: 100
is a bit arbitrary. It's possible that this will be changed in the future, but clients need to be able to cover everything with the modal when they need, although there might be some elements, like chatbots or the like, that should go in front of the modal. 100 allows for plenty of room for manoeuvre.
The modal body has margin-top
and margin-bottom
auto
. This is so that it's always centred vertically. The display: flex
and justify-content: center
on the background already deals with horizontal centering. I didn't use align-items: center
because this would actually cut off the top of the modal in case it becomes too tall for the screen. This way, with overflow: scroll
, the whole modal is still usable in the client applications.
Let's also just take a little look at a complex-looking but elegant piece of CSS that I devised for the client applications that I have control over - the micro frontends.
body:has(#modal-root:not(:empty)) {
overflow: hidden;
}
It looks a bit complicated at first glance, but it reads quite easily. When the body has a #modal-root
nested within it that is not empty, then apply the overflow: hidden
. If there was one aspect of the modal development I wasn't completely happy with, it was requiring the client application to add CSS to prevent scrolling the page with the modal open. This is the behaviour users expect.
There are ways to do this from the library, like programmatically modifying the body styles inline. However, I decided not to meddle with the client's body tag because this could cause some unusual bugs, especially if they have their own inline styles for whatever reason. I thought it best to leave it alone and allow the client application to decide how to handle it.
That wraps it up. We now have a simple Modal component that can contain whatever contents is needed. The client is also free to style the contents in any way it wants. The colour scheme is opinionated, as is usually the case in component libraries. After all, it's a reflection on the brand. We could do some work to extend this to allow for colour changes, or even a light and dark mode. But that's beyond the scope for the time being.