Back to Blog
ReactZustandDebuggingReact 19State ManagementDevGraphFrontendJavaScriptTypeScriptPerformance

The Black Screen Bug That Wasn't CSS: Debugging a Zustand + React 19 Infinite Render Loop

A completely black screen pointed to CSS. It wasn't. This is the story of debugging an infinite render loop caused by Zustand store selectors in React 19 while building DevGraph.

June 13, 20265 min read

Recently, I started building DevGraph, a developer tool designed to help engineers understand entire codebases instead of just reading individual files.

The vision is simple:

Developers can read code. Developers struggle to understand systems.

DevGraph aims to visualize architecture, execution flows, dependencies, and learning paths inside large repositories.

As I was building the frontend, I encountered one of the most frustrating bugs I've seen during development.

A completely black screen.


The Symptom

The application would:

  1. Start normally.
  2. Display the UI for about one second.
  3. Instantly disappear.
  4. Leave only a black screen.

At first glance, this looked like a CSS issue.

The obvious suspects were:

  • display: none
  • visibility: hidden
  • opacity: 0
  • Full-screen overlays
  • Incorrect z-index
  • Tailwind utility conflicts
  • Broken layout containers

The browser network panel looked healthy. The assets loaded. The Vite development server worked. The JavaScript bundles were delivered correctly.

Everything suggested the UI should be visible.

Yet it wasn't.


My First Assumption Was Wrong

Like many frontend developers, I immediately blamed CSS.

The screen was black. The UI vanished. It had to be styling.

So I began investigating:

rg -n "opacity-0|hidden|invisible|display:none|visibility:hidden" src

Nothing.

I checked global styles, layout containers, root element sizing, positioning, and overflow rules.

Still nothing. The bug remained.


Isolating the Problem

Instead of guessing, I reduced the application to the smallest possible test.

I replaced the entire application with:

function App() {
  return (
    <div
      style={{
        minHeight: "100vh",
        background: "black",
        color: "white",
        padding: "40px",
      }}
    >
      DEVGRAPH IS RENDERING
    </div>
  );
}

The result? The text stayed visible forever. No disappearing. No black screen. No crash.

That immediately proved something important:

The rendering system works.
The root mount works.
The CSS isn't the primary problem.

The issue existed somewhere inside the component tree.


Looking at the Console

Once I focused on runtime behavior instead of styling, the browser console exposed the real issue.

React reported:

The result of getSnapshot should be cached
to avoid an infinite loop

Followed by:

Maximum update depth exceeded

And finally:

An error occurred in the <RepositoryHeader> component

Now the mystery was starting to make sense.

The UI wasn't disappearing. React was crashing.


The Real Root Cause

The issue originated from Zustand store selectors.

I had code similar to:

const { repository, selectedFile } =
  useRepositoryStore((state) => ({
    repository: state.repository,
    selectedFile: state.selectedFile,
  }));

At first glance, this looks harmless.

However, there is a subtle problem. Every render creates a brand-new object:

{
  repository: state.repository,
  selectedFile: state.selectedFile
}

Even if the values are identical, the object reference changes.

React 19 and Zustand 5 are much stricter about external store subscriptions. Internally, React compares snapshots returned by the store. Because a new object was being returned every render:

Render
 ↓
New Object
 ↓
Snapshot Changed
 ↓
Re-render
 ↓
New Object
 ↓
Snapshot Changed
 ↓
Re-render

The cycle never ended. React entered an infinite update loop. Eventually it threw Maximum update depth exceeded and the entire component tree collapsed.


The Fix

The solution was to use shallow comparison.

Instead of:

useRepositoryStore((state) => ({
  repository: state.repository,
  selectedFile: state.selectedFile,
}));

I changed it to:

import { useShallow } from "zustand/react/shallow";

const { repository, selectedFile } =
  useRepositoryStore(
    useShallow((state) => ({
      repository: state.repository,
      selectedFile: state.selectedFile,
    }))
  );

Now Zustand only triggers re-renders when the selected values actually change. No unnecessary object reference changes. No infinite loop. No React crash.


The Result

After applying shallow selectors across the repository components — RepositoryHeader, RepositoryTabs, RepositoryTree, CodeViewer, FlowPreview, and InsightPanel — the application became stable.

The UI no longer disappeared. The development server worked normally. The production build succeeded. The DevGraph workspace rendered correctly.


Lessons Learned

This bug reinforced several engineering principles.

1. Don't Trust First Impressions

A black screen looked like CSS. It wasn't. The real issue was state management.

2. Reduce Before You Debug

Replacing the entire app with a simple component immediately narrowed the search space. Small experiments beat random guessing.

3. Read Console Errors Carefully

The browser told me exactly what was wrong — getSnapshot and Maximum update depth exceeded. I simply wasn't looking in the right place initially.

4. Zustand Selectors Matter

Returning new object literals from selectors can create subtle rendering problems. With modern React and Zustand versions, proper selector optimization becomes increasingly important.


Final Thoughts

This wasn't a difficult bug because of its complexity.

It was difficult because the symptom and the cause lived in completely different layers of the application. The screen looked broken. The CSS looked suspicious. The actual problem was an infinite render loop caused by store subscriptions.

As DevGraph grows, I expect to encounter many more problems like this. And that's exactly why I enjoy building developer tools.

Every bug is another opportunity to understand the system more deeply.

Sometimes the fastest way to solve a problem is not writing more code. It's understanding what the system is trying to tell you.