DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

React 19 Portals for Isolated Tenant Modals: Breaking Out of Stacking Contexts Without z-index Wars

React 19 Portals for Isolated Tenant Modals: Breaking Out of Stacking Contexts Without z-index Wars

I've spent countless hours debugging CSS stacking context issues in CitizenApp's multi-tenant dashboard. A modal would vanish behind a parent container despite having z-index: 9999. The culprit? A parent div with transform: translateY(0) or position: relative creating a new stacking context. You can't fight CSS physics with bigger numbers.

React 19 Portals aren't new, but they're the architectural solution to this problem. Portals don't just render content in a different DOM location—they break free from stacking context inheritance entirely. This is especially critical in multi-tenant SaaS where you're rendering modals, dropdowns, and popovers across deeply nested feature dashboards with AI-powered overlays.

Let me show you why z-index will never win, and how Portals actually solve this.

The Stacking Context Trap

Here's what happens in a typical dashboard:

// The problem setup
export function TenantDashboard({ tenantId }: { tenantId: string }) {
  return (
    <div className="relative">
      {/* Parent has position: relative = new stacking context */}
      <AIFeatureCard>
        <div className="transform translate-y-0">
          {/* transform = another stacking context */}
          <Modal>
            {/* z-index: 9999 can't escape the stacking context above */}
          </Modal>
        </div>
      </AIFeatureCard>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The modal has the highest z-index within its stacking context, but that stacking context sits behind the parent's context. Your 9999 means nothing.

I learned this the hard way when CitizenApp's tenant invite modal—critical for onboarding—would vanish behind the analytics feature card. The modal worked perfectly in isolation. Rendered in production? Invisible hell.

Portals: Escape the Context Entirely

React Portals solve this by rendering components into a different DOM node, outside your component tree. Here's the setup:

// app/components/PortalRoot.tsx
export function PortalRoot() {
  return (
    <div 
      id="modal-root" 
      className="pointer-events-none"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Drop this in your root layout:

// app/layouts/RootLayout.astro
---
import { PortalRoot } from '@/components/PortalRoot';
---
<!DOCTYPE html>
<html>
  <head>
    <!-- head content -->
  </head>
  <body>
    <div id="app">
      <!-- Your entire app renders here -->
    </div>

    {/* Portal root lives OUTSIDE the app div */}
    <PortalRoot />
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now create a reusable Portal component:

// app/components/Portal.tsx
import { ReactNode, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';

interface PortalProps {
  children: ReactNode;
  containerId?: string;
}

export function Portal({ children, containerId = 'modal-root' }: PortalProps) {
  const [mounted, setMounted] = useState(false);

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

  if (!mounted) return null;

  const container = document.getElementById(containerId);
  if (!container) {
    console.warn(`Portal container '${containerId}' not found`);
    return null;
  }

  return createPortal(children, container);
}
Enter fullscreen mode Exit fullscreen mode

The hydration check (mounted) prevents server/client mismatch in Astro. This matters.

Implementing a Tenant Modal with Portal

Now the actual modal that lives in your dashboard but renders outside:

// app/components/TenantModal.tsx
import { useState } from 'react';
import { Portal } from './Portal';

interface TenantModalProps {
  isOpen: boolean;
  onClose: () => void;
  tenantId: string;
  title: string;
  children: ReactNode;
}

export function TenantModal({
  isOpen,
  onClose,
  tenantId,
  title,
  children,
}: TenantModalProps) {
  if (!isOpen) return null;

  return (
    <Portal>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/50 z-40"
        onClick={onClose}
        aria-label="Modal backdrop"
      />

      {/* Modal container */}
      <div className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none">
        <div
          className="bg-white rounded-lg shadow-xl w-full max-w-md pointer-events-auto"
          onClick={(e) => e.stopPropagation()}
        >
          {/* Header */}
          <div className="flex items-center justify-between p-6 border-b">
            <h2 className="text-lg font-semibold">{title}</h2>
            <button
              onClick={onClose}
              className="text-gray-500 hover:text-gray-700"
              aria-label="Close modal"
            >
              âś•
            </button>
          </div>

          {/* Content */}
          <div className="p-6">
            {children}
          </div>
        </div>
      </div>
    </Portal>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice the z-index strategy: backdrop is z-40, modal is z-50. These z-index values only matter relative to each other now, not to your entire app. That's the freedom Portals give you.

Usage in Your Dashboard

// app/pages/tenant/[tenantId]/dashboard.tsx
import { useState } from 'react';
import { TenantModal } from '@/components/TenantModal';
import { AIFeatureCard } from '@/components/AIFeatureCard';

export default function TenantDashboard({ tenantId }: { tenantId: string }) {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-8">Tenant Dashboard</h1>

      {/* Deeply nested feature card with transforms */}
      <div className="grid grid-cols-3 gap-6">
        <AIFeatureCard
          title="Document Analysis"
          onClick={() => setIsModalOpen(true)}
        />
        {/* ... more cards ... */}
      </div>

      {/* Modal renders here in code, but appears outside app DOM */}
      <TenantModal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        tenantId={tenantId}
        title="Feature Details"
      >
        <p>Content lives here, but renders in the portal root.</p>
      </TenantModal>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The modal is declared where it logically belongs (near the feature triggering it), but renders at the document root. Best of both worlds.

Multi-Tenant RBAC Considerations

In CitizenApp, I needed to ensure tenant isolation. Portals don't automatically respect RBAC—you need to:

// Check permissions before rendering sensitive modals
export function ProtectedTenantModal(props: TenantModalProps) {
  const { user } = useAuth();

  if (!user.can('view_tenant_details', props.tenantId)) {
    return null; // Don't even render the Portal
  }

  return <TenantModal {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

Portals are DOM-agnostic; permission logic stays in your components.

Gotcha: Event Bubbling and stopPropagation

I nearly shipped a bug where clicking the modal content would close it. The parent's click handler was catching the event through the Portal. The fix:

onClick={(e) => e.stopPropagation()}  // Critical for modal content
Enter fullscreen mode Exit fullscreen mode

Portals maintain event bubbling to your React component tree, so you still need proper event handling.

Why This Beats z-index Hell

  • No stacking context warfare: Parent transforms don't affect Portal children.
  • Predictable layering: z-index values are scoped to the Portal, not the entire app.
  • Logical code location: Modal code lives where it's triggered, renders where it needs to appear.
  • Multi-tenant safety: Each tenant's modals render in isolation without layer conflicts.

Portals aren't fancy. They're architectural clarity. I prefer them because they eliminate an entire class of CSS bugs that burn hours of debugging time. Once you've chased a phantom z-index issue through five levels of nested divs, you never look at Portals the same way.

Use them. Your future self will thank you.

Top comments (0)