What if controlling a modal was easy as writing the following effect:
const someModal = useModal()
useEffect(() => {
if (someModal.isOpen) {
setTimeout(someModal.close, 1000)
}
}, [someModal])
My name is Itay Schechner, and I’m a growing fullstack develoepr who specializes in back-of-the-frontend code, particularly in React.js.
In this article, I’ll teach you how to write readable, reusable modal utilities.
NOTE: This article is heavily based on a previous post I wrote, explaining usage of the Context API in detail.
What you’ll learn today:
- usages of the useModal hook
- The modal component factory
- Writing readable code with modal factories.
The Modal Hook
Let’s start with some TypeScript:
export interface Modal {
isOpen: boolean;
open(): void;
close(): void;
}
From that, we understand that each modal will be able to open itself, close itself and “tell” the components and hooks using it if it’s open or not. This hook is relatively easy to implement:
export default function useModal(): Modal {
const [isOpen, setOpen] = useState(false);
return {
isOpen,
open() {
setOpen(true);
},
close() {
setOpen(false);
},
};
}
You can implement modal logic by using this hook in one of your components, and using a lot of prop drilling. For example:
export default function Navbar () {
const { isOpen, open, close } = useModal();
return (
<nav>
// ...navigation code
{ isOpen && <Modal close={close} /> }
<button onClick={open}>Open Modal</button>
</nav>
)
}
Because we are so used to writing components this way, we don’t recognize the full potential of modals. What if the exports of your modal file would look like this:
import LoginModal, { LoginModalOpener } from '../auth/LoginModal';
The Modal Factory
Unlike previous component factories we discussed, this factory will be much more complicated.
Let’s start, again, with some TypeScript, to see the requirements of this factory.
export function createModal<T extends object>(
context: Context<T>,
name: keyof T,
openerLabel: string
) { ... }
What do we understand from that?
- The function will take a Modal typed field in the context provided, and use it to create the modal
- The function takes an openerLabel field, meaning it will create the opener button as well.
- If we provided an opener, we should be able to provide a closer as well. I want my closer to display an x icon instead of a text, so I’ll upgrade my context action factory first.
type JSXProvider<Props> = (props: Props) => JSX.Element;
export function action<T extends object, Props extends object = {}>(
label: string | JSXProvider<Props>,
context: React.Context<T>,
consumer: (ctx: T) => void,
) {
return function ContextAction({ className, ...props }: withClass & Props) {
const ctx = useContext(context);
const action = useCallback(() => consumer(ctx), [ctx]);
return (
<button onClick={action} className={className}>
{typeof label === 'string' ? label : label(props as unknown as Props)}
</button>
);
};
}
Now, we can write our modal factory:
export function createModal<T extends object>(
context: Context<T>,
name: keyof T,
openerLabel: string
) {
return {
Visible: createWrapper(
context,
ctx => (ctx[name] as unknown as ModalHook).isOpen
),
Opener: action(openerLabel, context, ctx =>
(ctx[name] as unknown as Modal).open()
),
// Clear: A JSXProvider that takes width and height props
Closer: action(Clear, context, ctx =>
(ctx[name] as unknown as Modal).close()
),
};
}
Let’s see how we can use this factory to create clean code. In the example I’ll show you, I will create a Login modal in an authentication context, that is provided for the entire application in the App.tsx file.
// AuthContext.tsx
export default function AuthContextProvider({ children }: Wrapper) {
// other auth state ommited for bravety
const loginModal = useModal();
// effects ommitted for bravety
return (
<AuthContextProvider value={{ loginModal, ...anything }}>{ children }</AuthContextProvider>
)
}
// LoginModal.tsx
const ModalProvider = createModal(AuthContext, 'loginModal', 'Log In');
export const LoginModalOpener = ModalProvider.Opener;
export default function LoginModal() {
return (
<ModalProvider.Visible> // modal is hidden when hook state is hidden
// Modal UI (i.e dark fixed background, white modal)
<ModalProvider.Closer />
<div>
// form ommited for bravety
</div>
</ModalProvider.Visible>
)
}
// App.tsx
export default function App () {
return (
<AuthContextProvider>
<LoginModal />
<Navbar />
// rest of application
</AuthContextProvider>
)
}
Now, let’s see how SIMPLE our Navbar component becomes:
import { LoginModalOpener } from '../auth/LoginModal';
export default function Navbar () {
return (
// ... links ommited for bravety
<LoginModalOpener />
)
}
Wrapping up
If you think I made a mistake or I could write the post better, please make suggestions.
A project where I used this -
Top comments (3)
Nice explanation! I like that Typescript add on
Well done.
I have another approach, which seems a little bit unorthodox but works fine within react. dev.to/theluk/new-prompt-confirmat...
That looks really cool