Skip to main content

Command Palette

Search for a command to run...

From Prompt Chaos to Production Code: Spec-Driven React Development

A companion guide from the React Pune Meetup | April 11, 2026 | Intellias India, Viman Nagar, Pune

Published
21 min read
From Prompt Chaos to Production Code: Spec-Driven React Development

Thank you for attending the React Pune meetup! This blog post is a detailed companion to the session — expanding on everything covered in the slides and live demo. Whether you caught every detail or want to revisit the concepts, this post has you covered with code samples, architecture decisions, and step-by-step implementation guides.

The Problem: Prompt Chaos in React Development

If you've used AI coding assistants for React development, you've likely experienced what I call "Prompt Chaos" — the frustrating cycle of inconsistent outputs, quality roulette, and endless iteration.

Here's what it typically looks like:

// Attempt 1: "Create a login component with email and OAuth"
// Result: A basic form with no validation, no error states, no loading states

// Attempt 2: "Add validation and error handling"  
// Result: Overwrites previous OAuth logic, introduces new patterns

// Attempt 3: "Fix the OAuth flow and keep the validation"
// Result: Different state management approach than Attempt 1

// Attempt 47: Still iterating...

The core issues:

  • Inconsistent Outputs — Same prompt, different component structures every time. No reproducibility across team members.
  • Quality Roulette — Components work in Storybook but break in production. Missing edge cases, accessibility, error boundaries.
  • Prompt Engineering Tax — Hours spent crafting the "perfect prompt" instead of building features.
  • Endless Iteration — Back-and-forth with the AI, losing context each cycle, regressing on previously working code.

The fundamental problem? We're treating AI like a magic box instead of an engineering tool. We wouldn't ship code without specs and tests — so why do we ship AI-generated code without them?

The Solution: Spec-Driven Development

Spec-driven development replaces ad-hoc prompting with a structured, iterative workflow where the AI is guided by specifications — markdown documents that capture requirements, design, and implementation tasks.

The core loop is:

Requirements → Design → Tasks → Implementation

With human review gates between each phase.

The Four-Stage Workflow

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  1. REQUIREMENTS│────▶│   2. DESIGN     │────▶│  3. TASK LIST   │────▶│ 4. PRODUCTION   │
│     SPEC        │     │    DOCUMENT     │     │   BREAKDOWN     │     │     CODE        │
│                 │     │                 │     │                 │     │                 │
│ • User stories  │     │ • Architecture  │     │ • Atomic tasks  │     │ • Implementation│
│ • Acceptance    │     │ • Components    │     │ • Dependencies  │     │ • Tests         │
│   criteria      │     │ • Data models   │     │ • Traceability  │     │ • Edge cases    │
│ • EARS format   │     │ • API contracts │     │ • Sizing        │     │ • Documentation │
└─────────────────┘     └─────────────────┘     └─────────────────┘     └─────────────────┘
       ▲                        ▲                        ▲                        ▲
       │                        │                        │                        │
       └──── Human Review ──────┴──── Human Review ──────┴──── Human Review ──────┘

Why This Works for React

React's component-based architecture maps perfectly to spec-driven development:

  • Components = Self-contained units with clear interfaces (props/state)
  • Hooks = Reusable logic with defined inputs/outputs
  • Context = Shared state with explicit boundaries
  • Each spec = One feature, one component tree, one concern

Project Structure: The .kiro Directory

Spec-driven development uses a .kiro directory in your project root to organize all specifications:

my-react-app/
├── .kiro/
│   ├── steering/
│   │   ├── product.md          # What this app does
│   │   ├── structure.md        # Project structure & conventions
│   │   └── tech.md             # Tech stack & patterns
│   ├── specs/
│   │   └── user-authentication/
│   │       ├── requirements.md  # What to build
│   │       ├── design.md        # How to build it
│   │       └── tasks.md         # Step-by-step plan
│   └── hooks/
│       └── test-on-save.hook    # Event-driven automation
├── src/
│   ├── components/
│   ├── hooks/
│   ├── context/
│   └── ...
└── package.json

Deep Dive: Building User Authentication with Specs

Let's walk through the exact example from the live demo — building a complete User Authentication feature for a React application using spec-driven development.

Phase 1: Requirements Specification (requirements.md)

Requirements are written using EARS (Easy Approach to Requirements Syntax) — a structured natural language format that reduces ambiguity and gives the AI clear, testable criteria.

