DEV Community

Cover image for AI Keeps Reinventing Your Components. Here's How to Stop It.
Vuong Ngo
Vuong Ngo

Posted on

AI Keeps Reinventing Your Components. Here's How to Stop It.

Three days before a customer pilot, our PM pinged me: "Can we ship that analytics dashboard?" The design had been sitting in Figma for weeks. I promised I'd have it in production by Friday with AI co-pilot.

By Wednesday morning, the PR was still in draft. Not because the UI was hard—it looked exactly like the mock—but because the AI kept inventing work.

Here's what a typical week produced:

// Monday - inline styles
export const RevenueCard = () => {
  return (
    <div style={{
      background: 'white',
      borderRadius: '12px',
      padding: '24px',
      boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
    }}>
      <span style={{ color: '#6B7280', fontSize: '14px' }}>Total Revenue</span>
      <div style={{ fontSize: '32px', fontWeight: 600 }}>$124,500</div>
    </div>
  );
};

// Tuesday - MUI (we use Tailwind)
import { DataGrid } from '@mui/x-data-grid';

// Wednesday - CSS modules (since when?)
import styles from './FilterPanel.module.css';

// Thursday - styled-components (not even installed)
import styled from 'styled-components';
Enter fullscreen mode Exit fullscreen mode

Four days. Four completely different approaches. The code worked, technically. But maintaining it? Good luck.

The root cause became obvious: AI doesn't read documentation the way humans do. It pattern-matches. And if your codebase doesn't have clear patterns to match, AI will invent its own—differently every time.

The Lesson

AI reflects your architecture. Chaotic codebase, chaotic output. Structured codebase, structured output.

After months of trial and error, here's what actually works.


1. Separate State From Representation (Smart vs Dumb Components)

AI writes strange things when fetch logic, loading UI, and display live in the same file. Split them.

// Container: owns data fetching
export function RevenueCardContainer() {
  const { data, error, isLoading } = useRevenue();

  if (isLoading) return <RevenueCardView state="loading" />;
  if (error) return <RevenueCardView state="error" message="Revenue unavailable" />;
  if (!data) return <RevenueCardView state="empty" message="No revenue yet" />;

  return <RevenueCardView state="ready" value={data.value} previousValue={data.previousValue} />;
}

