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
- Architecture Patterns
- Component Design — Smart vs Dumb
- State Management — When to Use What
- Lazy Loading & Code Splitting
- Scalability Patterns
- Testing — What, When, and How Much
- Caching Strategies
- Running Scripts & Task Automation
- Naming Conventions
- Performance Tips
- VS Code Productivity
- Git Best Practices
- Debugging Tips
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+fetchtogether, 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: prebuild → build → postbuild.
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
validatescript 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.memowhen 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 |
Navigation
| 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
- Install React DevTools browser extension
- Use Components tab to inspect props/state
- Use Profiler to find performance issues — look for unnecessary re-renders
Network Debugging
- Open DevTools → Network tab
- Filter by XHR/Fetch
- Check request/response payloads
- 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) │
└─────────────────────────────────────────────────────────────┘