# Feature: User Authentication

## User Stories

### US-1: Email/Password Login
**As a** registered user  
**I want to** log in with my email and password  
**So that** I can access my personalized dashboard

#### Acceptance Criteria (EARS Format):
1. **When** the user submits valid credentials, **the system shall** 
   authenticate against the backend API and store the session token.
2. **When** the user submits invalid credentials, **the system shall** 
   display a specific error message without revealing which field is incorrect.
3. **While** authentication is in progress, **the system shall** 
   display a loading indicator and disable the submit button.
4. **Where** the session token exists in storage, **the system shall** 
   redirect authenticated users away from the login page.
5. **If** the API is unreachable, **then the system shall** 
   display a network error message with a retry option.

### US-2: OAuth Social Login
**As a** new or returning user  
**I want to** sign in with Google or GitHub  
**So that** I can access the app without creating a new password

#### Acceptance Criteria:
1. **When** the user clicks an OAuth provider button, **the system shall** 
   initiate the OAuth flow in a popup window.
2. **When** the OAuth provider returns a success callback, **the system shall** 
   exchange the auth code for a session token.
3. **If** the OAuth popup is closed without completing auth, 
   **then the system shall** return to the login form without error.
4. **If** the OAuth provider returns an error, 
   **then the system shall** display the provider-specific error message.

### US-3: Session Management  
**As an** authenticated user  
**I want** my session to persist across page refreshes  
**So that** I don't have to log in repeatedly

#### Acceptance Criteria:
1. **When** the application loads, **the system shall** check for an 
   existing valid token and restore the session.
2. **When** the token expires, **the system shall** attempt a silent 
   refresh before prompting re-authentication.
3. **When** the user clicks logout, **the system shall** clear all 
   stored tokens and redirect to the login page.

### US-4: Protected Routes
**As a** product owner  
**I want** certain routes to require authentication  
**So that** unauthorized users cannot access sensitive pages

#### Acceptance Criteria:
1. **When** an unauthenticated user navigates to a protected route, 
   **the system shall** redirect to login and preserve the intended destination.
2. **After** successful authentication, **the system shall** redirect 
   the user to their originally intended destination.

Phase 2: Design Document (design.md)

The design document outlines how the feature will be built — architecture, component hierarchy, data models, and technical decisions.

# Design: User Authentication

## Architecture Overview

### Component Hierarchy

(Context Provider - top level) ├── (Route: /login) │ ├── (Email/password form) │ │ ├── (Validated email field) │ │ ├── (Validated password field) │ │ └── (Loading-aware submit) │ └── (Social login providers) │ ├── │ └── ├── (Route guard HOC) └── (Shows current auth state)


### State Management: React Context + useReducer

**Decision**: Use React Context with `useReducer` instead of external 
state libraries. Rationale:
- Auth state is naturally tree-scoped
- Reducer pattern gives predictable state transitions
- No additional dependencies
- Easy to test with custom render wrappers

### Auth State Shape

```typescript
interface AuthState {
  user: User | null;
  token: string | null;
  refreshToken: string | null;
  status: 'idle' | 'loading' | 'authenticated' | 'unauthenticated' | 'error';
  error: AuthError | null;
}

interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  provider: 'email' | 'google' | 'github';
}

type AuthAction =
  | { type: 'AUTH_START' }
  | { type: 'AUTH_SUCCESS'; payload: { user: User; token: string; refreshToken: string } }
  | { type: 'AUTH_FAILURE'; payload: AuthError }
  | { type: 'LOGOUT' }
  | { type: 'TOKEN_REFRESH'; payload: { token: string } }
  | { type: 'SESSION_RESTORE'; payload: { user: User; token: string; refreshToken: string } };

Custom Hook API

interface UseAuth {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: AuthError | null;
  login: (email: string, password: string) => Promise<void>;
  loginWithOAuth: (provider: 'google' | 'github') => Promise<void>;
  logout: () => void;
  refreshSession: () => Promise<void>;
}

Token Storage Strategy

  • Access Token: In-memory (Context state) — never in localStorage
  • Refresh Token: httpOnly cookie (set by backend) OR secure localStorage with encryption as fallback
  • Session Persistence: Check refresh token validity on app mount

API Contract

// POST /api/auth/login
interface LoginRequest { email: string; password: string; }
interface LoginResponse { user: User; token: string; refreshToken: string; expiresIn: number; }

// POST /api/auth/oauth
interface OAuthRequest { provider: string; code: string; }
interface OAuthResponse { user: User; token: string; refreshToken: string; expiresIn: number; }

// POST /api/auth/refresh
interface RefreshRequest { refreshToken: string; }
interface RefreshResponse { token: string; expiresIn: number; }

// POST /api/auth/logout
interface LogoutRequest { refreshToken: string; }

Error Handling Strategy

interface AuthError {
  code: 'INVALID_CREDENTIALS' | 'NETWORK_ERROR' | 'OAUTH_CANCELLED' | 
        'OAUTH_FAILED' | 'TOKEN_EXPIRED' | 'SESSION_INVALID';
  message: string;
  retryable: boolean;
}

Testing Strategy

  • Unit tests for authReducer (all state transitions)
  • Unit tests for useAuth hook (using renderHook)
  • Integration tests for LoginForm (user interactions)
  • Mock Service Worker (MSW) for API mocking
  • E2E test for full OAuth flow with Playwright

### Phase 3: Task Breakdown (`tasks.md`)

Tasks are decomposed into atomic, ordered implementation steps with dependency tracking and requirement traceability.

```markdown
# Implementation Plan

