Using Modals In React.js, The Right Way (ZERO prop drilling)

itays123

Itay Schechner

Posted on September 1, 2021

Using Modals In React.js, The Right Way (ZERO prop drilling)

What if controlling a modal was easy as writing the following effect:

const someModal = useModal()

useEffect(() => {
  if (someModal.isOpen) {
    setTimeout(someModal.close, 1000)
  }
}, [someModal])
Enter fullscreen mode Exit fullscreen mode

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:

  1. usages of the useModal hook
  2. The modal component factory
  3. Writing readable code with modal factories.

The Modal Hook

Let’s start with some TypeScript:

export interface Modal {
  isOpen: boolean;
  open(): void;
  close(): void;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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>
    )
}

Enter fullscreen mode Exit fullscreen mode

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

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 -

GitHub logo itays123 / partydeck

A cool online card game!

💖 💪 🙅 🚩
itays123
Itay Schechner

Posted on September 1, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related