Development Tips & Best Practices

Development Tips & Best Practices

Code principles, architecture patterns, state management strategies, and productivity tips for building scalable web applications.


Table of Contents


The 3 Laws of Readable Code

1. Code Should Explain Itself

The reader should understand the code without needing comments.

Do Don't
Clear, descriptive variable names Single-letter variables (except loops)
Functions that do one thing Functions with side effects
Logic flows predictably Hidden behavior
Explicit over clever Tricks that confuse future you
// Bad
const d = users.filter(u => u.a && !u.d).map(u => u.n);

// Good
const activeUserNames = users
  .filter(user => user.isActive && !user.isDeleted)
  .map(user => user.name);

If someone has to ask "what does this do?", it's not readable.


2. Code Should Be Easy to Change

Readable code is modifiable without causing chain-reaction bugs.

Principle Description
Low Coupling Components depend on as little as possible
High Cohesion Functions/classes handle one responsibility
No Magic Numbers Use named constants
DRY Don't Repeat Yourself
Good Structure Organized folders and files
// Bad - magic numbers, tight coupling
if (items.length > 50) {
  paginate(items, 10);
}

// Good - named constants, configurable
const MAX_ITEMS_BEFORE_PAGINATION = 50;
const ITEMS_PER_PAGE = 10;

if (items.length > MAX_ITEMS_BEFORE_PAGINATION) {
  paginate(items, ITEMS_PER_PAGE);
}

If it's easy to break the code when editing it, it's not readable.


3. Reduce Cognitive Load

Code should minimize how much the brain must keep in memory to understand it.

Do Don't
Keep functions short (< 20 lines ideal) 100+ line functions
Keep conditionals shallow Deep nesting (> 3 levels)
Break into meaningful chunks Monolithic blocks
Consistent formatting Mixed styles
Linear flow Jumping all over the file
// Bad - deep nesting, hard to follow
function processOrder(order) {
  if (order) {
    if (order.items) {
      if (order.items.length > 0) {
        if (order.customer) {
          if (order.customer.isActive) {
            // finally do something
          }
        }
      }
    }
  }
}

// Good - early returns, flat structure
function processOrder(order) {
  if (!order?.items?.length) return;
  if (!order.customer?.isActive) return;

  // do something
}

Readable code lets the reader understand it in one mental pass.


Architecture Patterns

Data Flow

The Golden Rule:

Component → Hook → Utils (if needed) → Service → API
API → Service → Utils (if needed) → Hook → Component re-renders

Layer Responsibilities

Layer Location Responsibility Example
Component modules/ UI rendering, user interaction DashboardModule.tsx
Hook hooks/ State management, side effects useDashboardData.ts
Utils utils/ Pure logic, no API calls formatDate.ts
Service services/ Business logic with API calls dashboard.service.ts
API api/ HTTP client, request/response dashboard.api.ts

Frontend Folder Structure

frontend/
├── api/                    # HTTP client layer
│   ├── client.ts           # Base fetch/axios wrapper
│   ├── config.ts           # API base URL, endpoints
│   └── dashboard.api.ts    # Domain-specific API calls
│
├── services/               # Business logic layer
│   └── dashboard/
│       └── dashboard.service.ts
│
├── hooks/                  # State management layer
│   └── useDashboardData.ts
│
├── utils/                  # Pure utility functions
│   └── formatters.ts       # No side effects, no API calls
│
├── modules/                # Feature modules
│   └── DashboardModule/
│       ├── DashboardModule.tsx    # Smart parent component
│       ├── DashboardHeader.tsx    # Dumb child
│       ├── DashboardStats.tsx     # Dumb child
│       └── DashboardChart.tsx     # Dumb child
│
├── components/             # Shared reusable UI components
│   ├── ui/                 # Atoms: Button, Modal, Input, etc.
│   ├── layout/             # Container, Header, Sidebar
│   ├── forms/              # FormField, Checkbox, Select
│   └── data-display/       # Table, Grid, EmptyState
│
├── contexts/               # React Context providers
│   └── AppSettingsContext.tsx
│
├── store/                  # Zustand stores
│   └── useUIStore.ts
│
├── pages/                  # Next.js pages (routing)
│   ├── index.tsx
│   └── dashboard.tsx
│
├── styles/                 # Global styles and themes
│   └── globals.css
│
└── public/                 # Static assets
    └── assets/

Component Design — Smart vs Dumb

The Pattern