## Task 1: Create Auth Types and Constants
- [ ] Define TypeScript interfaces for AuthState, User, AuthError, AuthAction
- [ ] Define API request/response types
- [ ] Create auth-related constants (token keys, API endpoints, error messages)
- **Requirements**: US-1, US-2, US-3
- **Files**: `src/types/auth.ts`, `src/constants/auth.ts`

## Task 2: Implement Auth Reducer
- [ ] Create `authReducer` function handling all AuthAction types
- [ ] Implement state transitions with proper TypeScript narrowing
- [ ] Write unit tests covering all action types and edge cases
- **Requirements**: US-1, US-2, US-3
- **Files**: `src/context/authReducer.ts`, `src/context/authReducer.test.ts`

## Task 3: Build Auth Context and Provider
- [ ] Create AuthContext with proper default value
- [ ] Implement AuthProvider with useReducer
- [ ] Add token persistence logic (save/restore from storage)
- [ ] Implement auto-refresh timer for token expiration
- [ ] Write integration tests for Provider
- **Requirements**: US-3, US-4
- **Files**: `src/context/AuthContext.tsx`, `src/context/AuthProvider.tsx`

## Task 4: Create useAuth Custom Hook
- [ ] Implement useAuth hook consuming AuthContext
- [ ] Add login method with API call and error handling
- [ ] Add loginWithOAuth method with popup management
- [ ] Add logout method with token cleanup
- [ ] Add refreshSession method
- [ ] Write hook tests using renderHook
- **Requirements**: US-1, US-2, US-3
- **Files**: `src/hooks/useAuth.ts`, `src/hooks/useAuth.test.ts`

## Task 5: Build LoginForm Component
- [ ] Create form with email/password fields
- [ ] Implement form validation (email format, password length)
- [ ] Add loading state and disabled submit
- [ ] Add error message display
- [ ] Write component tests with React Testing Library
- **Requirements**: US-1
- **Files**: `src/components/auth/LoginForm.tsx`, `src/components/auth/LoginForm.test.tsx`

## Task 6: Build OAuth Buttons Component
- [ ] Create OAuthButtons component with Google and GitHub
- [ ] Implement OAuth popup window management
- [ ] Handle popup close/cancel gracefully  
- [ ] Add loading indicators per provider
- [ ] Write component tests
- **Requirements**: US-2
- **Files**: `src/components/auth/OAuthButtons.tsx`, `src/components/auth/OAuthButtons.test.tsx`

## Task 7: Implement ProtectedRoute Component
- [ ] Create ProtectedRoute wrapper component
- [ ] Implement redirect to login for unauthenticated users
- [ ] Preserve intended destination in redirect state
- [ ] Redirect back after successful auth
- [ ] Write route guard tests
- **Requirements**: US-4
- **Files**: `src/components/auth/ProtectedRoute.tsx`, `src/components/auth/ProtectedRoute.test.tsx`

## Task 8: Integration and E2E Testing [OPTIONAL]
- [ ] Set up MSW handlers for auth endpoints
- [ ] Write integration tests for full login flow
- [ ] Write E2E tests with Playwright for OAuth
- **Requirements**: US-1, US-2, US-3, US-4

