The Vibe Coding Delusion
Source: Dev.to
Introduction
I recently sat in a code review that terrified me. A junior engineer—bright, enthusiastic, well‑meaning—had just vibe coded a new feature. He described the “vibe” of the feature to an LLM, pasted the output into our repository, and opened a PR. It worked: the pixels were in the right place, the button clicked, the data loaded.
But the file contained hard‑coded timeout values, duplicated state logic across three components, and a useEffect hook with a wildly permissive dependency array. It was a “happy path” masterpiece that would explode in production.
The promise of the current AI wave is that natural language is the new programming language. You tell the computer what you want; it handles the how. This is dangerous because the how is where bugs and security vulnerabilities live.
Vibe‑coded example
// UserProfile.jsx (The Vibe Version)
import React, { useState, useEffect } from 'react';
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
useEffect(() => {
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setName(data.name);
setEmail(data.email);
});
}, [userId]);
const handleSave = () => {
fetch(`https://api.example.com/users/${userId}`, {
method: 'PUT',
body: JSON.stringify({ name, email }),
headers: { 'Content-Type': 'application/json' }
}).then(() => {
setIsEditing(false);
// refetch to update
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
});
};
if (!user) return Loading...;
return (
{isEditing ? (
setName(e.target.value)} />
setEmail(e.target.value)} />
Save
) : (
## {user.name}
{user.email}
setIsEditing(true)}>Edit
)}
);
};
export default UserProfile;
For a junior developer this looks clean and works. For a senior engineer it raises red flags.
Why the Vibe code is problematic
Race condition
The useEffect fires a fetch each time userId changes. Rapid changes can cause out‑of‑order responses, displaying the wrong user data. The AI does not automatically use AbortController to cancel stale requests.
State desynchronization
Separate local states (name, email) are manually synced with user. If the parent updates user, the local fields may become stale, leading to bugs.
Missing error handling
No .catch() on the fetch promises. If the API is down, the component may crash or display nothing, because the AI assumes a happy path.
Hard‑coded fragility
URLs are hard‑coded, and there is no loading state for the save action. Multiple rapid clicks can fire duplicate PUT requests.
Security oversight
No authorization headers are sent. The component would silently fail with a 401 in a real application.
Specification Engineering approach
Define types and contracts
// types.ts
export type User = {
id: string;
name: string;
email: string;
updatedAt: string;
};
export type UserProfileProps = {
userId: string;
onUpdate?: (user: User) => void;
// Dependency injection for fetching prevents hardcoded URLs
fetchUser: (id: string, signal: AbortSignal) => Promise;
updateUser: (id: string, data: Partial) => Promise;
};
Prompt with explicit constraints
Prompt:
Generate aUserProfilecomponent based on theUserProfilePropsinterface above.
Constraints:
- Use
useQuery(or a similar pattern) to handle loading and error states explicitly.- Handle race conditions with
AbortSignal.- Show a loading state on the Save button to prevent double‑submission.
- Do not duplicate state; treat server data as the source of truth.
- Display user‑friendly error UI instead of console logs.
Engineered version
// UserProfile.tsx (The Engineered Version)
import React, { useState, useEffect } from 'react';
import { User, UserProfileProps } from './types';
// Utility for safe state management
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
export const UserProfile = ({
userId,
fetchUser,
updateUser,
onUpdate,
}: UserProfileProps) => {
const [state, setState] = useState>({ status: 'idle' });
const [isSaving, setIsSaving] = useState(false);
const [editForm, setEditForm] = useState | null>(null);
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
fetchUser(userId, controller.signal)
.then(data => setState({ status: 'success', data }))
.catch(error => {
if (error.name !== 'AbortError') {
setState({ status: 'error', error });
}
});
return () => controller.abort();
}, [userId, fetchUser]);
const handleSave = async () => {
if (!editForm || state.status !== 'success') return;
try {
setIsSaving(true);
const updated = await updateUser(userId, editForm);
setState({ status: 'success', data: updated });
setEditForm(null); // Exit edit mode
onUpdate?.(updated);
} catch (e) {
alert('Failed to save'); // Ideally use a toast system here
} finally {
setIsSaving(false);
}
};
if (state.status === 'loading') return Loading...;
if (state.status === 'error')
return Error: {state.error.message};
const user = state.status === 'success' ? state.data : null;
return (
{editForm ? (
setEditForm({ ...editForm, name: e.target.value })}
/>
setEditForm({ ...editForm, email: e.target.value })}
/>
{isSaving ? 'Saving…' : 'Save'}
) : (
user && (
## {user.name}
{user.email}
setEditForm({ name: user.name, email: user.email })}>
Edit
)
)}
);
};
Takeaway
The solution isn’t to stop using AI; it’s to change how you use it.
- Write clear specifications, types, and constraints before prompting.
- Treat AI‑generated snippets as drafts that must be reviewed, tested, and hardened for production.
- Keep the “how” explicit—error handling, race‑condition mitigation, security, and state management—so the resulting code is reliable, maintainable, and safe.