DEV Community

Cover image for I Let GitHub Copilot Define My React Standards for Six Months. Here's the Damage It Did.
Bishoy Bishai
Bishoy Bishai

Posted on • Edited on • Originally published at bishoy-bishai.github.io

I Let GitHub Copilot Define My React Standards for Six Months. Here's the Damage It Did.

AI doesn't invent bad code. It invents averageness — confidently, quickly, and at scale.


There's a specific kind of technical debt that doesn't show up in your linter.

It's not a bug. It's not a performance issue. It's not something your CI pipeline catches. It's the kind of debt where six months into a project, two developers open components that do the same thing — and neither can understand the other's approach. Both are "correct." Both would pass code review. But they feel like they were written by people who never spoke to each other.

In 2022, during the early months of the Passbase design system work, I watched this happen in real time. We had Copilot. We loved Copilot. We used Copilot for everything. And slowly, invisibly, Copilot became the de facto architect of our component patterns — not because we decided that, but because we never decided anything else.

A button component in one feature used interface for props. Another used type. One used React.FC<Props>. Another used the function signature directly. One had a Button/ folder with an index file. Another was just ButtonComponent.tsx sitting flat in components. All of them passed TypeScript. All of them worked. None of them felt like they belonged to the same codebase.

Copilot didn't cause this. We did. By treating every AI suggestion as a decision rather than an input, we outsourced our architectural thinking to a model trained on the average of the internet. And the internet's average is not your team's standard.

What you'll be able to DO after reading this:

  • Understand the specific psychological trap that makes AI tools drift toward architectural chaos
  • Build a React standard document that Copilot actually reinforces instead of undermines
  • Know which decisions must be made by humans and which ones AI can safely handle
  • Walk away with a folder structure, naming convention, and component pattern system you can ship to your team tomorrow

This is Pillar 2 content — for the senior developer who's been on the team long enough to see the drift, and wants to fix it before it becomes a rewrite.


The Psychological Trap Nobody Names

I want to spend time on the mechanism before the solution, because if you don't understand why this happens, you'll implement standards and watch them erode anyway.

Here's the trap: a suggestion that arrives pre-formed is harder to reject than a blank page.

When you open a new file and need to decide how to structure a component, you have a moment of creative friction. You think about the codebase. You think about the pattern used in the last three components you touched. You make a decision that's connected to context.

When Copilot opens that same file and immediately suggests a complete component structure, that friction disappears. The suggestion looks reasonable. It compiles. You accept it and move on.

What just happened? You didn't make a decision. You approved a default. And defaults, repeated across a team of five developers over six months, become the de facto standard — not because anyone chose it, but because nobody rejected it.

Picasso said: "Computers are useless. They can only give you answers."

He was being provocative, but the insight is precise. Copilot is extraordinarily good at answers. It will give you A valid component structure. A valid prop pattern. A valid hook. But "valid" is not "right for your context." Only you know your context. Only your team has the history of decisions that make one pattern better than another for your specific product.

The moment you stop asking "what should our standard be?" and start asking "what did Copilot suggest?" — you've given the architect's chair to a model that has never seen your codebase, never attended your design reviews, and never had to maintain the code it generated.

At Passbase, when I finally named this pattern to the team, the reaction was immediate recognition. Everyone had felt the drift. Nobody had named what was causing it.


What "Averageness at Scale" Looks Like

Let me show you the actual damage before I show you the fix.

This is what organic Copilot-driven drift looks like after six months on a real team:

src/components/
├── Button.tsx                    ← Developer A, month 1
├── ButtonComponent.tsx           ← Developer B, month 2 (same thing, different name)
├── PrimaryButton/
│   └── PrimaryButton.tsx         ← Developer C, month 3 (folder style)
├── ui/
│   └── button.tsx                ← Developer D, month 4 (shadcn influence)
└── IconButton.tsx                ← Developer A again, month 5 (forgot the folder pattern)
Enter fullscreen mode Exit fullscreen mode

Five developers. Five buttons. Zero decisions. All of them "correct."

Now multiply this across forms, modals, inputs, cards, hooks, services, and API layers. You don't have a codebase. You have a collection of individually reasonable files that happen to be in the same repository.

The cost isn't the inconsistency itself — developers can read inconsistent code. The cost is the cognitive overhead of uncertainty. Every developer who opens a new file asks: "How should I do this?" And without a standard, the answer is always: "I'll see what Copilot suggests." Which perpetuates the problem.