Phase 4: Production Code — Implementation

With the spec complete, here's what the AI generates — production-ready code that follows the design document exactly.

The Auth Reducer

// src/context/authReducer.ts
import { AuthState, AuthAction } from '../types/auth';

export const initialAuthState: AuthState = {
  user: null,
  token: null,
  refreshToken: null,
  status: 'idle',
  error: null,
};

export function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'AUTH_START':
      return {
        ...state,
        status: 'loading',
        error: null,
      };

    case 'AUTH_SUCCESS':
      return {
        ...state,
        user: action.payload.user,
        token: action.payload.token,
        refreshToken: action.payload.refreshToken,
        status: 'authenticated',
        error: null,
      };

    case 'AUTH_FAILURE':
      return {
        ...state,
        user: null,
        token: null,
        refreshToken: null,
        status: 'error',
        error: action.payload,
      };

    case 'LOGOUT':
      return {
        ...initialAuthState,
        status: 'unauthenticated',
      };

    case 'TOKEN_REFRESH':
      return {
        ...state,
        token: action.payload.token,
      };

    case 'SESSION_RESTORE':
      return {
        ...state,
        user: action.payload.user,
        token: action.payload.token,
        refreshToken: action.payload.refreshToken,
        status: 'authenticated',
      };

    default:
      return state;
  }
}

The useAuth Custom Hook

// src/hooks/useAuth.ts
import { useContext, useCallback, useRef, useEffect } from 'react';
import { AuthContext } from '../context/AuthContext';
import { authApi } from '../api/auth';
import { AuthError, UseAuth } from '../types/auth';
import { TOKEN_STORAGE_KEY, REFRESH_INTERVAL_MS } from '../constants/auth';

export function useAuth(): UseAuth {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }

  const { state, dispatch } = context;
  const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);

  // Auto-refresh token before expiry
  useEffect(() => {
    if (state.status === 'authenticated' && state.token) {
      refreshTimerRef.current = setInterval(() => {
        refreshSession();
      }, REFRESH_INTERVAL_MS);
    }

    return () => {
      if (refreshTimerRef.current) {
        clearInterval(refreshTimerRef.current);
      }
    };
  }, [state.status]);

  const login = useCallback(async (email: string, password: string) => {
    dispatch({ type: 'AUTH_START' });
    
    try {
      const response = await authApi.login({ email, password });
      dispatch({
        type: 'AUTH_SUCCESS',
        payload: {
          user: response.user,
          token: response.token,
          refreshToken: response.refreshToken,
        },
      });
      
      // Persist refresh token
      localStorage.setItem(TOKEN_STORAGE_KEY, response.refreshToken);
    } catch (error) {
      const authError: AuthError = {
        code: isNetworkError(error) ? 'NETWORK_ERROR' : 'INVALID_CREDENTIALS',
        message: getErrorMessage(error),
        retryable: isNetworkError(error),
      };
      dispatch({ type: 'AUTH_FAILURE', payload: authError });
    }
  }, [dispatch]);

  const loginWithOAuth = useCallback(async (provider: 'google' | 'github') => {
    dispatch({ type: 'AUTH_START' });

    try {
      const code = await openOAuthPopup(provider);
      
      if (!code) {
        // User closed popup without completing auth
        dispatch({ type: 'AUTH_FAILURE', payload: {
          code: 'OAUTH_CANCELLED',
          message: 'Authentication was cancelled',
          retryable: true,
        }});
        return;
      }

      const response = await authApi.oauth({ provider, code });
      dispatch({
        type: 'AUTH_SUCCESS',
        payload: {
          user: response.user,
          token: response.token,
          refreshToken: response.refreshToken,
        },
      });
      
      localStorage.setItem(TOKEN_STORAGE_KEY, response.refreshToken);
    } catch (error) {
      dispatch({ type: 'AUTH_FAILURE', payload: {
        code: 'OAUTH_FAILED',
        message: `${provider} authentication failed. Please try again.`,
        retryable: true,
      }});
    }
  }, [dispatch]);

  const logout = useCallback(() => {
    // Clear refresh timer
    if (refreshTimerRef.current) {
      clearInterval(refreshTimerRef.current);
    }
    
    // Notify backend
    if (state.refreshToken) {
      authApi.logout({ refreshToken: state.refreshToken }).catch(() => {
        // Silent failure — we're logging out regardless
      });
    }

    // Clear local storage
    localStorage.removeItem(TOKEN_STORAGE_KEY);
    
    dispatch({ type: 'LOGOUT' });
  }, [dispatch, state.refreshToken]);

  const refreshSession = useCallback(async () => {
    const storedRefreshToken = localStorage.getItem(TOKEN_STORAGE_KEY);
    
    if (!storedRefreshToken) {
      dispatch({ type: 'LOGOUT' });
      return;
    }

    try {
      const response = await authApi.refresh({ refreshToken: storedRefreshToken });
      dispatch({ type: 'TOKEN_REFRESH', payload: { token: response.token } });
    } catch {
      // Refresh failed — session is invalid
      localStorage.removeItem(TOKEN_STORAGE_KEY);
      dispatch({ type: 'LOGOUT' });
    }
  }, [dispatch]);

  return {
    user: state.user,
    isAuthenticated: state.status === 'authenticated',
    isLoading: state.status === 'loading',
    error: state.error,
    login,
    loginWithOAuth,
    logout,
    refreshSession,
  };
}