Type Also Called What It Does What It Doesn't Do
Smart (Container) Parent, Controller Calls hooks, manages state, handles logic, passes data down Render complex UI directly
Dumb (Presentational) Child, Pure Receives props, renders UI, emits events via callbacks Fetch data, manage state, call APIs
// Smart parent — owns the state and logic
function UserDashboard() {
  const { users, isLoading, deleteUser } = useUsers();
  const [filter, setFilter] = useState('all');

  const filtered = users.filter(u =>
    filter === 'all' ? true : u.role === filter
  );

  return (
    <div>
      <FilterBar value={filter} onChange={setFilter} />
      <UserList users={filtered} onDelete={deleteUser} />
      {isLoading && <Spinner />}
    </div>
  );
}

// Dumb child — no state, no hooks, just props
function UserList({ users, onDelete }: UserListProps) {
  return (
    <ul>
      {users.map(user => (
        <UserCard key={user.id} user={user} onDelete={() => onDelete(user.id)} />
      ))}
    </ul>
  );
}

// Dumb child — pure display
function UserCard({ user, onDelete }: UserCardProps) {
  return (
    <div>
      <span>{user.name}</span>
      <button onClick={onDelete}>Delete</button>
    </div>
  );
}

Why This Matters at Scale

Benefit How
Testability Dumb components are trivially testable — pass props, assert output
Reusability Dumb components can be reused anywhere with different data
Refactoring Change the data source in the smart parent without touching children
Performance Wrap dumb components in React.memo — they only re-render when props change
Readability Each file has a single, clear job

Rule of thumb: If a component imports a hook that calls an API, it's a smart component. Keep the number of smart components small — ideally one per feature module.


Single Responsibility

// Bad - does too much
function UserDashboard() {
  // fetches data, formats data, handles forms,
  // manages modals, renders everything
}

// Good - separated concerns
function UserDashboard() {
  const { user, isLoading } = useUser();

  if (isLoading) return <Spinner />;
  return <UserProfile user={user} />;
}

Composition Over Inheritance

// Build complex UIs from simple pieces
<Card>
  <CardHeader>
    <CardTitle>Settings</CardTitle>
  </CardHeader>
  <CardContent>
    <SettingsForm />
  </CardContent>
</Card>

Props Down, Events Up

// Parent owns state
function Parent() {
  const [value, setValue] = useState('');
  return <Child value={value} onChange={setValue} />;
}

// Child is controlled — no internal state
function Child({ value, onChange }) {
  return <input value={value} onChange={e => onChange(e.target.value)} />;
}

State Management — When to Use What

Decision Flowchart

React Context

Best for: Low-frequency global state — theme, locale, auth, user preferences.

// contexts/ThemeContext.tsx
const ThemeContext = createContext<ThemeContextType | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

When NOT to use Context:

  • State that changes frequently (typing, animations, real-time data) — every context update re-renders all consumers
  • Complex state with many fields — use Zustand instead

Zustand

Best for: Complex client-side state, UI state shared across unrelated components, state that updates frequently.

// store/useUIStore.ts
import { create } from 'zustand';

interface UIStore {
  sidebarOpen: boolean;
  activeModal: string | null;
  toggleSidebar: () => void;
  openModal: (id: string) => void;
  closeModal: () => void;
}

export const useUIStore = create<UIStore>((set) => ({
  sidebarOpen: true,
  activeModal: null,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),
}));

Why Zustand over Context for complex state:

React Context Zustand
Re-renders All consumers on any change Only components using the specific slice that changed
Boilerplate Provider, context, hook, types One create() call
DevTools None built-in Redux DevTools compatible
Outside React Not possible Can read/write from anywhere
Middleware None persist, devtools, immer

Zustand with selectors (performance):

// Only re-renders when sidebarOpen changes — not when activeModal changes
const sidebarOpen = useUIStore((s) => s.sidebarOpen);

React Redux (Redux Toolkit)

Best for: Large-scale apps with complex, deeply nested state, many developers, or where you need strict predictability, time-travel debugging, and middleware.

Redux is the industry standard for state management in large React apps. Redux Toolkit (RTK) is the modern way to write Redux — it eliminates the old boilerplate (action types, action creators, switch statements).

// store/userSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface UserState {
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  isLoggedIn: boolean;
}