This is Sultan Ibrahim in the cage. Not locked in by force — locked in by comfort. Why struggle with the decision when the suggestion is right there?


The Standard Document: What It Contains and Why

The solution isn't to turn off Copilot. It's to give Copilot something to accelerate.

A standard document is a decision record — the output of the architectural conversations your team should be having explicitly, written down so they don't have to be re-litigated every time someone opens a new file.

Here's ours. Not as a template to copy — as a worked example to understand.

1. Folder Structure — The First Decision That Shapes Everything

Your folder structure is an architectural statement. It says: "This is how we think about our application." Get this wrong and every subsequent decision builds on a shaky foundation.

src/
├── app/                    # App shell — routing, global layout, providers
│   ├── layout.tsx          # Root layout
│   ├── router.tsx          # Route definitions
│   └── providers.tsx       # Context providers stacked here, not scattered
│
├── components/             # Shared UI primitives — NO business logic here
│   ├── Button/
│   │   ├── Button.tsx      # The component
│   │   ├── Button.test.tsx # Tests live next to what they test
│   │   └── index.ts        # Re-export for clean imports
│   ├── Input/
│   ├── Modal/
│   └── index.ts            # Barrel export for all shared components
│
├── features/               # Domain features — business logic lives here
│   ├── Auth/
│   │   ├── components/     # Components only used inside Auth
│   │   ├── hooks/          # Hooks only used inside Auth
│   │   ├── services/       # API calls only used inside Auth
│   │   ├── types.ts        # Types only used inside Auth
│   │   └── AuthPage.tsx    # The page component
│   ├── UserProfile/
│   └── Dashboard/
│
├── hooks/                  # Reusable hooks — NO feature-specific logic
├── services/               # API layer — all fetch calls go through here
├── types/                  # Global TypeScript types
├── utils/                  # Pure helper functions
└── main.tsx
Enter fullscreen mode Exit fullscreen mode

The decision this encodes: business logic belongs in features/, not in components/. A button doesn't know about authentication. An auth form doesn't belong in components/. This boundary is the single most important architectural decision in the document — because it determines where every future file goes.

Why Copilot violates this without guidance: Copilot looks at the nearest files for context. If you're in features/Auth/ and ask for a button, it might generate a button component right there — not because it's wrong, but because it doesn't know your folder structure convention. Without the standard, the developer accepts it. With the standard, the developer knows to reach for components/Button/ instead.

2. Naming Conventions — The Decisions That Eliminate a Thousand Arguments

// ✅ Our conventions — written down once, never debated again

// Components: PascalCase, noun-based
// ✅ UserProfileCard.tsx
// ✅ AuthenticationForm.tsx
// ❌ userProfileCard.tsx
// ❌ UserProfileCardComponent.tsx  ← "Component" suffix is redundant
// ❌ UPCard.tsx  ← Abbreviations are hidden knowledge

// Props interfaces: ComponentNameProps
// ✅ interface ButtonProps {}
// ✅ interface UserProfileCardProps {}
// ❌ interface IButtonProps {}  ← I-prefix is hungarian notation, we don't use it
// ❌ interface Props {}  ← Too generic, hard to search

// Custom hooks: always useNoun or useNounVerb
// ✅ useAuth
// ✅ useUserProfile
// ✅ useProductFilter
// ❌ useFetchUser  ← The "fetch" is an implementation detail, not the hook's identity

// Event handlers: always handleEventName
// ✅ handleSubmit, handleClick, handleFilterChange
// ❌ onSubmit (that's for the prop name, not the handler variable)
// ❌ submitForm, clickButton (verbs without "handle" prefix)
Enter fullscreen mode Exit fullscreen mode

Why this matters more than it looks: naming conventions are how your team searches the codebase. If half your props are onX and half are handleX, your grep results are always incomplete. If some components have I prefix and some don't, TypeScript autocomplete is inconsistent. These are tiny individual decisions that compound into daily friction.

3. Component Architecture — The Three Patterns We Use and When

This is the hardest part to standardize, because it requires actual architectural judgment. Here's ours:

Pattern A: Simple Presentational Component

Use when: the component receives data and renders it. No fetching. No complex state. No side effects.

// components/UserAvatar/UserAvatar.tsx
// This component has one reason to change: the avatar's visual design.
// It doesn't know where the user came from or how their data is structured globally.

interface UserAvatarProps {
  name: string;
  imageUrl?: string;
  size?: 'sm' | 'md' | 'lg';
}