// Helper: Open OAuth popup and wait for callback
function openOAuthPopup(provider: string): Promise<string | null> {
  return new Promise((resolve) => {
    const width = 500;
    const height = 600;
    const left = window.screenX + (window.outerWidth - width) / 2;
    const top = window.screenY + (window.outerHeight - height) / 2;

    const popup = window.open(
      `/api/auth/oauth/${provider}/start`,
      `${provider}-oauth`,
      `width=\({width},height=\){height},left=\({left},top=\){top}`
    );

    // Listen for message from popup
    const handleMessage = (event: MessageEvent) => {
      if (event.origin !== window.location.origin) return;
      if (event.data?.type === 'oauth-callback') {
        window.removeEventListener('message', handleMessage);
        resolve(event.data.code);
      }
    };

    window.addEventListener('message', handleMessage);

    // Check if popup was closed without completing
    const checkClosed = setInterval(() => {
      if (popup?.closed) {
        clearInterval(checkClosed);
        window.removeEventListener('message', handleMessage);
        resolve(null);
      }
    }, 500);
  });
}

function isNetworkError(error: unknown): boolean {
  return error instanceof TypeError && error.message === 'Failed to fetch';
}

function getErrorMessage(error: unknown): string {
  if (error instanceof Response) {
    return 'Invalid email or password. Please try again.';
  }
  if (isNetworkError(error)) {
    return 'Unable to connect. Please check your network and try again.';
  }
  return 'An unexpected error occurred. Please try again.';
}

The LoginForm Component

// src/components/auth/LoginForm.tsx
import { useState, FormEvent } from 'react';
import { useAuth } from '../../hooks/useAuth';

interface FormErrors {
  email?: string;
  password?: string;
}

export function LoginForm() {
  const { login, isLoading, error } = useAuth();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [formErrors, setFormErrors] = useState<FormErrors>({});

  function validate(): boolean {
    const errors: FormErrors = {};
    
    if (!email) {
      errors.email = 'Email is required';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      errors.email = 'Please enter a valid email address';
    }

    if (!password) {
      errors.password = 'Password is required';
    } else if (password.length < 8) {
      errors.password = 'Password must be at least 8 characters';
    }

    setFormErrors(errors);
    return Object.keys(errors).length === 0;
  }

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    
    if (!validate()) return;
    
    await login(email, password);
  }

  return (
    <form onSubmit={handleSubmit} noValidate aria-label="Login form">
      {error && (
        <div role="alert" className="auth-error">
          <p>{error.message}</p>
          {error.retryable && (
            <button type="submit" className="retry-btn">
              Try Again
            </button>
          )}
        </div>
      )}

      <div className="form-field">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          aria-invalid={!!formErrors.email}
          aria-describedby={formErrors.email ? 'email-error' : undefined}
          disabled={isLoading}
          autoComplete="email"
          required
        />
        {formErrors.email && (
          <span id="email-error" className="field-error" role="alert">
            {formErrors.email}
          </span>
        )}
      </div>

      <div className="form-field">
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          aria-invalid={!!formErrors.password}
          aria-describedby={formErrors.password ? 'password-error' : undefined}
          disabled={isLoading}
          autoComplete="current-password"
          required
        />
        {formErrors.password && (
          <span id="password-error" className="field-error" role="alert">
            {formErrors.password}
          </span>
        )}
      </div>

      <button 
        type="submit" 
        disabled={isLoading}
        aria-busy={isLoading}
      >
        {isLoading ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  );
}