const initialState: UserState = {
  name: '',
  email: '',
  role: 'guest',
  isLoggedIn: false,
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    login: (state, action: PayloadAction<{ name: string; email: string; role: 'admin' | 'user' }>) => {
      state.name = action.payload.name;
      state.email = action.payload.email;
      state.role = action.payload.role;
      state.isLoggedIn = true;
    },
    logout: (state) => {
      return initialState;
    },
  },
});

export const { login, logout } = userSlice.actions;
export default userSlice.reducer;

Setting up the store:

// store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import uiReducer from './uiSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
    ui: uiReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Typed hooks (create once, use everywhere):

// store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Wrapping your app:

// pages/_app.tsx
import { Provider } from 'react-redux';
import { store } from '../store/store';

function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>
      <Component {...pageProps} />
    </Provider>
  );
}

Using in a component:

function UserProfile() {
  const { name, role } = useAppSelector((state) => state.user);
  const dispatch = useAppDispatch();

  return (
    <div>
      <p>{name} ({role})</p>
      <button onClick={() => dispatch(logout())}>Log out</button>
    </div>
  );
}

Async operations with createAsyncThunk:

// store/userSlice.ts
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { fetchUserProfile } from '../api/users.api';

export const loadUser = createAsyncThunk('user/load', async (userId: string) => {
  const user = await fetchUserProfile(userId);
  return user;
});

const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(loadUser.pending, (state) => { state.loading = true; })
      .addCase(loadUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(loadUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

Note: For server data (fetching, caching, pagination), prefer React Query over createAsyncThunk. Use Redux for client-side state only. Many production apps use both — Redux for app state + React Query for server state.

RTK Query (built-in alternative to React Query):

Redux Toolkit also ships with RTK Query, its own data fetching and caching layer. If you're already fully committed to Redux, this keeps everything in one ecosystem:

// store/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUsers: builder.query({ query: () => '/users' }),
    deleteUser: builder.mutation({ query: (id) => ({ url: `/users/${id}`, method: 'DELETE' }) }),
  }),
});

export const { useGetUsersQuery, useDeleteUserMutation } = api;

React Query / TanStack Query

Best for: Anything that comes from a server — API data, fetching, caching, revalidation, pagination, optimistic updates.

// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchUsers, deleteUser } from '../api/users.api';

export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 5 * 60 * 1000,  // consider data fresh for 5 minutes
  });
}

export function useDeleteUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: deleteUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

What React Query gives you for free:

Feature Without React Query With React Query
Loading state Manual useState isLoading
Error handling Manual try/catch isError, error
Caching Build your own Automatic with staleTime
Refetch on focus Build your own On by default
Deduplication Build your own Automatic (same queryKey = one request)
Pagination Manual offset/cursor tracking useInfiniteQuery
Optimistic updates Manual rollback logic onMutate + onError rollback

Rule of thumb: If you're writing useEffect + useState + fetch together, you probably want React Query instead.


Local useState

Best for: State that only matters to one component — form inputs, toggles, open/close, hover states.

function SearchBar() {
  const [query, setQuery] = useState('');
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div>
      <button onClick={() => setIsExpanded(!isExpanded)}>Search</button>
      {isExpanded && (
        <input value={query} onChange={(e) => setQuery(e.target.value)} />
      )}
    </div>
  );
}

If two sibling components need the same state, lift it up to their shared parent and pass it down as props. Don't reach for Context or Zustand until props become unwieldy (3+ levels deep = "prop drilling").


Zustand vs Redux — When to Use Which

Zustand Redux Toolkit
Setup One create() call, no provider needed configureStore + Provider + typed hooks
Boilerplate Minimal More structure (slices, store, hooks, provider)
Learning curve Very low Moderate (actions, reducers, dispatch, selectors)
DevTools Redux DevTools compatible Full Redux DevTools (time-travel, action log)
Middleware Simple (persist, devtools, immer) Powerful (thunks, sagas, listeners, RTK Query)
Team size Small teams, solo projects Large teams (enforced patterns = consistency)
Async Do it however you want createAsyncThunk or RTK Query (structured)
Ecosystem Small but sufficient Massive (RTK Query, Redux Saga, Redux Persist, etc.)
When to pick Side projects, startups, small-medium apps Enterprise apps, large teams, apps that need strict patterns

Rule of thumb: Start with Zustand. Move to Redux if your team is growing, you need strict architectural patterns, or you're joining a codebase that already uses it. Many job postings list Redux as a requirement — it's worth knowing regardless.


Common Mistakes