// We use function declaration over React.FC — team decision.
// Reason: React.FC has implicit children prop in older React versions,
// and the function declaration makes the return type clearer.
export function UserAvatar({ name, imageUrl, size = 'md' }: UserAvatarProps) {
  const initials = name
    .split(' ')
    .map(part => part[0])
    .join('')
    .toUpperCase()
    .slice(0, 2);

  const sizeClasses = {
    sm: 'w-8 h-8 text-xs',
    md: 'w-10 h-10 text-sm',
    lg: 'w-14 h-14 text-base',
  };

  if (imageUrl) {
    return (
      <img
        src={imageUrl}
        alt={`${name}'s avatar`}
        className={`rounded-full object-cover ${sizeClasses[size]}`}
      />
    );
  }

  return (
    <div
      className={`rounded-full bg-blue-500 text-white flex items-center justify-center font-medium ${sizeClasses[size]}`}
      aria-label={`${name}'s avatar`}
    >
      {initials}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern B: Container Component with Custom Hook

Use when: the component needs data fetching or complex state logic.

// features/UserProfile/hooks/useUserProfile.ts
// The hook owns the data logic. The component owns the rendering.
// If the API changes, you touch the hook. If the design changes, you touch the component.
// They have separate reasons to change — separate files.

import { useState, useEffect } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
  avatarUrl?: string;
  bio: string;
}

interface UseUserProfileReturn {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  refetch: () => void;
}

export function useUserProfile(userId: string): UseUserProfileReturn {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchUser = async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error('Failed to fetch user');
      const data: User = await response.json();
      setUser(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    fetchUser();
  }, [userId]);

  return { user, isLoading, error, refetch: fetchUser };
}
Enter fullscreen mode Exit fullscreen mode
// features/UserProfile/components/UserProfileCard.tsx
// This component is dumb on purpose. It renders what it receives.
// The complexity lives in the hook — this stays readable.

import { useUserProfile } from '../hooks/useUserProfile';
import { UserAvatar } from '../../../components/UserAvatar';

interface UserProfileCardProps {
  userId: string;
}

