DEV Community

Cover image for Building a Global Modal System in Next.js: A Deep Dive into Client-Side State Management
Selasie Sepenu
Selasie Sepenu

Posted on

Building a Global Modal System in Next.js: A Deep Dive into Client-Side State Management

Creating a seamless and reusable modal system is a common requirement in modern web development. Modals are used for everything from user authentication to notifications, and they need to be accessible, performant, and easy to manage across an application. However, implementing a global modal system in a framework like Next.js—especially with the introduction of Server Components—can be tricky. In this article, I’ll walk you through my experience building a global modal system using Zustand for state management, React Portals for rendering, and the challenges I faced along the way.


The Problem: A Global Modal System

The goal was simple: create a modal that could be triggered from anywhere in the application and would render consistently across all pages. The modal needed to:

  1. Be Global: Accessible from any component or page.
  2. Be Performant: Avoid unnecessary re-renders.
  3. Work with Server Components: Next.js 13+ utilizes Server Components, which complicated things because React hooks (like Zustand’s useStore) can only be used in Client Components.

At first glance, this seemed straightforward. However, as I dove deeper, I encountered several challenges that required creative solutions.


The Initial Approach: Wrapping the Application

My first thought was to wrap the entire application in a ClientWrapper component that would handle the modal logic. This wrapper would use Zustand to manage the modal’s state and conditionally render the modal based on that state.

The Code

Here’s what the initial implementation looked like:

"use client";
import { useModalStore } from "@/store/store";
import WaitlistModal from "@/components/ui/Modal";
import { ReactNode } from "react";

interface ClientWrapperProps {
  children: ReactNode;
}

export default function ClientWrapper({ children }: ClientWrapperProps) {
  const { isModalOpen, closeModal } = useModalStore();

  return (
    <>
      {children}
      <WaitlistModal isOpen={isModalOpen} onClose={closeModal} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Problem

While this approach worked in theory, it introduced a critical issue: unreachable code. When the children were rendered first, the WaitlistModal became unreachable, and vice versa. This was due to the way React handles component rendering and the order of operations.


The Solution: React Portals

To solve the unreachable code issue, I turned to React Portals. Portals allow you to render a component outside its parent DOM hierarchy, which is perfect for modals that need to appear above all other content.

What Are Portals?

Portals are a feature in React that let you render a component into a DOM node outside its parent component. This is particularly useful for modals, tooltips, and other overlay components that need to break out of their container.

Implementing Portals

I created a Portal component to handle the rendering logic:

"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";

interface PortalProps {
  children: React.ReactNode;
}

export default function Portal({ children }: PortalProps) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    return () => setMounted(false);
  }, []);

  if (!mounted) return null;

  return createPortal(children, document.body);
}
Enter fullscreen mode Exit fullscreen mode

Updating the Modal Component

Next, I wrapped the WaitlistModal component with the Portal component:

"use client";
import { motion } from "framer-motion";
import Portal from "./Portal";

interface WaitlistModalProps {
  isOpen: boolean;
  onClose: () => void;
}

const WaitlistModal = ({ isOpen, onClose }: WaitlistModalProps) => {
  if (!isOpen) return null;

  return (
    <Portal>
      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
        onClick={onClose}
      >
        <motion.div
          initial={{ y: -50, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: -50, opacity: 0 }}
          className="bg-white rounded-lg p-8 w-full max-w-md relative z-60"
          onClick={(e) => e.stopPropagation()}
        >
          {/* Modal content */}
        </motion.div>
      </motion.div>
    </Portal>
  );
};

export default WaitlistModal;
Enter fullscreen mode Exit fullscreen mode

Why Portals Work

Portals solved the unreachable code issue by rendering the modal outside the main component tree. This ensured that the modal would always appear above other content, regardless of where it was triggered.


The Next Challenge: Server Components

With the modal working as expected, I encountered a new challenge: Server Components. Next.js 13+ utilizes Server Components, which are rendered on the server and cannot use React hooks like useState or useStore.

The Error

When I tried to use useModalStore in the app/layout.tsx file, I got the following error:

Error: useSyncExternalStore only works in Client Components. Add the "use client" directive at the top of the file to use it.
Enter fullscreen mode Exit fullscreen mode

The Solution: A Modal Provider

To resolve this, I created a ModalProvider component that would handle the modal logic and render the WaitlistModal. This component would be a Client Component, ensuring that useModalStore could be used without issues.

ModalProvider.tsx

"use client";
import { useModalStore } from "@/store/store";
import WaitlistModal from "@/components/ui/Modal";

export default function ModalProvider() {
  const { isModalOpen, closeModal } = useModalStore();

  return <WaitlistModal isOpen={isModalOpen} onClose={closeModal} />;
}
Enter fullscreen mode Exit fullscreen mode

Updating the Layout

I then included the ModalProvider in the app/layout.tsx file:

import { Inter } from "next/font/google";
import "./globals.css";
import ModalProvider from "@/components/ModalProvider";

const inter = Inter({ subsets: ["latin"] });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        {children}
        <ModalProvider />
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Final Result

With these changes, the global modal system was fully functional:

  1. Global Access: The modal could be triggered from any component using the useModalStore hook.
  2. Performant: The modal was rendered only when needed, avoiding unnecessary re-renders.
  3. Server-Safe: The modal logic was handled in a Client Component, ensuring compatibility with Server Components.

Key Takeaways

  1. Portals Are Powerful: React Portals are an excellent tool for rendering components outside their parent hierarchy, making them ideal for modals and overlays.
  2. Separate Client and Server Logic: When working with Next.js Server Components, it’s essential to separate client-side logic into dedicated Client Components.
  3. State Management Matters: Zustand provided a simple and effective way to manage global state, but it’s crucial to use it correctly in a Server Components architecture.

Conclusion

Building a global modal system in Next.js was a challenging but rewarding experience. By leveraging React Portals, Zustand, and a clear separation of client and server logic, I was able to create a performant and reusable solution. If you’re working on a similar project, I hope this article provides valuable insights and helps you avoid common pitfalls.

Happy coding! 🚀

Top comments (0)