// Presentational: pure UI, tokens only
export function RevenueCardView({ state, value, previousValue, message }: Props) {
  if (state === 'loading') return <MetricCard loading label="Revenue" />;
  if (state === 'error') return <MetricCard label="Revenue" error message={message} />;
  if (state === 'empty') return <MetricCard label="Revenue" empty message={message} />;

  return (
    <MetricCard
      label="Revenue"
      value={value}
      previousValue={previousValue}
      format="currency"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Storybook becomes the contract AI must honor. Capture the four canonical states—loading, empty, error, ready—so the bot can't invent new ones:

// RevenueCardView.stories.tsx
export const Loading = { args: { state: 'loading' } };
export const Empty = { args: { state: 'empty', message: 'No revenue yet' } };
export const Error = { args: { state: 'error', message: 'Revenue unavailable' } };
export const Ready = { args: { state: 'ready', value: 124500, previousValue: 110600 } };
Enter fullscreen mode Exit fullscreen mode

AI stops rebuilding components that already exist because the stories show the "golden" versions.


2. Adopt Atomic Design

Atomic Design by Brad Frost turns out to be exactly what you need when AI is generating your code.

The core insight: hierarchical composition. Atoms form molecules. Molecules form organisms. Each level has a single responsibility.

Why does this matter for AI? Because AI excels at composition when given well-defined pieces and falls apart when rules are ambiguous.

// Level 1: Atoms - indivisible primitives
export const Text = ({ variant, color, children }: TextProps) => (
  <span className={cn(textVariants[variant], textColors[color])}>
    {children}
  </span>
);

export const Skeleton = ({ variant = 'text' }: SkeletonProps) => (
  <div className={cn('animate-pulse bg-muted rounded', skeletonVariants[variant])} />
);

// Level 2: Molecules - atoms with a purpose
export const MetricValue = ({ value, previousValue, format, loading }: Props) => {
  if (loading) {
    return (
      <div className="space-y-2">
        <Skeleton variant="heading" />
        <Skeleton variant="text" className="w-16" />
      </div>
    );
  }

  return (
    <div className="space-y-1">
      <Text variant="display" color="primary">{formatters[format](value)}</Text>
      {previousValue && <TrendIndicator value={calculateChange(value, previousValue)} />}
    </div>
  );
};

// Level 3: Organisms - complete UI sections
export const MetricCard = ({ label, value, previousValue, format, loading }: Props) => (
  <Card variant="elevated" padding="lg">
    <MetricLabel label={label} />
    <MetricValue value={value} previousValue={previousValue} format={format} loading={loading} />
  </Card>
);
Enter fullscreen mode Exit fullscreen mode

Now when I ask for "a revenue metric card," AI composes: <MetricCard label="Revenue" value={revenue} format="currency" />. Consistent every time.


3. Design Tokens as Vocabulary

Components solve structural consistency. Design tokens solve visual consistency.

Named constants for every visual decision—not "blue" but action-primary, not "16px" but spacing-4:

export const colors = {
  action: {
    primary: '#6366F1',
    primaryHover: '#4F46E5',
  },
  surface: {
    page: '#F9FAFB',
    card: '#FFFFFF',
  },
  content: {
    primary: '#111827',
    secondary: '#4B5563',
    muted: '#9CA3AF',
  },
} as const;
Enter fullscreen mode Exit fullscreen mode

Wire them through Tailwind config:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        action: {
          primary: 'var(--color-action-primary)',
        },
        surface: {
          card: 'var(--color-surface-card)',
        },
        content: {
          primary: 'var(--color-content-primary)',
        },
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Now when AI writes bg-surface-card or text-content-secondary, it's speaking your design language. No hex codes that drift.


4. Scaffold Before You Generate

AI behaves best when you give it a guardrailed sandbox instead of a blank file.

A command like pnpm ui:generate metric-card should create:

MetricCard/
├── MetricCard.tsx        # Container
├── MetricCardView.tsx    # Presentational
├── MetricCard.stories.tsx
├── MetricCard.test.tsx
└── index.ts
Enter fullscreen mode Exit fullscreen mode

The generated files include TODOs and comments telling AI where to edit and where not to touch. AI fills the blanks instead of rewriting the world. You can also use this mcp to help with scaffolding with the folder structure you liked.


5. Enforce Contracts with Lint and Stories

Static rules catch mistakes before they ship.

// eslint.config.mjs
export default [
  {
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: ['styled-components', '@mui/material'],
          patterns: [
            { group: ['**/../*'], message: 'Import UI from @/components/ui' },
          ],
        },
      ],
      'tailwindcss/no-custom-classname': [
        'error',
        { callees: ['cn'], config: './tailwind.config.js' },
      ],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode
  • ESLint bans off-piste imports
  • Tailwind plugin forces token utilities
  • CI fails if stories miss the four states

Mistakes die in CI, not in code review.


Bonus: Composition Over God Components

Don't build this:

// ❌ 60+ props nobody can reason about
<DataTable
  data={transactions}
  columns={columns}
  pagination={true}
  paginationPosition="bottom"
  sortable={true}
  filterable={true}
  selectable={true}
  // ... 55 more props
/>
Enter fullscreen mode Exit fullscreen mode

Build this:

// ✅ Composition: each piece does one thing well
<DataTable data={transactions} columns={columns}>
  <DataTableToolbar>
    <DataTableFilter column="status" options={statusOptions} />
    <DataTableSearch placeholder="Search..." />
  </DataTableToolbar>
  <DataTableBody loading={isLoading} emptyState={<Empty />} />
  <DataTableFooter>
    <DataTablePagination pageSize={10} />
  </DataTableFooter>
</DataTable>
Enter fullscreen mode Exit fullscreen mode

Same capabilities, different mental model. When requirements change, you reorganize JSX rather than hunting through props.


Extra Tips

Embed source locations in the DOM:

<div
  data-component="MetricCard"
  data-source="src/components/MetricCard/MetricCard.tsx"
>
Enter fullscreen mode Exit fullscreen mode

AI can inspect the DOM and jump straight to the file. No guessing.

Sub-agents to save context:

Your main conversation doesn't need the entire component library in memory. Spin up focused agents for specific tasks (UI fixes, story writing, a11y audits)—they load only what they need and return.

Reusable commands:

Build /add-story, /review-component, /fix-ui commands that encode your conventions. AI follows them without you repeating yourself.


The Path Forward

The fix for inconsistent AI output isn't better prompting. It's tighter architecture.

Every atom you add to your library is an atom AI never reinvents. Every design token is a decision that never drifts. Every composition pattern is a template for variations you haven't thought of yet.

Build the rails, the bot stays on track.


Originally published at agiflow.io

Top comments (0)