The ProtectedRoute Component

// src/components/auth/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';

interface ProtectedRouteProps {
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export function ProtectedRoute({ children, fallback }: ProtectedRouteProps) {
  const { isAuthenticated, isLoading } = useAuth();
  const location = useLocation();

  if (isLoading) {
    return fallback ? <>{fallback}</> : <LoadingSkeleton />;
  }

  if (!isAuthenticated) {
    // Preserve the intended destination
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return <>{children}</>;
}

function LoadingSkeleton() {
  return (
    <div className="auth-loading" aria-label="Verifying authentication...">
      <div className="skeleton-pulse" />
    </div>
  );
}

Tests — The Missing Piece from Ad-Hoc Prompting

One of the biggest advantages of spec-driven development: tests are generated automatically because they're part of the task spec.

// src/context/authReducer.test.ts
import { authReducer, initialAuthState } from './authReducer';

describe('authReducer', () => {
  it('should return initial state', () => {
    expect(authReducer(initialAuthState, { type: 'AUTH_START' })).toEqual({
      ...initialAuthState,
      status: 'loading',
      error: null,
    });
  });

  it('should handle AUTH_SUCCESS', () => {
    const payload = {
      user: { id: '1', email: 'test@example.com', name: 'Test', provider: 'email' as const },
      token: 'abc123',
      refreshToken: 'refresh123',
    };

    const result = authReducer(
      { ...initialAuthState, status: 'loading' },
      { type: 'AUTH_SUCCESS', payload }
    );

    expect(result.status).toBe('authenticated');
    expect(result.user).toEqual(payload.user);
    expect(result.token).toBe('abc123');
    expect(result.error).toBeNull();
  });

  it('should handle AUTH_FAILURE', () => {
    const error = { code: 'INVALID_CREDENTIALS' as const, message: 'Invalid', retryable: false };
    
    const result = authReducer(
      { ...initialAuthState, status: 'loading' },
      { type: 'AUTH_FAILURE', payload: error }
    );

    expect(result.status).toBe('error');
    expect(result.error).toEqual(error);
    expect(result.user).toBeNull();
    expect(result.token).toBeNull();
  });

  it('should handle LOGOUT and clear all state', () => {
    const authenticatedState = {
      user: { id: '1', email: 'a@b.com', name: 'A', provider: 'email' as const },
      token: 'token',
      refreshToken: 'refresh',
      status: 'authenticated' as const,
      error: null,
    };

    const result = authReducer(authenticatedState, { type: 'LOGOUT' });

    expect(result.user).toBeNull();
    expect(result.token).toBeNull();
    expect(result.refreshToken).toBeNull();
    expect(result.status).toBe('unauthenticated');
  });

  it('should handle TOKEN_REFRESH without changing user', () => {
    const authenticatedState = {
      user: { id: '1', email: 'a@b.com', name: 'A', provider: 'email' as const },
      token: 'old-token',
      refreshToken: 'refresh',
      status: 'authenticated' as const,
      error: null,
    };

    const result = authReducer(
      authenticatedState,
      { type: 'TOKEN_REFRESH', payload: { token: 'new-token' } }
    );

    expect(result.token).toBe('new-token');
    expect(result.user).toEqual(authenticatedState.user);
  });
});
// src/components/auth/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { AuthProvider } from '../../context/AuthProvider';

const renderLoginForm = () => {
  return render(
    <AuthProvider>
      <LoginForm />
    </AuthProvider>
  );
};

describe('LoginForm', () => {
  it('shows validation errors for empty submission', async () => {
    renderLoginForm();
    const user = userEvent.setup();
    
    await user.click(screen.getByRole('button', { name: /sign in/i }));
    
    expect(screen.getByText('Email is required')).toBeInTheDocument();
    expect(screen.getByText('Password is required')).toBeInTheDocument();
  });

  it('shows email format error', async () => {
    renderLoginForm();
    const user = userEvent.setup();
    
    await user.type(screen.getByLabelText(/email/i), 'notanemail');
    await user.type(screen.getByLabelText(/password/i), '[PASSWORD]');
    await user.click(screen.getByRole('button', { name: /sign in/i }));
    
    expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
  });

  it('disables submit button while loading', async () => {
    renderLoginForm();
    const user = userEvent.setup();
    
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), '[PASSWORD]');
    await user.click(screen.getByRole('button', { name: /sign in/i }));
    
    await waitFor(() => {
      expect(screen.getByRole('button')).toBeDisabled();
      expect(screen.getByText('Signing in...')).toBeInTheDocument();
    });
  });

  it('has proper accessibility attributes', () => {
    renderLoginForm();
    
    expect(screen.getByRole('form')).toHaveAccessibleName('Login form');
    expect(screen.getByLabelText(/email/i)).toHaveAttribute('autocomplete', 'email');
    expect(screen.getByLabelText(/password/i)).toHaveAttribute('autocomplete', 'current-password');
  });
});

