So your side project was humming along beautifully. You prompted your way through a full-stack app in a weekend, the demo looked great, and then... something broke. And you have no idea why.
Welcome to the hangover phase of vibe coding.
The term "vibe coding" — coined by Andrej Karpathy in early 2025 — describes the practice of leaning heavily on AI to generate code while you mostly steer at a high level. You describe what you want, accept the output, and keep moving. It's genuinely fun. It's also a fantastic way to build something that becomes unmaintainable within weeks.
I'm not here to tell you vibe coding is bad. I've used it myself to prototype ideas fast. But I've also had to untangle the aftermath — both in my own projects and in code other developers handed off to me. Here's how to diagnose and fix the most common failures.
The Root Problem: No Mental Model
When you write code yourself, you build a mental model of how the pieces fit together. When AI writes it, that model is shallow at best. You know what the app does but not how or why specific decisions were made.
This means when something breaks, you're essentially debugging a stranger's code. Except that stranger had no long-term memory between prompts and might have contradicted itself three files ago.
The symptoms are predictable:
- State management that works until it doesn't (usually around the third feature addition)
- Duplicated logic scattered across components because each prompt started fresh
- Error handling that's either completely absent or cargo-culted from training data
- Dependencies pulled in for trivial tasks because the model defaulted to what it's seen most often
Step 1: Map What You Actually Have
Before fixing anything, you need to understand the codebase you've inherited from your past self (and your AI assistant). I start with a dependency audit:
# Check for unused dependencies (Node.js example)
npx depcheck
# Look at your bundle — you might be shocked
npx vite-bundle-visualizer
# or for webpack projects:
npx webpack-bundle-analyzer stats.json
In one vibe-coded project I inherited, depcheck flagged 14 unused packages. The AI had installed moment, date-fns, AND dayjs across different prompts. Three date libraries. For an app with two date fields.
Next, trace your data flow manually. Open your entry point and follow the logic yourself, file by file. Yes, this is tedious. No, there's no shortcut. I usually sketch it out on paper — what calls what, where state lives, which API endpoints exist.
Step 2: Fix the State Spaghetti
The single most common mess in vibe-coded React apps is state management. AI models love to solve immediate problems by adding useState everywhere, and they rarely refactor toward a cohesive pattern.
Here's what I typically find:
// The vibe-coded version — state scattered everywhere
function Dashboard() {
const [user, setUser] = useState(null);
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedProject, setSelectedProject] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [filterText, setFilterText] = useState('');
const [sortOrder, setSortOrder] = useState('desc');
// ...15 more useState calls
// Each piece fetched independently with duplicate error handling
useEffect(() => { /* fetch user */ }, []);
useEffect(() => { /* fetch projects */ }, []);
useEffect(() => { /* fetch something else */ }, []);
}
The fix isn't complicated, but it requires thought about what state actually belongs together:
// Step 1: Group related state with useReducer
function projectReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, projects: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'SELECT':
return { ...state, selectedProject: action.payload };
default:
return state;
}
}
// Step 2: Extract data fetching into a custom hook
function useProjects() {
const [state, dispatch] = useReducer(projectReducer, {
projects: [],
selectedProject: null,
loading: true,
error: null,
});
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetch('/api/projects')
.then(res => res.json())
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
}, []);
return { ...state, dispatch };
}
The key insight: vibe coding produces locally correct code. Each prompt got a reasonable answer. But nobody was thinking about the global architecture. That's your job now.
Step 3: Consolidate the Duplicate Logic
Search your codebase for patterns that appear more than twice. In vibe-coded projects, you'll almost always find duplicated API calls, repeated validation logic, and copy-pasted error handling.
# Find suspiciously similar files
# jscpd detects copy-pasted code blocks
npx jscpd ./src --min-lines 5 --reporters console
When I ran this on a project last month, it found 23% duplication. Nearly a quarter of the codebase was copy-paste with minor variations. The fix is boring but effective: extract shared logic into utility functions and custom hooks, then delete the duplicates.
Step 4: Add the Tests That Should Have Been There
Here's the uncomfortable truth — most vibe coding sessions skip tests entirely. The feedback loop of "prompt → see it work in the browser → prompt again" feels productive, but it builds on sand.
You don't need 100% coverage. Start with integration tests for your critical paths:
// Focus on user-facing flows, not implementation details
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('user can create a project and see it in the list', async () => {
render(<App />);
// Navigate to the form
await userEvent.click(screen.getByText('New Project'));
// Fill it out
await userEvent.type(screen.getByLabelText('Name'), 'My Project');
await userEvent.click(screen.getByText('Create'));
// Verify it appears
await waitFor(() => {
expect(screen.getByText('My Project')).toBeInTheDocument();
});
});
These tests serve double duty: they verify behavior AND they force you to understand the user flows your app actually supports.
Step 5: Prevent This Next Time
Vibe coding isn't going away, and honestly it shouldn't. It's a legitimate tool for exploration and prototyping. The trick is knowing when to shift gears.
Here's the approach that's been working for me:
- Prototype phase: Vibe code freely. Go fast. Don't worry about architecture.
- Commit point: Once you decide the project is worth continuing, stop. Read every file. Understand the decisions that were made.
- Refactor phase: Restructure before adding more features. This is where you build your mental model.
- Feature phase: Use AI assistance but review every suggestion critically. Ask "does this fit the architecture I've established?"
The developers I see struggling the most are the ones who never leave the prototype phase. They keep prompting and accepting, prompting and accepting, until the codebase is a tangled mess of contradictory patterns.
The Honest Take
Vibe coding accelerates the fun part of development — seeing your idea come to life. But software engineering was never mainly about the initial build. It's about maintaining, debugging, and extending code over time.
The fix isn't to stop using AI tools. The fix is to treat AI-generated code with the same scrutiny you'd give a pull request from a new junior developer — someone talented but unfamiliar with your codebase and your conventions.
Read the code. Understand the code. Own the code. That's still the job, no matter who or what wrote it.
Top comments (0)