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:
- Server-side rendering worked fine: no errors during SSR
- The crash happened 1-2 seconds after page load: after hydration completed
- It was intermittent: depending on API response timing
- The error was minified: no useful stack trace in production
- Multiple unrelated console warnings: created noise that obscured the real issue
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:
- On initial SSR,
aiApiKeymight be undefined (waiting for async config) - The component renders with the early return
- After hydration, the config loads and
aiApiKeybecomes available - Now the component tries to render with
useEditorand additional hooks - 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:
- API responses
- Context values that load asynchronously
- localStorage/sessionStorage reads
- Feature flags from remote config
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:
- All hooks at the top: Declare all hooks before any conditional logic or early returns
- No hooks in conditions: Never put hooks inside if statements, loops, or nested functions
- ESLint rules: Enable
eslint-plugin-react-hookswith therules-of-hooksrule set to "error" - Test with async delays: Add artificial delays to API calls during development to catch timing-dependent bugs
- 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:
- The exact component name where the error occurred
- The full error message with context
- A complete stack trace
- Often, suggestions for how to fix it
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:
- "The production server needs to stay running"
- "We can debug this in place"
- "Dev mode won't reproduce the issue" (we never actually tried)
- "It must be a version mismatch or dependency conflict"
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:
- Run dev mode first. Always, no exceptions.
- The error means hook call count changed between renders
- Look for early returns placed between hook declarations
- Check for async state that affects conditional logic around hooks
- With TipTap specifically, ensure
useEditoris always called regardless of configuration state
We hope this saves someone else the multi-day debugging session we went through. Don't be heroes. Just run dev mode.