跪拜 Guibai
← Back to the summary

Five Production Killers Lurking in Vibe-Coded React

Last week I helped a friend working on an indie project review his code. He used Claude Code + Cursor and built a complete React admin dashboard in three days — login works, CRUD works, Excel export works, and the UI looks decent.

He said: "Take a look, I'm about to launch."

I opened the project, ran npm start, and the pages indeed worked. Then I opened the code — after ten minutes I told him: "It runs, but don't launch. At least fix these five things first."

This isn't an isolated case. The biggest problem with Vibe Coding right now isn't "it can't write code" — it's that the output looks great but is full of time bombs. The FT recently ran a piece titled "Who cleans up after the vibe-coding party?" Someone on Reddit also said: "The first 80% of vibe coding feels fast. The last 20% has broken me."

Here are five typical problems I found in that codebase. If you're using AI to write code, check against these.

Problem 1: Dumping every piece of state into global context

Opening the state management, I saw a giant AppContext:

// ❌ AI's favorite move: shove all state into one Context
const AppContext = createContext(null);

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [sidebarOpen, setSidebarOpen] = useState(true);
  const [notifications, setNotifications] = useState([]);
  const [tableData, setTableData] = useState([]);
  const [filters, setFilters] = useState({});
  const [selectedRows, setSelectedRows] = useState([]);
  const [modalVisible, setModalVisible] = useState(false);
  const [formData, setFormData] = useState({});
  const [loading, setLoading] = useState(false);
  // ...20 more

  return (
    <AppContext.Provider value={{
      user, setUser, theme, setTheme,
      sidebarOpen, setSidebarOpen,
      // ...expose everything
    }}>
      {children}
    </AppContext.Provider>
  );
}

Over 40 pieces of state, all stuffed into one Provider. The result: any single state change re-renders the entire component tree.

You tick a row in the table, selectedRows changes — the sidebar, navbar, and notification popover all re-render.

Why does AI write this way? Because you tell it "add feature X" and it just adds another state to the existing Context. It never proactively says "this state should live in its own Context."

// ✅ Split Context by responsibility
const AuthContext = createContext(null);
const UIContext = createContext(null);

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <AuthContext.Provider value={{ user, setUser }}>
      {children}
    </AuthContext.Provider>
  );
}

function UIProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const [sidebarOpen, setSidebarOpen] = useState(true);
  return (
    <UIContext.Provider value={{ theme, setTheme, sidebarOpen, setSidebarOpen }}>
      {children}
    </UIContext.Provider>
  );
}

// Table-page state stays inside the table component; it never needed Context
function DataTable() {
  const [selectedRows, setSelectedRows] = useState([]);
  const [filters, setFilters] = useState({});
  // ...
}

Rule of thumb: is this state used by only one page/component? If only one place uses it, don't put it in Context.

Problem 2: Every request runs naked

I skimmed the data-fetching code:

// ❌ Typical AI-written request code: fire and forget
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => setUsers(data));
  }, []);

  const handleDelete = async (id) => {
    await fetch(`/api/users/${id}`, { method: 'DELETE' });
    // Re-fetch the whole list after delete
    const res = await fetch('/api/users');
    const data = await res.json();
    setUsers(data);
  };

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>
          {u.name}
          <button onClick={() => handleDelete(u.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

Checklist of issues:

// ✅ Production-grade requests should look like this
function UserList() {
  const queryClient = useQueryClient();
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
  });

  const deleteMutation = useMutation({
    mutationFn: (id) => fetch(`/api/users/${id}`, { method: 'DELETE' }),
    onMutate: async (id) => {
      await queryClient.cancelQueries({ queryKey: ['users'] });
      const prev = queryClient.getQueryData(['users']);
      queryClient.setQueryData(['users'], old =>
        old.filter(u => u.id !== id)
      );
      return { prev };
    },
    onError: (err, id, context) => {
      queryClient.setQueryData(['users'], context.prev);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorFallback error={error} />;

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>
          {u.name}
          <button
            disabled={deleteMutation.isPending}
            onClick={() => deleteMutation.mutate(u.id)}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

It's not that every request needs this much code. It's that AI never proactively considers these edge cases. It only implements the "happy path" you described; all the error paths simply don't exist.

Problem 3: Environment variables hardcoded in plain text

This one made me the most nervous:

// ❌ I actually saw this in the code
const supabase = createClient(
  'https://xxxxx.supabase.co',
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx...'
);

// In another file
const STRIPE_KEY = 'sk_live_xxxxxxxxxxxxx';

Supabase's anon key written directly in the code. Stripe's secret key written directly in frontend code.

I asked him: "Where did this key come from?"

He said: "I told Claude my key in the prompt, and it configured it for me."

AI never proactively tells you "this key shouldn't be in frontend code." It just makes the code run. If you give it a key in the prompt, it writes it in verbatim.

// ✅ Environment variables must go through .env
// .env.local (do not commit to git)
// NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
// NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);

// Stripe secret key must never appear on the frontend
// Must be placed in a server-side API route
// pages/api/checkout.js
// const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

Checklist:

What to check How to check
Any hardcoded keys in the code Global search for sk_, eyJ, key, secret, password
.gitignore ignores .env Open .gitignore and look for .env*
Frontend code contains backend secrets Keys in frontend code must only have NEXT_PUBLIC_ or VITE_ prefix
Git history has leaked keys git log --all -p -- '*.env'

Problem 4: Permission checks only on the frontend

I saw a "page only admins can see" and opened the routing:

// ❌ AI-written "permission control"
function AdminRoute({ children }) {
  const { user } = useAuth();

  if (user?.role !== 'admin') {
    return <Navigate to="/dashboard" />;
  }

  return children;
}

// Route config
<Route path="/admin/users" element={
  <AdminRoute>
    <UserManagement />
  </AdminRoute>
} />

Looks fine, right? Only admins can enter the admin page.

But — open the API calls inside the UserManagement component:

// This endpoint can be called by anyone
const handleDeleteUser = async (userId) => {
  await fetch(`/api/admin/users/${userId}`, {
    method: 'DELETE',
  });
};

The frontend hides the button, but the API has no permission check. Anyone opens browser DevTools, directly calls /api/admin/users/123, and deletes a user.

This is "Broken Access Control" from the OWASP Top 10 — the number one web security vulnerability for years running.

// ✅ Permissions must be verified on the backend (Next.js API Route example)
export default async function handler(req, res) {
  const session = await getServerSession(req, res, authOptions);

  if (!session || session.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }

  if (req.method === 'DELETE') {
    const { id } = req.query;
    await db.user.delete({ where: { id } });
    return res.status(200).json({ success: true });
  }

  res.status(405).end();
}

Iron rule: frontend permission checks are UX optimization (don't show users buttons they can't use); backend permission checks are the actual security line. You need both, but the backend one is non-negotiable.

Problem 5: Gigantic components, 500 lines in a single file

This is the most visible signature of AI-written code — all logic crammed into one component:

// ❌ AI's classic output: a 500-line "complete" component
function OrderManagement() {
  // 20 useState calls
  const [orders, setOrders] = useState([]);
  const [filters, setFilters] = useState({});
  const [selectedOrder, setSelectedOrder] = useState(null);
  const [isEditing, setIsEditing] = useState(false);
  // ...

  // 10 handler functions
  const handleSearch = () => { /* 30 lines */ };
  const handleFilter = () => { /* 25 lines */ };
  const handleEdit = () => { /* 40 lines */ };
  const handleDelete = () => { /* 20 lines */ };
  const handleExport = () => { /* 50 lines */ };
  // ...

  // 200 lines of JSX
  return (
    <div>
      {/* Search bar */}
      <div>{/* 50 lines of search form */}</div>
      {/* Filters */}
      <div>{/* 40 lines of filter conditions */}</div>
      {/* Table */}
      <table>{/* 80 lines of table rendering */}</table>
      {/* Edit modal */}
      {isEditing && <div>{/* 60 lines of edit form */}</div>}
      {/* Pagination */}
      <div>{/* 30 lines of paginator */}</div>
    </div>
  );
}

Why does AI like writing this way? Because you said "build an order management page" and it implements everything in one file. Its goal is "make your requirement run," not "make the code maintainable."

When you need to fix a filter bug, you have to find those 20 lines inside 500 lines of code. When you need to add a column to the table, you have to understand all the state dependencies across those 500 lines.

// ✅ Split by responsibility
// components/OrderFilters.jsx
function OrderFilters({ value, onChange }) {
  return (/* Filter UI, only handles filtering */);
}

// components/OrderTable.jsx
function OrderTable({ data, onEdit, onDelete }) {
  return (/* Table UI, only handles display */);
}

// components/OrderEditModal.jsx
function OrderEditModal({ order, onSave, onClose }) {
  return (/* Edit modal, only handles editing */);
}

// hooks/useOrders.js
function useOrders(filters) {
  return useQuery({
    queryKey: ['orders', filters],
    queryFn: () => fetchOrders(filters),
  });
}

// pages/OrderManagement.jsx — composition layer, under 50 lines
function OrderManagement() {
  const [filters, setFilters] = useState({});
  const [editingOrder, setEditingOrder] = useState(null);
  const { data: orders, isLoading } = useOrders(filters);

  return (
    <div>
      <OrderFilters value={filters} onChange={setFilters} />
      <OrderTable
        data={orders}
        onEdit={setEditingOrder}
        onDelete={handleDelete}
      />
      {editingOrder && (
        <OrderEditModal
          order={editingOrder}
          onSave={handleSave}
          onClose={() => setEditingOrder(null)}
        />
      )}
    </div>
  );
}

Review Quick-Reference Table

Check Item How to Check Common AI Code Problem
Global state Search for Context/Provider All state stuffed into one Context
Request handling Search for fetch/axios No loading/error/race-condition handling
Key leaks Search for sk_/eyJ/secret/password Hardcoded in frontend code
Permission control Check if API routes have auth checks Frontend hides buttons but endpoints are naked
Component size Check file line count Single file 500+ lines, all logic mixed together

Vibe Coding isn't the problem; not reviewing is

I'm not against Vibe Coding. Shipping a fully functional admin dashboard in three days was unimaginable two years ago.

But AI-generated code and human-written code need the same review standards. You wouldn't let an intern's code go straight to production without review, and AI-written code shouldn't either.

The difference: with an intern's code you can spot the problems at a glance. AI-written code looks professional — clean naming, clear structure, thorough comments. But those five problems hide inside code that "looks right."

If you're Vibe Coding a project headed for production, at least run through the quick-reference table above.

Have you reviewed AI-written code? Found anything that made you break into a cold sweat?

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

彦祖在编程

Just create more rules, no setup needed, that's it. More rules cost tokens, but the results are much better. Try to achieve the effect you want.

kyriewen

Yes, add more constraints, skills, etc.