context pattern with modals
By Per Fröjd
- typescript
- react
- snippets
Before I start
I initally read about this implementation here, when I was looking for ways of implementing a better modal. A lot of the groundwork was already within the above article, so big props to the author.
Why
I’ve always considered modals a messy component to implement, because it should be available everywhere, be flexible enough to contain multiple different types of components, and also be able to receive data, props and everything else you might imagine.
Note that this post will mainly be about how to handle logic and reusability rather than styling and accessibility (even though those are very important too).
Previous implementations tends to end up something like (some pseudocode):
export type ModalData = {
// one type per modal
}
// A ModalContext
function showModal(id: string, args: ModalData) {
switch (id) {
case 'editUser': {
return <EditUserModal {...args} />
}
case 'removeUser': {
return <RemoveUserModal {...args} />
}
}
}
// Invoked over here, different file.
function MyComponent(users: Array<User>): React.ReactElement {
const { showModal } = useModalContext()
function removeUser(user: User) {
showModal('removeUser', { id: user.id })
}
function editUser(user: User) {
showModal('editUser', { id: user.id })
}
return (
<UserTable>
{...data.map(user)}
<UserRow>
{...user}
<UserButtons>
<EditUserButton onClick={editUser(user)} />
<RemoveUserButton onClick={removeUser(user)} />
</UserButtons>
</UserRow>
</UserTable>
)
}
My biggest gripe with this approach is that I have to pass around my user between these different components, and the context needs to be very aware of how the props look. If I need to display a different state in my modal, I now need to make sure it’s added in the correct Modal-handlers and properly passed on.
The solution
This didn’t dawn on me until quite recently, but Contexts
aren’t unique by design, they often become unique because we tend to place them high up in the hierarchy, often wrapping our entire application in multiple sets of ContextProviders
, and often that is the correct way of doing it, we want our authentication context to be unique, for example.
But a Modal doesn’t have to be unique, although preferably it should only have one visible at once, but this can be managed. So, given that a context is not unique, how do we create an implementation that doesn’t force us to pass props around, to rely on magic id-strings and generally feels better to work with?
function MyComponent(users: Array<User>): React.ReactElement {
const { data } = useRemoteData("/api/users");
const { patch, post, remove } = useRemoteData("/api/users");
return (
<div>
<UserTable>
{...data.map(user)}
<UserRow>
{...user}
<UserButtons>
<Modal> <!-- this is my context -->
<ModalContent> <!-- this is where I decide what is shown in my modal -->
<EditUserForm user={user} ... />
</ModalContent>
<OpenModal> <!-- this is my controller, how do I open this modal -->
<EditUserButton onClick={patch(user)} />
</OpenModal>
</Modal>
<Modal>
<ModalContent>
<RemoveUserForm user={user} ... />
</ModalContent>
<OpenModal>
<RemoveUserButton onClick={remove(user)} />
</OpenModal>
</Modal>
</UserButtons>
</UserRow>
</UserTable>
</div>
)
}
So, what are we seeing here. We’re seeing multiple <Modal>
-components, each representing a separate context with separate state, with each component containing a <ModalContent>
and <OpenModal>
, lets walk through them one by one, to see how their implementation would look like.
Modal component/context
So the Modal
is the only real stateful component of the ones above, it’s the component responsible for handling the logic of closing and opening the modal, and is used to separate multiple modals from one another. It’s fairly short and sweet.
export interface ModalContextProps {
isOpen: boolean
setOpen: (newState: boolean) => void
}
export const ModalContext = createContext<ModalContextProps>({
isOpen: true,
setOpen: () => {},
})
The context is then consumed in multiple places, but primarily in the <Modal>
-component. Note that these initial values for the context will be overwritten once we create the consuming component, they are primarily there to avoid typescript errors for the interface.
For the actual consuming component, the <Modal>
-component, it will end up looking something like this:
export function Modal({
children,
}: {
children: React.ReactElement | React.ReactElement[]
}) {
const [isOpen, setOpen] = useState<boolean>(false)
return (
<ModalContext.Provider
value={{
isOpen: isOpen,
setOpen: (newState) => setOpen(newState),
}}
>
{children}
</ModalContext.Provider>
)
}
We set up our local state, unique for this particular context, and hook it up straight to our new ModalContext
, we could think about optimization here, but since a single <Modal>
hopefully only appears once ever, we shouldn’t be affected by multiple re-renders, but your mileage may vary.
ModalContent
So a modal often consists of multiple things, we need a container to handle styling, we often also want an overlay to get focus to the new element that shows up, we may want to animate the elements and much more. Since typically, most of these are the same for each of our Modal, we handle this here.
export function ModalContent({ children, label }: ModalBaseProps) {
return (
<ModalBase label={label}> <!-- Our only real component here, props can be designed as we want -->
<ModalCloseContainer> <!-- styled-component -->
<CloseModal> <!-- We'll look at these in the next section -->
<ModalCloseButton>
<VisuallyHidden>Close</VisuallyHidden> <!-- styled-component -->
<span aria-hidden>x</span>
</ModalCloseButton>
</CloseModal>
</ModalCloseContainer>
<ModalContentContainer> <!-- styled-component -->
{children}
</ModalContentContainer>
</ModalBase>
);
}
For now, ignore the components related to our <CloseModal>
, as we’ll walk through that one together with the <OpenModal>
component. Within <ModalBase>
is where most of our visual handling is done, where as this component mostly is repsonsible for arranging the elements in an appropriate order and fashion.
Lets continue looking at the <ModalBase>
.
function ModalBase({ children, label }: ModalBaseProps): React.ReactElement {
const context = useContext(ModalContext);
if (!context) {
throw new Error("Provider not initialized");
}
const { isOpen, setOpen } = context;
const transitions = useTransition(isOpen, {
from: { opacity: 0, y: -100 },
enter: { opacity: 1, y: 0 },
leave: { opacity: 0, y: 100 }
});
return transitions(
(styles, item) =>
item && (
<ModalOverlay <!-- styled-component -->
isOpen={isOpen}
onDismiss={() => setOpen(false)}
style={{ opacity: styles.opacity }}
>
<ModalContainer <!-- styled-component -->
aria-label={label}
style={{
transform: styles.y.to(
(value) => `translate3d(0px, ${value}px, 0px)`
)
}}
>
{children}
</ModalContainer>
</ModalOverlay>
)
);
}
This is where we want animations done, and for this, I’ve added react-spring
to help us with transitioning our elements. We consume our ModalContext
, which will only look up the immediate closest context (and prevent any confusion with multiple contexts). We supply our state to useTransition
in order to gain the appropriate styles, in this case our modal will appear from the bottom of the screen, and increase in opacity from 0 to 1.
<ModalOverlay>
and <ModalContainer>
are only done for styling, and won’t be part of the focus here.
If this looks like a lot, remember that this can just be the following, if you like:
function ModalBase({ children, label }: ModalBaseProps): React.ReactElement {
const context = useContext(ModalContext)
if (!context) {
throw new Error('Provider not initialized')
}
const { isOpen, setOpen } = context
if (!isOpen) {
return null
}
return (
<ModalOverlay isOpen={isOpen} onDismiss={() => setOpen(false)}>
<ModalContainer aria-label={label}>{children}</ModalContainer>
</ModalOverlay>
)
}
OpenModal/CloseModal
These components are a bit magic, but only because we make certain assumptions about what they will contain. This is how they look:
function CloseModal({ children }: { children: React.ReactElement }) {
const { setOpen } = useContext(ModalContext)
return React.cloneElement(children as React.ReactElement<any>, {
onClick: () => setOpen(false),
})
}
export function OpenModal({ children }: { children: React.ReactElement }) {
const { setOpen } = useContext(ModalContext)
return React.cloneElement(children as React.ReactElement<any>, {
onClick: () => setOpen(true),
})
}
What this basically does is it consumes our ModalContext
to get our callback for toggling our modal, and then whatever children you pass into the <OpenModal>
, will have the onClick
prop added to its props. This will work out of the box if you make sure to pass any child that expects and can use a onClick
method on it, but <button>
is a good start.
The magic here is again with the context only consuming the immediate context of the component, which means that just by existing within a ModalContext
, it knows which Modal
to close. In this case, we don’t export
the <CloseModal>
component, but only because we add a small X
in the top of the modal to close it, but for example, if one of the modals contain a “Dismiss” button, we could just wrap that in the <CloseModal>
wrapper, and it’s fixed.
Summing it up
With the examples in here, we’ve made it so our modals are reusable, and become very flexible and customizable. Since each modal is basically exposed in its entirety to the “host”-component (the component responsible for creating the modal) we don’t have to worry about passing props, about middleman-ing props via a global state back and forth.
This all comes with the drawback that we no longer can be aware whether we have a modal open or not, since each context will handle that separately. But we could even argue why you might need to be aware of whether a modal is open or not. Wrap the modals overlay in a <CloseModal>
and any click outside of the modal will dismiss it (maybe not entirely straight out of the box, but it’s manageable).
Drawbacks
Unfortunately I couldn’t find a neat way of ensuring that a <Modal>
-component always contains a <ModalContent>
, so it’s more difficult to make sure that when implementing these, to ensure that all the components required to show a modal is there. It’s a fairly minor note, but is common when working with these compound component patterns where each component depends on the existence of another.
Check it out online
I composed a codesandbox where you can browse the code (if you made it this far), have a look here