Mistake Problem Fix
Putting server data in Zustand/Redux Stale data, manual cache invalidation Use React Query (or RTK Query) for server state
Using Context for frequently changing state Every consumer re-renders on every change Use Zustand or Redux with selectors
useEffect + fetch for data loading No caching, no deduplication, no error retry Use React Query
Global state for local concerns Unnecessary coupling, hard to delete Use useState in the component
Giant monolithic store One change re-renders everything Split into multiple small stores (Zustand) or slices (Redux)
Using Redux for everything Overengineered, slow development Only put client-side shared state in Redux — server data in React Query, local state in useState

Lazy Loading & Code Splitting

When to Lazy Load

Scenario Lazy Load? Why
Routes / pages Yes Users only visit one page at a time
Modals / dialogs Yes Most users never open them
Heavy libraries (charts, editors, PDF viewers) Yes Large bundle cost for features used occasionally
Admin-only sections Yes Only admins pay the bundle cost
Small shared components (Button, Input) No Already tiny, overhead of splitting is worse
Above-the-fold content No User sees it immediately — don't delay it

Route-Level Splitting

The highest impact lazy loading — each page is its own chunk, loaded only when the user navigates to it.

// pages/_app.tsx (Next.js handles this automatically with pages/ directory)
// Each file in pages/ becomes its own chunk

