Debugging React Error #300: "Rendered fewer hooks than expected" with TipTap Editor

After days of debugging a cryptic production crash, we traced it to a hooks violation in TipTap when AI features weren't configured. Here's exactly how to find and fix this issue.

Published 2026-02-26 by TechNet New England

We spent days debugging a production crash that only appeared after hydration, showed a cryptic minified error code, and seemed completely random. The culprit? A React hooks violation buried deep in our TipTap rich text editor configuration. Here's the full story and how to fix it.

The Symptoms

Our application was crashing in production with this error:

Minified React error #300; visit https://reactjs.org/docs/error-decoder.html?invariant=300

When decoded, this translates to:

Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

The crash was particularly frustrating because:

Understanding React's Hook Rules

Before diving into the fix, let's understand why this error occurs. React internally tracks hooks by call order using an array index. Every render must call the exact same hooks in the exact same order.

This code violates the rules:

function MyComponent({ data }) {
  const [count, setCount] = useState(0);

  // ❌ WRONG: Early return before all hooks are called
  if (!data) {
    return null;
  }

  // This hook won't be called if data is falsy
  const [name, setName] = useState('');

  return <div>{name}: {count}</div>;
}

On the first render, if data exists, React sees 2 hooks. On a subsequent render, if data becomes null, React only sees 1 hook. This mismatch triggers Error #300.

Our Specific Issue: TipTap Without AI Configuration

We were using TipTap editor with optional AI features. The problem was in how we conditionally initialized the editor based on whether AI was configured:

function RichTextEditor({ aiEnabled, aiApiKey }) {
  const [content, setContent] = useState('');

  // ❌ This was our bug
  if (aiEnabled && !aiApiKey) {
    // AI was enabled but not configured - bail early
    return <div>AI configuration required</div>;
  }

  // TipTap's useEditor hook - not called when we return early!
  const editor = useEditor({
    extensions: [
      StarterKit,
      aiEnabled ? AIExtension.configure({ apiKey: aiApiKey }) : null,
    ].filter(Boolean),
    content,
    onUpdate: ({ editor }) => setContent(editor.getHTML()),
  });

  // More hooks that wouldn't be called...
  const [isFocused, setIsFocused] = useState(false);

  return <EditorContent editor={editor} />;
}

The issue was subtle:

  1. On initial SSR, aiApiKey might be undefined (waiting for async config)
  2. The component renders with the early return
  3. After hydration, the config loads and aiApiKey becomes available
  4. Now the component tries to render with useEditor and additional hooks
  5. Boom: hook count mismatch, Error #300

The Fix: Always Call All Hooks

The solution is to ensure all hooks are called on every render, regardless of conditions:

function RichTextEditor({ aiEnabled, aiApiKey }) {
  const [content, setContent] = useState('');
  const [isFocused, setIsFocused] = useState(false);

  // ✅ Always call useEditor, even if we won't use it
  const editor = useEditor({
    extensions: [
      StarterKit,
      // Only add AI extension if properly configured
      (aiEnabled && aiApiKey) ? AIExtension.configure({ apiKey: aiApiKey }) : null,
    ].filter(Boolean),
    content,
    onUpdate: ({ editor }) => setContent(editor.getHTML()),
  });

  // ✅ Now we can do conditional returns AFTER all hooks
  if (aiEnabled && !aiApiKey) {
    return <div>AI configuration required</div>;
  }

  if (!editor) {
    return <div>Loading editor...</div>;
  }

  return <EditorContent editor={editor} />;
}

Debugging Techniques That Helped

1. Enable React Development Mode Locally

The development build of React provides much better error messages. The minified Error #300 becomes a full stack trace pointing to the exact component.

2. Use React DevTools Profiler

The React DevTools Profiler can help identify which component is causing issues by showing render patterns and hook calls.

3. Add Strategic Console Logs

We added logging at the top of suspected components:

function SuspectComponent(props) {
  console.log('SuspectComponent render', {
    propsKeys: Object.keys(props),
    hasData: !!props.data
  });
  // ... rest of component
}

This revealed the pattern: the component was rendering with different prop states between SSR and hydration.

