
CodeCraftinghub
Before you commit AI code, run a 5 second vibe check: Can you explain why it's structured that way? Is the state management sensible? Does it look like a 2021 tutorial? Three quick questions that save hours of debugging later.
I have a confession. I‘ve shipped AI written code that I didn’t fully understand. It worked in the browser, passed the quick smoke test, and looked clean enough. Then two weeks later, I‘m staring at a production bug that makes zero sense, and the culprit is that one block of code I accepted without a second thought.
It’s a terrible feeling. And I know I'm not alone.
After enough of these self-inflicted fire drills, I built myself a tiny habit that changed everything. I call it the Vibe Check. It takes five seconds literally and it's saved me from merging things that would have cost me hours (or days) down the road.
Here's exactly how it works, with real examples so you can steal it wholesale.
AI assistants like Cursor and Copilot are brilliant at generating code that compiles. The syntax is correct. The patterns are recognizable. The function names make sense. But here's the trap: correct syntax doesn't mean correct logic, and familiar patterns don't mean the right pattern for your codebase.
The AI doesn't know your app's data flow. It doesn't know your team's conventions. It doesn't know that the fetch Users function returns a cached promise and you shouldn't be calling it inside a useEffect like it's 2019.
But the code looks fine. So we merge it. And then we pay for it later.
Before I commit any chunk of code I didn't write entirely myself, I pause and ask myself these three questions. If any answer is a "no" or a "hmm," the code doesn't go in until I fix it.
Can I explain why this code is structured this way, in one sentence, without pointing at the screen and shrugging?
This sounds almost too simple, but it's the most powerful gate. If I can't articulate the reasoning behind the code, I don't understand it. And if I don't understand it, I can't maintain it.
Real example: I recently had AI generate a custom hook for managing a multi-step form state. It looked like this:
function useMultiStepForm(steps) {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({});
const nextStep = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
}, [steps.length]);
const updateField = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
return { currentStep, formData, nextStep, updateField };
}Looks reasonable, right? But when I did the "Why" test, I realized I couldn't explain why nextStep was wrapped in useCallback but updateField wasn't. The AI had thrown in useCallback semi-randomly. There was no real performance concern. The dependency on steps.length was unnecessary because steps never changed after mount. A simpler version would have been:
function useMultiStepForm(steps) {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({});
const nextStep = () => {
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
};
const updateField = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
return { currentStep, formData, nextStep, updateField };
}I removed the useCallback entirely. The hook is simpler, the reasoning is clear, and I can explain it to a teammate in one breath: "It tracks current step index plus a flat form data object, and provides plain functions to advance and update fields." That's the bar.
If you find yourself saying, "I think it's for performance?" or "The AI put it there," pause. Don't commit yet.
Did the AI use useState where it should have used a ref? Did it shove server state into useEffect instead of using a proper data-fetching library?
AI models have an almost romantic attachment to useEffect. It's their comfort blanket. If they need data, they reach for useEffect with an empty dependency array, fetch inside it, set some local state, and call it a day. This is fine for a tiny demo. It's a nightmare in a real app where you need caching, retries, deduplication, and stale while revalidate logic.
I've seen AI write stuff like this more times than I can count:
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/products')
.then((res) => res.json())
.then((data) => {
setProducts(data);
setLoading(false);
});
}, []);This breaks the second you navigate away and come back. It refetches unconditionally. It doesn't cache. It doesn't handle errors gracefully. In my codebase, we use TanStack Query (React Query) for this. The AI didn't know thatunless I told it. The fix:
const { data: products, isLoading } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then((res) => res.json()),
});Now I get caching, background refetching, error boundaries, and a loading state that just works. The AI's version was a time bomb.
The other common state sin is using useState for values that don't need to trigger re-renders. I once caught AI generating a useState for a timer ID that was only ever read inside a useEffect cleanup function. That should have been a useRef. Re-renders on timer updates? No thanks.
Rule of thumb: If changing the value shouldn't cause a visual update, lean toward a ref. If the value comes from a server, use a library designed for it—not raw useEffect.
Does this look like it was copied from a Medium article titled "Top 10 React Hooks for 2021"?
AI models were trained on code from the past. Sometimes the distant past. I've seen AI generate class components in a hooks-only codebase. I've seen componentDidMount-style logic smuggled into functional components via bloated useEffect blocks. I've seen var instead of let/const in a TypeScript project. (That one was almost impressive in its wrongness.)
Real example from a PR I reviewed last month:
useEffect(() => {
// Runs once on mount (like componentDidMount)
loadUserPreferences();
const interval = setInterval(() => {
refreshToken();
}, 60000);
return () => {
clearInterval(interval);
};
}, []);This works. But it's a 2019 pattern. The comment even admits the mental model: "like componentDidMount." In 2026, that interval logic should probably live in a custom hook or an external store, not jammed into a useEffect that has nothing to do with rendering. And if the data is server state, we're back to TanStack Query's refetchInterval.
The freshness check is simple: If you squint and can picture a class component behind this code, it's probably outdated. React moved on. Hooks are the standard. Server Components exist. We have better patterns now. The AI doesn't always know that unless you shepherd it.
Here's a realistic scenario from my workflow. I ask Cursor for a component that displays a user's profile and lets them toggle between editing and viewing mode. I'm tired and a little lazy, so I accept the whole block without tweaking the prompt much.
What I get back:
const ProfileCard = ({ userId }) => {
const [user, setUser] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({});
const inputRef = useState(null); // ❌ Wrong—should be useRef
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setFormData(data);
});
}, [userId]);
const handleSave = async () => {
await fetch(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(formData),
});
setIsEditing(false);
};
if (!user) return <p>Loading...</p>;
return (
<div>
{isEditing ? (
<form onSubmit={handleSave}>
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<button type="submit">Save</button>
</form>
) : (
<div>
<h2>{user.name}</h2>
<button onClick={() => setIsEditing(true)}>Edit</button>
</div>
)}
</div>
);
};Let's run the Vibe Check.
1. The "Why" Test: Can I explain why useEffect fetches on userId change? Yes that part is clear. But why is inputRef declared as a state? I can't explain that. It should be a ref. Why is form data duplicated in both user and formData state? That's muddy. Not a clean pass.
2. The State Inspection: Fetching server state in useEffect classic. No caching, no error handling, no optimistic updates. Should be TanStack Query. Also, inputRef as state is a state misuse. Should be useRef (though ironically the generated code doesn't even use it anywhere, so it's dead code too).
3. The Freshness Check: The pattern is fundamentally functional, but that useEffect fetch pattern feels pre React Query. The inputRef state gaffe smells like an older mental model leak. Not fresh.
This code would technically run. A quick smoke test would pass. But in production, it's fragile and full of small landmines. The Vibe Check caught it in five seconds. I rejected the chunk, wrote a better prompt referencing our existing hooks, and got this instead:
const ProfileCard = ({ userId }) => {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
});
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(user?.name ?? '');
const updateUser = useMutation({
mutationFn: (data) =>
fetch(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => setIsEditing(false),
});
if (isLoading) return <p>Loading...</p>;
return (
<div>
{isEditing ? (
<form onSubmit={(e) => { e.preventDefault(); updateUser.mutate({ name }); }}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit">Save</button>
</form>
) : (
<div>
<h2>{user.name}</h2>
<button onClick={() => setIsEditing(true)}>Edit</button>
</div>
)}
</div>
);
};Cleaner. Uses the data layer we actually use. Mutations handled properly. The Vibe Check takes no time at all, and it turns AI scaffolding into solid code.
I'm not telling you to stop using AI. I use it every day. The Vibe Check isn't about paranoia it's about ownership. The code that lands in your repo has your name on it. The AI doesn't get paged at 3 a.m. when something breaks. You do.
By spending five seconds on these three questions, I catch most of the nonsense before it ever reaches a branch. The rest I catch in a proper code review, which is a topic for another day.
Try the Vibe Check on your next AI-aided PR. See how many "looks fine" moments it turns into "wait, let me fix that." I bet it's more than you think.
Join the newsletter for practical insights on architecture, code quality, and developer workflow.
Comments
No approved comments yet.