export function UserProfileCard({ userId }: UserProfileCardProps) {
  const { user, isLoading, error } = useUserProfile(userId);

  if (isLoading) return <UserProfileCardSkeleton />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;

  return (
    <div className="rounded-lg border border-gray-200 p-6">
      <div className="flex items-center gap-4">
        <UserAvatar name={user.name} imageUrl={user.avatarUrl} size="lg" />
        <div>
          <h2 className="text-lg font-semibold">{user.name}</h2>
          <p className="text-gray-500 text-sm">{user.email}</p>
        </div>
      </div>
      {user.bio && <p className="mt-4 text-gray-700">{user.bio}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern C: Compound Components

Use when: the component has multiple configurable sub-parts with shared state.

// components/Tabs/Tabs.tsx
// The compound pattern gives consumers flexibility without prop explosion.
// Compare:

// ❌ Prop explosion — rigid, hard to extend, impossible to compose
<Tabs
  activeTab={currentTab}
  onTabChange={handleChange}
  tabTitles={['Overview', 'Details', 'Settings']}
  tabContents={[<Overview />, <Details />, <Settings />]}
  tabIcons={[overviewIcon, detailsIcon, settingsIcon]}
  tabDisabled={[false, false, true]}
/>

// ✅ Compound — flexible, composable, clear ownership
<Tabs value={currentTab} onChange={handleChange}>
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="details">Details</Tabs.Trigger>
    <Tabs.Trigger value="settings" disabled>Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview"><Overview /></Tabs.Content>
  <Tabs.Content value="details"><Details /></Tabs.Content>
  <Tabs.Content value="settings"><Settings /></Tabs.Content>
</Tabs>
Enter fullscreen mode Exit fullscreen mode
// The implementation — compound components use Context to share state
// without prop drilling between the sub-components

import { createContext, useContext, useState, ReactNode } from 'react';

interface TabsContextValue {
  activeTab: string;
  onChange: (value: string) => void;
}

const TabsContext = createContext<TabsContextValue | null>(null);

function useTabsContext() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs compound components must be used within <Tabs>');
  }
  return context;
}

interface TabsProps {
  value: string;
  onChange: (value: string) => void;
  children: ReactNode;
}

// The root component — owns the context
export function Tabs({ value, onChange, children }: TabsProps) {
  return (
    <TabsContext.Provider value={{ activeTab: value, onChange }}>
      <div className="tabs-root">{children}</div>
    </TabsContext.Provider>
  );
}

// Sub-components — consume the context
Tabs.List = function TabsList({ children }: { children: ReactNode }) {
  return <div role="tablist" className="flex border-b">{children}</div>;
};

Tabs.Trigger = function TabsTrigger({
  value,
  disabled = false,
  children,
}: {
  value: string;
  disabled?: boolean;
  children: ReactNode;
}) {
  const { activeTab, onChange } = useTabsContext();
  const isActive = activeTab === value;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      disabled={disabled}
      onClick={() => !disabled && onChange(value)}
      className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors
        ${isActive ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500'}
        ${disabled ? 'opacity-40 cursor-not-allowed' : 'hover:text-gray-700'}`}
    >
      {children}
    </button>
  );
};

Tabs.Content = function TabsContent({
  value,
  children,
}: {
  value: string;
  children: ReactNode;
}) {
  const { activeTab } = useTabsContext();
  if (activeTab !== value) return null;
  return <div role="tabpanel" className="py-4">{children}</div>;
};
Enter fullscreen mode Exit fullscreen mode

4. State Management Decision Tree

This is the decision that Copilot gets wrong most often — because the right answer depends on context that Copilot doesn't have.

When you need state, ask in this order:

1. Can this be derived from existing state or props?
   YES → Don't add state. Compute it inline.
   NO  → Continue.

2. Is this state used by only ONE component?
   YES → useState (or useReducer if the logic is complex)
   NO  → Continue.

3. Is this state used by a small, bounded component tree?
   YES → useContext within that feature
   NO  → Continue.

4. Is this state genuinely global (user session, theme, cart)?
   YES → Zustand store
   NO  → Go back to step 2. You probably answered wrong.
Enter fullscreen mode Exit fullscreen mode
// ✅ The decision tree in practice:

// Step 1 — derived state: don't store it
function UserGreeting({ user }: { user: User }) {
  // Don't useState for displayName — it's derived from user.name
  const displayName = user.name.split(' ')[0]; // Compute inline
  return <h1>Hello, {displayName}</h1>;
}

// Step 2 — single component: useState
function SearchInput() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// Step 2 — complex single-component logic: useReducer
type FilterAction =
  | { type: 'SET_CATEGORY'; payload: string }
  | { type: 'SET_PRICE_RANGE'; payload: [number, number] }
  | { type: 'RESET' };

interface FilterState {
  category: string;
  priceRange: [number, number];
}

function filterReducer(state: FilterState, action: FilterAction): FilterState {
  switch (action.type) {
    case 'SET_CATEGORY':
      return { ...state, category: action.payload };
    case 'SET_PRICE_RANGE':
      return { ...state, priceRange: action.payload };
    case 'RESET':
      return { category: '', priceRange: [0, 1000] };
  }
}

// Step 3 — feature-scoped shared state: useContext
// See the compound Tabs example above — TabsContext is step 3.

// Step 4 — global state: Zustand
// (Lives in src/store/ — not shown here to keep focus)
Enter fullscreen mode Exit fullscreen mode

Making Copilot Enforce Your Standard

Here's the part that changes everything: once you have a written standard, Copilot becomes significantly better — because you've given it context.

Method 1: .github/copilot-instructions.md

GitHub Copilot reads this file and uses it to inform suggestions across your repository.

# Copilot Instructions — YourProject React Standard

## Component Structure
- Use function declarations, not React.FC
- Props interface named ComponentNameProps
- Export named, not default
- Each component in its own folder with index.ts re-export

## File Location Rules
- Shared UI with no business logic → src/components/
- Feature-specific components → src/features/FeatureName/components/
- Never create components directly in src/components/ that import from src/features/

## State Management
- Derived state: compute inline, never useState
- Single-component state: useState or useReducer
- Feature-scoped shared state: useContext within the feature
- Global state: Zustand in src/store/

## Naming
- Components: PascalCase noun (UserProfileCard not UserProfileCardComponent)
- Event handlers: handleEventName (handleSubmit not onSubmit or submitForm)
- Custom hooks: useNoun or useNounVerb (useAuth not useFetchAuth)
- Props interfaces: ComponentNameProps (no I prefix)

## TypeScript
- Always type props explicitly — no implicit any
- Prefer interface for props, type for unions and utility types
- Avoid type assertions (as X) unless absolutely necessary
Enter fullscreen mode Exit fullscreen mode

Method 2: Annotated Example Files

Create a _examples/ folder in your repository with heavily commented reference implementations. Copilot learns from nearby files — give it the right nearby files.

// src/_examples/ExampleFeatureComponent.tsx
// THIS IS A REFERENCE EXAMPLE — not production code.
// Copilot uses this as a pattern reference for new components.

// STANDARD: function declaration, not React.FC
// STANDARD: props interface named ComponentNameProps
// STANDARD: named export, not default

interface ExampleFeatureComponentProps {
  // STANDARD: required props first, optional props last
  userId: string;
  // STANDARD: optional props have explicit defaults in the signature
  variant?: 'compact' | 'full';
}

// STANDARD: component name matches filename
export function ExampleFeatureComponent({
  userId,
  variant = 'full', // STANDARD: default here, not in the interface
}: ExampleFeatureComponentProps) {
  // STANDARD: hook for data fetching — never fetch in the component body
  const { user, isLoading, error } = useUserProfile(userId);

  // STANDARD: early returns for loading and error states
  // before the main render — keeps the happy path clean
  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;

  return (
    <div>
      {/* STANDARD: conditional rendering with ternary for two states,
          short-circuit && for one state */}
      {variant === 'full' ? (
        <FullUserCard user={user} />
      ) : (
        <CompactUserCard user={user} />
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Method 3: ESLint Rules That Enforce the Standard

The standard isn't just a document — it's code. Anything you can automate, automate.

// .eslintrc.js
module.exports = {
  rules: {
    // Enforce named exports — no default exports from component files
    'import/no-default-export': 'error',

    // Enforce consistent function component style
    'react/function-component-definition': [
      'error',
      { namedComponents: 'function-declaration' }
    ],

    // Prevent prop-types (we use TypeScript)
    'react/prop-types': 'off',

    // Enforce hook naming convention
    'react-hooks/rules-of-hooks': 'error',

    // Custom rule: warn when useState is used for values derivable from props
    // (This requires a custom ESLint plugin — the team built one)
    'custom/no-derived-state': 'warn',
  }
};
Enter fullscreen mode Exit fullscreen mode

The Uncomfortable Truth About Standards

Here's what I want to say that most "coding standards" articles avoid:

A standards document that lives in Notion is worth less than no standard.

I've watched teams spend two days writing a beautiful standards document, put it in Notion, link it in the README, and then watch it be completely ignored within three weeks. Not from malice — from friction. Opening Notion is friction. Remembering the document exists is friction. The blank file with the Copilot suggestion is zero friction.

Your standard must live where developers work. That means:

  • In the repository — a Markdown file that's versioned alongside the code
  • In the linter — rules that enforce it automatically
  • In example files — reference implementations that Copilot reads
  • In code review — a checklist that reviewers actively use

The standard that requires no effort to follow is the one that survives.

And the other uncomfortable truth: your standard will be wrong in some places. Accept this upfront. The goal isn't a perfect standard — it's a shared standard. A team that consistently does the same slightly-suboptimal thing is more productive than a team that inconsistently does the theoretically-optimal thing five different ways.

Document the decisions. Include the reasoning. Make it easy to update. And revisit it every quarter — not to rewrite it, but to capture the decisions the team has been making implicitly, and make them explicit.


But

** "This is too much overhead for a small team. We don't need a standards document."**

A standards document for a three-person team takes half a day to write. The inconsistency that builds without one takes weeks to untangle six months later — and those weeks come at the worst possible time, when the team is scaling or a critical deadline is approaching. The overhead is front-loaded. The cost of not doing it is back-loaded and much higher.

"Copilot will just get better. Won't this be solved by the next model?"

Better models will give better suggestions — but "better" still means "better at the average." The average of all React codebases on the internet is not your codebase. No model, however good, knows the specific tradeoffs your team made in sprint three. The need for explicit team standards is not a temporary gap that AI will close. It's the permanent requirement of working with other humans.

"Standards slow down development. We move fast and break things."

Inconsistency slows down development. Standards speed it up. The developer who doesn't have to decide "should I use interface or type here?" every single time they open a new file makes that file faster. The developer who knows exactly where to put a new hook doesn't spend ten minutes deciding on folder structure. The code reviewer who isn't arguing about naming conventions is reviewing logic. Standards are a speed investment, not a speed cost.


📚 my notes

Copilot gives you answers. Your team has to ask the right questions first. The standard document is the list of questions your team has already answered — so Copilot can answer the new ones correctly.


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai

📘 Curious about AI?: You can also check out my book: Surrounded by AI

Top comments (0)