The Tooling: Kiro IDE

All of this was demonstrated using Kiro — a free agentic IDE from AWS built on VS Code. Here's how it enables spec-driven development:

Key Features for React Developers

Feature What It Does
Spec Generation Converts your natural language intent into structured requirements, design docs, and task lists
Agentic Steering Uses your specs as guardrails — the AI follows the design document, not its own patterns
React-Optimized Patterns Recognizes React conventions — hooks rules, component patterns, Context API usage
Agent Hooks Event-driven automation — run tests on save, update docs on commit, lint on change
Vibe Mode For quick explorations — chat first, then build (like other AI coding tools)
Spec Mode For production features — plan first, then build (the spec-driven approach)

Agent Hooks for React

Agent hooks are event-driven automations that run in your IDE. Example for React development:

# .kiro/hooks/test-on-save.hook
title: "Run Component Tests on Save"
description: "Automatically runs related test files when a component is saved"
event: "File Saved"
file_paths:
  - "src/components/**/*.tsx"
  - "src/hooks/**/*.ts"
instructions: |
  A React component or hook file has been saved. Find and run the 
  corresponding test file (same name with .test.tsx/.test.ts extension).
  If tests fail, analyze the failure and suggest a fix.

Steering Documents for Team Conventions

# .kiro/steering/tech.md

## React Conventions
- Use functional components exclusively
- Prefer custom hooks for reusable logic
- Use React Context for shared state (not Redux for this project)
- All components must have proper TypeScript interfaces for props
- Use React Testing Library for component tests
- Follow the container/presentational pattern for complex components

## Code Style
- Use named exports (not default exports)
- Colocate tests with source files (ComponentName.test.tsx)
- Use absolute imports from `src/`
- Error boundaries around all route-level components

Key Takeaways

1. Specs Produce Better AI Output Than Ad-Hoc Prompts

A single prompt like "create a login form" gives you a basic form. A spec gives you:

  • Proper validation with accessible error messages
  • Loading states and disabled interactions
  • OAuth integration with popup management
  • Token management with auto-refresh
  • Protected routes with redirect preservation
  • Comprehensive tests covering edge cases

2. Same Specs = Consistent, Reproducible Results

Your team member in a different timezone runs the same spec → gets the same architecture, same patterns, same quality. The spec is version-controlled and reviewable.

3. Generated Code is Production-Ready from Day One

Because the spec includes error handling, accessibility, testing strategy, and edge cases as requirements, the generated code handles them from the start — not as afterthoughts.

4. Start Small, Scale Up

You don't need to spec-drive your entire app overnight:

  • Start with one complex feature (auth, forms, data fetching)
  • See the quality difference
  • Gradually apply to more features
  • Share specs across your team

Repository & Resources

🔗 Demo Repository: https://github.com/vishalcloud/task-management-app

The repository includes:

  • Complete .kiro/ directory with all spec files
  • Full implementation code from the demo
  • Test suite with >90% coverage
  • Steering documents for team conventions
  • Example hooks for automation

🔗 Get Started with Kiro: kiro.dev (Free, built on VS Code)

🔗 EARS Notation Guide: Wikipedia — Easy Approach to Requirements Syntax

🔗 Slides: Attached to this post

What's Next?

If you're interested in applying spec-driven development to your React projects, here's a suggested progression:

  1. Week 1: Install Kiro, try spec mode on a single component
  2. Week 2: Write your first steering documents for your project
  3. Week 3: Spec a medium-complexity feature (form, data table, auth)
  4. Week 4: Set up hooks for automated testing and docs

Have questions? Reach out on Linkedin, Twitter or drop a comment below. See you at the next community meetup!