// For custom route splitting with React Router:
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./modules/DashboardModule'));
const Settings = lazy(() => import('./modules/SettingsModule'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Component-Level Splitting

For heavy components that don't appear on initial load:

import { lazy, Suspense } from 'react';

// Only loaded when the user clicks "Show Chart"
const HeavyChart = lazy(() => import('../components/HeavyChart'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      {showChart && (
        <Suspense fallback={<Spinner />}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
}

Dynamic Imports for Libraries

Don't load a 200KB library at startup if only one feature uses it:

// Bad — entire library loaded on page load
import jsPDF from 'jspdf';

// Good — loaded only when user clicks "Export PDF"
async function handleExportPDF(data: ReportData) {
  const { default: jsPDF } = await import('jspdf');
  const doc = new jsPDF();
  doc.text(data.title, 10, 10);
  doc.save('report.pdf');
}

Scalability Patterns

Feature Modules

Organize code by feature, not by type. Each module is a self-contained unit with its own components, hooks, and types:

modules/
├── Dashboard/
│   ├── DashboardModule.tsx       # Smart parent (entry point)
│   ├── DashboardHeader.tsx       # Dumb child
│   ├── DashboardStats.tsx        # Dumb child
│   ├── DashboardChart.tsx        # Dumb child
│   └── types.ts                  # Module-specific types
│
├── Settings/
│   ├── SettingsModule.tsx
│   ├── SettingsForm.tsx
│   ├── ThemeSelector.tsx
│   └── types.ts

A developer working on Settings never has to touch Dashboard files. Features are isolated — easy to add, modify, or delete without side effects.

Barrel Exports

Use index.ts files to create clean import paths:

// components/ui/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
export { Input } from './Input';

// Usage — clean single import
import { Button, Modal, Input } from '../components/ui';

API Layer Abstraction

Never call fetch directly from components or hooks. Centralize HTTP logic so the entire app can switch from REST to GraphQL, or change the auth header, in one place:

// api/client.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';

export async function apiClient<T>(
  endpoint: string,
  options?: RequestInit
): Promise<T> {
  const response = await fetch(`${API_BASE}${endpoint}`, {
    headers: { 'Content-Type': 'application/json', ...options?.headers },
    ...options,
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status} ${response.statusText}`);
  }

  return response.json();
}

// api/users.api.ts — domain-specific calls
import { apiClient } from './client';

export const fetchUsers = () => apiClient<User[]>('/api/users');
export const deleteUser = (id: string) =>
  apiClient(`/api/users/${id}`, { method: 'DELETE' });

Error Boundaries

Prevent one broken component from crashing the entire app:

// components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';

interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; }

class ErrorBoundary extends Component<Props, State> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    console.error('ErrorBoundary caught:', error);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

// Usage — wrap each feature module independently
<ErrorBoundary fallback={<p>Dashboard failed to load.</p>}>
  <DashboardModule />
</ErrorBoundary>

Environment Configuration

Keep environment values in one place — never scatter process.env calls across the codebase:

// config/env.ts
export const ENV = {
  API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
  AI_URL: process.env.NEXT_PUBLIC_AI_URL || 'http://localhost:8000',
  APP_NAME: process.env.NEXT_PUBLIC_APP_NAME || 'MyApp',
  IS_DEV: process.env.NODE_ENV === 'development',
} as const;

Testing — What, When, and How Much

The Testing Pyramid

Level What It Tests Speed How Many
Unit One function or component in isolation Fast (ms) Many (70-80%)
Integration Multiple units working together (hook + API + component) Medium (seconds) Some (15-20%)
E2E Full user flows through the real app (browser) Slow (seconds–minutes) Few (5-10%)

What to Test at Each Level

Unit tests:

// Utils — pure functions are the easiest to test
test('formatCurrency formats correctly', () => {
  expect(formatCurrency(1234.5)).toBe('$1,234.50');
  expect(formatCurrency(0)).toBe('$0.00');
  expect(formatCurrency(-50)).toBe('-$50.00');
});

// Dumb components — render with props, assert output
test('UserCard renders name and role', () => {
  render(<UserCard user={{ name: 'Alice', role: 'Admin' }} />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Admin')).toBeInTheDocument();
});

Integration tests:

// Component + hook + mocked API working together
test('UserList fetches and displays users', async () => {
  server.use(
    rest.get('/api/users', (req, res, ctx) =>
      res(ctx.json([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]))
    )
  );

  render(<UserList />);
  expect(await screen.findByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();
});

E2E tests:

// Playwright — real browser, full flow
test('user can log in and see dashboard', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name="email"]', 'alice@test.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');
  await expect(page.locator('h1')).toHaveText('Dashboard');
});

When to Write Tests

Situation What to Do
Building a new feature Write unit tests for utils/services, integration test for the main user flow
Fixing a bug Write a test that reproduces the bug first, then fix it — prevents regressions
Refactoring Tests should already exist — run them to confirm nothing breaks
Shared utility / library code Always unit test — other code depends on it
Prototype / spike Skip tests — you'll throw this code away
Critical business logic Test thoroughly (payments, auth, data transformations)
Simple CRUD UI Integration test for the happy path, skip unit testing every button

Tools

Tool Purpose Level
Vitest Test runner (fast, Vite-native) Unit + Integration
Jest Test runner (widely used, mature) Unit + Integration
React Testing Library Render components, query by user-visible text Unit + Integration
MSW (Mock Service Worker) Mock API responses at the network level Integration
Playwright Browser automation for real E2E flows E2E
Cypress Alternative browser E2E testing E2E

Testing Patterns

Test behavior, not implementation:

// Bad — tests implementation details (fragile)
test('calls setUsers after fetch', () => {
  expect(setUsersSpy).toHaveBeenCalledWith([...]);
});

// Good — tests what the user sees (resilient)
test('displays user list after loading', async () => {
  render(<UserList />);
  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

Use MSW instead of mocking fetch/axios directly:

// msw handler — intercepts real network requests
import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) =>
    res(ctx.json([{ id: 1, name: 'Alice' }]))
  )
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Arrange → Act → Assert:

test('delete button removes user from list', async () => {
  // Arrange — set up the component and data
  render(<UserList />);
  await screen.findByText('Alice');

  // Act — perform the user action
  fireEvent.click(screen.getByRole('button', { name: 'Delete' }));

  // Assert — verify the result
  await waitFor(() => {
    expect(screen.queryByText('Alice')).not.toBeInTheDocument();
  });
});

What NOT to Test

Don't Test Why
Third-party libraries (React, Zustand, Next.js) They have their own tests
CSS / styling Brittle, changes constantly, use visual regression tools if needed
Implementation details (internal state, private methods) Tests break on refactor even when behavior is correct
Every single component Focus on components with logic — pure display components rarely need tests
Console.log output Not user-facing behavior

Caching Strategies

When to Cache

Client-Side Caching with React Query

React Query manages client-side caching automatically. The key setting is staleTime — how long data is considered "fresh" before refetching:

// Data that rarely changes — cache for 30 minutes
const { data: userProfile } = useQuery({
  queryKey: ['user', 'profile'],
  queryFn: fetchUserProfile,
  staleTime: 30 * 60 * 1000,  // 30 minutes
});

// Data that changes moderately — cache for 2 minutes
const { data: dashboardStats } = useQuery({
  queryKey: ['dashboard', 'stats'],
  queryFn: fetchDashboardStats,
  staleTime: 2 * 60 * 1000,  // 2 minutes
});

// Data that changes often — always refetch (default behavior)
const { data: notifications } = useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  staleTime: 0,  // always stale — refetch on every window focus
});
Setting What It Does Default
staleTime How long data is "fresh" — won't refetch while fresh 0 (always stale)
gcTime How long unused data stays in cache before garbage collection 5 min
refetchOnWindowFocus Refetch when user tabs back to the app true
refetchOnReconnect Refetch when network reconnects true
retry How many times to retry a failed request 3

Browser Caching (HTTP Headers)

Set by the backend — controls how browsers and CDNs cache responses:

// Express example — set Cache-Control headers
app.get('/api/config', (req, res) => {
  // Cache static config for 1 hour
  res.set('Cache-Control', 'public, max-age=3600');
  res.json(config);
});

app.get('/api/users', (req, res) => {
  // Don't cache user-specific data in shared caches
  res.set('Cache-Control', 'private, max-age=60');
  res.json(users);
});

app.get('/api/notifications', (req, res) => {
  // Never cache — always get fresh data
  res.set('Cache-Control', 'no-store');
  res.json(notifications);
});
Header Use When
public, max-age=3600 Static assets, config, rarely changing data
private, max-age=60 User-specific data (only browser caches, not CDN)
no-store Sensitive or real-time data — never cache
stale-while-revalidate=60 Serve stale while fetching fresh in background

In-Memory Caching (Backend)

For expensive computations or database queries that don't change every request:

// Simple in-memory cache with TTL
const cache = new Map<string, { data: any; expires: number }>();

function getCached<T>(key: string, ttlMs: number, fetcher: () => T): T {
  const cached = cache.get(key);
  if (cached && Date.now() < cached.expires) {
    return cached.data;
  }

  const data = fetcher();
  cache.set(key, { data, expires: Date.now() + ttlMs });
  return data;
}

// Usage
app.get('/api/stats', (req, res) => {
  const stats = getCached('dashboard-stats', 60_000, () => {
    return computeExpensiveStats();  // only runs once per minute
  });
  res.json(stats);
});

For production apps with multiple server instances, use Redis instead of in-memory caching — it's shared across processes.

Cache Invalidation

The hardest part of caching. Strategies:

Strategy How When
Time-based (TTL) Data expires after X seconds Default — works for most cases
Event-based Invalidate on write (mutation) After creating/updating/deleting data
Manual Force refetch on user action "Refresh" buttons, pull-to-refresh
// React Query — invalidate cache after a mutation
const { mutate: createUser } = useMutation({
  mutationFn: postNewUser,
  onSuccess: () => {
    // Invalidate the users list so it refetches
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});

Common Caching Mistakes

Mistake Problem Fix
Caching user-specific data with public header Other users see someone else's data Use private or no-store
Infinite staleTime without invalidation Data never updates Always pair long cache with mutation invalidation
Caching everything Memory bloat, stale data everywhere Only cache data that's expensive to fetch or rarely changes
Not caching static assets Slow page loads, wasted bandwidth Set long max-age for JS/CSS/images + content hashing in filenames
Forgetting to invalidate after writes User creates something, doesn't see it invalidateQueries after every mutation

Running Scripts & Task Automation

package.json Scripts

Your package.json scripts are the entry point for every developer workflow — running, building, testing, and deploying:

{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "vitest",
    "test:watch": "vitest --watch",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "type-check": "tsc --noEmit",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "clean": "rm -rf .next node_modules/.cache"
  }
}

Script Composition Patterns

Chain scripts together for complex workflows:

{
  "scripts": {
    "validate": "npm run type-check && npm run lint && npm run test",
    "ci": "npm run validate && npm run build",
    "dev:all": "concurrently \"npm run dev\" \"npm run test:watch\""
  }
}
Operator Behavior
&& Run sequentially — stop on first failure
& Run in parallel (background) — fragile, prefer concurrently
concurrently Run multiple scripts in parallel with proper output (npm package)
; Run sequentially — continue even if one fails

Pre/Post Hooks

npm automatically runs pre* and post* scripts around any script:

{
  "scripts": {
    "prebuild": "npm run type-check && npm run lint",
    "build": "next build",
    "postbuild": "echo 'Build complete!'"
  }
}

Running npm run build will execute: prebuildbuildpostbuild.

Common Script Recipes

Task Script When to Use
Dev server next dev --turbopack Local development
Type checking tsc --noEmit Catch type errors without emitting files
Lint next lint or eslint . Enforce code style and catch issues
Format prettier --write . Auto-format all files
Test (single run) vitest run CI pipelines, pre-commit
Test (watch) vitest --watch During development — reruns on file change
Test coverage vitest run --coverage See which code is tested and which isn't
E2E tests playwright test Full browser tests before deploy
Bundle analysis ANALYZE=true next build Check what's in your bundle (requires @next/bundle-analyzer)
Clean cache rm -rf .next node_modules/.cache Fix stale build issues
Full validation tsc --noEmit && eslint . && vitest run Run before committing or in CI

Tip: Add a validate script that runs type-check + lint + tests. Run it before every PR. Better yet, add it as a pre-push git hook with Husky.


Naming Conventions

Type Convention Example
Components PascalCase UserProfile, DashboardStats
Hooks camelCase with use prefix useUsers, useTheme
Services camelCase with .service suffix dashboard.service.ts
API files camelCase with .api suffix users.api.ts
Utils camelCase formatDate, parseJSON
Constants SCREAMING_SNAKE_CASE MAX_ITEMS, API_BASE_URL
Types/Interfaces PascalCase User, DashboardStats
CSS classes kebab-case user-profile, card-header
Folders camelCase or PascalCase (match content) Dashboard/, utils/
Zustand stores camelCase with use prefix useUIStore.ts

Performance Tips

React Optimization

// Memoize expensive calculations
const sortedItems = useMemo(() =>
  items.sort((a, b) => a.name.localeCompare(b.name)),
  [items]
);

// Memoize callbacks passed to child components
const handleClick = useCallback(() => {
  doSomething(id);
}, [id]);

// Memoize dumb components — only re-render when props change
const MemoizedUserCard = React.memo(UserCard);

Don't over-memoize. Only use useMemo/useCallback/React.memo when you've identified a real performance problem (profiler shows unnecessary re-renders). Premature memoization adds complexity for no benefit.

Avoid Re-renders

// Bad - creates a new object every render, breaking React.memo
<Component style={{ color: 'red' }} />

// Good - stable reference
const style = useMemo(() => ({ color: 'red' }), []);
<Component style={style} />

// Bad - inline function creates new reference every render
<Button onClick={() => handleDelete(id)} />

// Good - stable callback (if Button is memoized)
const handleDeleteClick = useCallback(() => handleDelete(id), [id]);
<Button onClick={handleDeleteClick} />

Bundle Size

# Analyze what's in your bundle
npm run build
npx source-map-explorer 'build/static/js/*.js'

# Or with next.js
ANALYZE=true npm run build  # (requires @next/bundle-analyzer)
Quick Wins Impact
Import only what you use: import { format } from 'date-fns' not import * as dateFns Reduces unused code via tree shaking
Use dynamic imports for large libraries Loads only when needed
Replace heavy libraries with lighter alternatives (e.g., dayjs over moment) Smaller initial bundle
Check bundle analyzer for unexpected large dependencies Identifies bloat

VS Code Productivity

Essential Shortcuts

Shortcut Action
Ctrl+P Quick open file
Ctrl+Shift+P Command palette
Ctrl+Shift+F Search across files
Ctrl+. Quick fix / code actions
F2 Rename symbol
F12 Go to definition
Shift+F12 Find all references
Ctrl+Shift+K Delete line
Alt+Up/Down Move line
Shift+Alt+Up/Down Duplicate line

Multi-Cursor Editing

Shortcut Action
Ctrl+D Select next occurrence
Ctrl+Shift+L Select ALL occurrences
Alt+Click Add cursor
Ctrl+Alt+Up/Down Add cursor above/below
Shortcut Action
Ctrl+G Go to line
Ctrl+Shift+O Go to symbol in file
Ctrl+T Go to symbol in workspace
Alt+Left/Right Navigate back/forward
Ctrl+Tab Switch between open files

Git Best Practices

Commit Messages

# Format
<type>: <short description>

# Types
feat:     New feature
fix:      Bug fix
refactor: Code change (no new feature or fix)
style:    Formatting, semicolons, etc.
docs:     Documentation only
test:     Adding tests
chore:    Maintenance tasks

Examples:

feat: add dark mode toggle to settings
fix: resolve memory leak in text editor
refactor: extract validation logic to utils
docs: update API documentation

Branch Naming

feature/add-dark-mode
bugfix/fix-memory-leak
refactor/extract-validation

Before Committing

# Check status
git status

# Review changes
git diff

# Stage specific files (not everything)
git add src/components/Button.tsx

# Commit with message
git commit -m "feat: add hover state to button"

Debugging Tips

Console Methods

// Group related logs
console.group('User Data');
console.log('Name:', user.name);
console.log('Email:', user.email);
console.groupEnd();

// Table format for arrays/objects
console.table(users);

// Timing
console.time('fetch');
await fetchData();
console.timeEnd('fetch'); // fetch: 234ms

// Stack trace
console.trace('How did we get here?');

React DevTools

  1. Install React DevTools browser extension
  2. Use Components tab to inspect props/state
  3. Use Profiler to find performance issues — look for unnecessary re-renders

Network Debugging

  1. Open DevTools → Network tab
  2. Filter by XHR/Fetch
  3. Check request/response payloads
  4. Look for failed requests (red)

Quick Reference Card

┌─────────────────────────────────────────────────────────────┐
│                    DATA FLOW                                │
├─────────────────────────────────────────────────────────────┤
│  Component → Hook → Service → API → Backend                 │
│  Component ← Hook ← Service ← API ← Backend                 │
├─────────────────────────────────────────────────────────────┤
│                    STATE MANAGEMENT                         │
├─────────────────────────────────────────────────────────────┤
│  Server data:        React Query (TanStack Query)           │
│  Global simple:      React Context (theme, auth, locale)    │
│  Global complex:     Zustand (small/mid) or Redux (large)   │
│  Local:              useState / useReducer                   │
│  Parent ↔ Children:  Props (lift state up)                  │
├─────────────────────────────────────────────────────────────┤
│                    COMPONENT RULES                          │
├─────────────────────────────────────────────────────────────┤
│  Smart:  Calls hooks, manages state, passes data down       │
│  Dumb:   Receives props, renders UI, emits events up        │
│  Rule:   One smart parent per feature module                │
├─────────────────────────────────────────────────────────────┤
│                    LAYER RULES                              │
├─────────────────────────────────────────────────────────────┤
│  Component: UI only, uses hooks                             │
│  Hook: State + effects, calls services                      │
│  Service: Business logic, calls API                         │
│  API: HTTP calls only                                       │
│  Utils: Pure functions, no side effects                     │
├─────────────────────────────────────────────────────────────┤
│                    NAMING                                   │
├─────────────────────────────────────────────────────────────┤
│  Component:  PascalCase      UserProfile.tsx                │
│  Hook:       useXxx          useUserData.ts                 │
│  Service:    xxx.service     user.service.ts                │
│  API:        xxx.api         user.api.ts                    │
│  Store:      useXxxStore     useUIStore.ts                  │
│  Constant:   SCREAMING       MAX_RETRIES                    │
├─────────────────────────────────────────────────────────────┤
│                    LAZY LOAD?                               │
├─────────────────────────────────────────────────────────────┤
│  Routes/pages:          YES                                 │
│  Modals/dialogs:        YES                                 │
│  Heavy libs:            YES (dynamic import)                │
│  Admin-only features:   YES                                 │
│  Small shared UI:       NO                                  │
│  Above the fold:        NO                                  │
├─────────────────────────────────────────────────────────────┤
│                    TESTING                                  │
├─────────────────────────────────────────────────────────────┤
│  Unit:         Utils, services, dumb components (70-80%)    │
│  Integration:  Hook + API + component together  (15-20%)    │
│  E2E:          Full user flows in browser       (5-10%)     │
│  Tools:        Vitest, React Testing Library, Playwright    │
│  Rule:         Test behavior, not implementation            │
├─────────────────────────────────────────────────────────────┤
│                    CACHING                                  │
├─────────────────────────────────────────────────────────────┤
│  Client:       React Query staleTime (0 → 30min)           │
│  Browser:      Cache-Control headers (public/private)       │
│  Backend:      In-memory Map or Redis                       │
│  Invalidate:   TTL, event-based, or manual                  │
│  Rule:         Always invalidate after mutations            │
├─────────────────────────────────────────────────────────────┤
│                    SCRIPTS                                  │
├─────────────────────────────────────────────────────────────┤
│  Dev:          npm run dev                                  │
│  Validate:     type-check && lint && test                   │
│  CI:           validate && build                            │
│  Tip:          Add validate as pre-push hook (Husky)        │
└─────────────────────────────────────────────────────────────┘