4. Binary Search Through Components

When the stack trace isn't helpful, systematically disable components until the error stops. Then narrow down within that component.

5. Check for Async State Dependencies

Look for any hooks that depend on async data that might not be available on first render:

TipTap-Specific Gotchas

If you're using TipTap with React, watch out for these patterns:

Conditional Extensions

// ❌ Don't do this - extensions array changes between renders
const extensions = aiEnabled
  ? [StarterKit, AIExtension]
  : [StarterKit];

// ✅ Do this instead - always same array structure
const extensions = [
  StarterKit,
  aiEnabled ? AIExtension.configure({ apiKey }) : null,
].filter(Boolean);

Conditional useEditor Calls

// ❌ Never do this
const editor = isEnabled ? useEditor({ ... }) : null;

// ✅ Always call the hook
const editor = useEditor({
  extensions,
  editable: isEnabled, // Control behavior via options instead
});

Editor in Conditional Components

// ❌ This can cause issues if EditorWrapper conditionally renders
{showEditor && <EditorWrapper />}

// ✅ Better: always render, control visibility
<div style={{ display: showEditor ? 'block' : 'none' }}>
  <EditorWrapper />
</div>

// ✅ Or: use a key to force remount when toggling
{showEditor && <EditorWrapper key="editor" />}

Prevention Checklist

To avoid this issue in the future:

  1. All hooks at the top: Declare all hooks before any conditional logic or early returns
  2. No hooks in conditions: Never put hooks inside if statements, loops, or nested functions
  3. ESLint rules: Enable eslint-plugin-react-hooks with the rules-of-hooks rule set to "error"
  4. Test with async delays: Add artificial delays to API calls during development to catch timing-dependent bugs
  5. SSR hydration testing: Test the full SSR to hydration flow, not just client-side rendering

ESLint Configuration

Add this to your ESLint config to catch these issues early:

{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

This will flag any conditional hook calls at development time, before they become production crashes.

The Hard Lesson: What We Did Wrong

This bug took us over 10 hours to solve. Here's an honest breakdown of what we tried and why it didn't work:

What We Did Time Spent Why It Failed
Tested with curl Hours curl only tests SSR. Completely useless for client-side hydration crashes.
Blamed TipTap versions Hours Was a real issue but not THE cause. A red herring.
Static analysis of hook patterns Hours Identified wrong components entirely. The actual culprit wasn't flagged.
Set up Puppeteer for browser testing Hours Password escaping issues, cookie problems, cross-server complications

What Would Have Solved It in 2 Minutes

After all that struggle, here's what actually fixed it:

# Stop the production server
pm2 stop your-app

# Run in development mode
NODE_ENV=development npx next dev -p 3000

Then visit the page. The unminified error immediately said:

Error in <AIWritingTools>: Rendered fewer hooks than expected.

That's it. Component name. Exact error. Done.

The entire 10+ hour debugging session could have been a 15-minute fix.

The Rule We Now Follow

When you see "Something went wrong" or React Error #300 in production:
Step 1 is always dev mode. No exceptions. No curl. No static analysis. No Puppeteer gymnastics.

Just run the app in development mode and let React tell you exactly what's wrong.

Production React deliberately minifies errors to save bundle size. Development React gives you:

Why We Didn't Do This First

Tunnel vision. Pure and simple.

Even experienced developers fall into this trap. You get so deep into debugging, trying advanced techniques, setting up elaborate test harnesses, analyzing code statically, that your mind drifts into a kind of denial. You forget the basics. You convince yourself the solution must be complicated because the problem feels complicated.

We told ourselves:

None of that was true. We just... forgot to try the obvious thing first.

It's humbling. Years of experience, and we spent 10 hours avoiding a 2-minute solution because we were too deep in the weeds to step back and ask: "What would a beginner do?"

A beginner would run dev mode. A beginner would read the actual error message. A beginner would have fixed this in 15 minutes.

Conclusion

React Error #300 is one of those bugs that can consume days of debugging time because it often manifests far from its source. The key insights are:

We hope this saves someone else the multi-day debugging session we went through. Don't be heroes. Just run dev mode.