Skip to main content

React Development Best Practices

Updated 36 min readMarkdown

A practical guide with good and bad examples

Purpose: A practical reference for building React applications that are easy to understand, test, maintain, and scale.

The best React code is not the code that uses the most Hooks or libraries. It is code that solves the requirement clearly, safely, and with the least unnecessary complexity.

Audience: Beginner-to-intermediate React developers and teams reviewing production code.

Examples: Most examples use modern function components, JavaScript, and Vite-style environment variables. Add imports where needed. Adapt the folder structure to the size of your application rather than following it mechanically.


Contents

  1. Core principles
  2. Component design
  3. File naming and folder structure
  4. Props and component communication
  5. State management fundamentals
  6. useState best practices
  7. useEffect best practices
  8. Other important Hooks
  9. Custom Hooks
  10. Context API and useContext
  11. useReducer
  12. Choosing useState, useReducer, Context, or Redux Toolkit
  13. Redux Toolkit
  14. API calls and data fetching
  15. Loading, error, empty, and success states
  16. Forms and validation
  17. Rendering and performance
  18. Routing and code splitting
  19. Error boundaries and error handling
  20. Security and accessibility
  21. Testing
  22. Code quality and maintainability
  23. Example feature structure
  24. Code review checklist
  25. Useful official resources

1. Core principles

Keep these principles in mind when writing React code:

  • Keep components focused: A component should have one clear UI responsibility.
  • Keep state close to where it is used: Do not make every value global.
  • Avoid duplicated state: Derive values from existing state when possible.
  • Do not mutate state: Create new arrays and objects when updating state.
  • Separate responsibilities: UI, API communication, business logic, and shared utilities should not become one large file.
  • Handle every request state: Consider loading, success, empty, and error states.
  • Optimize based on evidence: Do not add memoization everywhere.
  • Make the UI accessible: Prefer semantic HTML and keyboard-friendly interactions.
  • Test important behaviour: Focus on user-visible outcomes and critical business rules.

React is declarative: describe what the UI should look like for the current state. Avoid manually changing the DOM when React state can represent the change.

Bad: manually changing the DOM

function Message() {
  function showMessage() {
    document.getElementById("message").innerText = "Saved";
  }

  return <p id="message">Not saved</p>;
}

Good: represent the UI with state

import { useState } from "react";

function Message() {
  const [saved, setSaved] = useState(false);

  return (
    <>
      <p>{saved ? "Saved" : "Not saved"}</p>
      <button onClick={() => setSaved(true)}>Save</button>
    </>
  );
}

2. Component design

2.1 Keep components small and focused

A component should have a clear purpose. Split a large component when it handles unrelated UI, data fetching, form logic, filtering, and modal behaviour all together.

Bad

function UsersPage() {
  // Fetch users
  // Filter users
  // Handle form submission
  // Manage modal
  // Render header, filters, table and modal
  // Hundreds of lines of mixed responsibilities
}

Good

function UsersPage() {
  return (
    <PageLayout>
      <UsersHeader />
      <UserFilters />
      <UsersTable />
      <UserFormDialog />
    </PageLayout>
  );
}

Do not split every few lines into a new component. Extract a component when it has a meaningful responsibility, is reused, or makes the parent easier to understand.

2.2 Prefer function components

Use function components and Hooks for new React code.

function Welcome({ name }) {
  return <h1>Welcome, {name}</h1>;
}

Class components are still supported, and you may encounter them in older projects. New application code generally uses function components.

2.3 Keep render logic readable

Bad

return (
  <div>
    {users && users.length > 0
      ? users.map((user) => user.active
          ? <div>{user.name}</div>
          : null)
      : <p>No users</p>}
  </div>
);

Good

if (users.length === 0) {
  return <p>No users found.</p>;
}

return (
  <ul>
    {users.map((user) => (
      <li key={user.id}>
        {user.name}
      </li>
    ))}
  </ul>
);

Use helper functions or smaller components when conditional rendering becomes complicated.

2.4 Avoid defining components inside components

Defining a component inside another component creates a new component type on every parent render and can reset child state.

Avoid

function Dashboard() {
  function ProfileCard() {
    return <div>Profile</div>;
  }

  return <ProfileCard />;
}

Prefer

function ProfileCard() {
  return <div>Profile</div>;
}

function Dashboard() {
  return <ProfileCard />;
}

3. File naming and folder structure

Consistency matters more than one universally correct naming scheme. Agree on conventions and use them throughout the project.


Item Recommended convention Example


React component PascalCase UserProfile.jsx

Page component PascalCase UsersPage.jsx

Custom Hook camelCase with use useUsers.js prefix

Utility function camelCase formatDate.js

API/service module camelCase userService.js

Context PascalCase or AuthContext.jsx descriptive camelCase

Reducer camelCase cartReducer.js

Constants camelCase file; appConstants.js uppercase values where
useful

Test Match source file plus UserProfile.test.jsx test suffix

Styles Match component name UserProfile.module.css

Use names that describe purpose. Avoid vague names such as data.js, common2.js, helper.js, or NewComponent.jsx.

3.2 Small application structure

For a small app, a simple structure is enough:

src/
├── assets/
├── components/
├── pages/
├── hooks/
├── services/
├── utils/
├── App.jsx
└── main.jsx

3.3 Feature-based structure for medium and large applications

Group code by feature so related UI, hooks, API functions, and tests stay together.

src/
├── app/
│   ├── App.jsx
│   ├── routes.jsx
│   └── store.js
│
├── features/
│   ├── auth/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── services/
│   │   ├── authSlice.js
│   │   └── AuthPage.jsx
│   │
│   ├── users/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── services/
│   │   ├── usersSlice.js
│   │   └── UsersPage.jsx
│   │
│   └── products/
│       ├── components/
│       ├── hooks/
│       ├── services/
│       └── ProductsPage.jsx
│
├── shared/
│   ├── components/
│   ├── hooks/
│   └── utils/
│
├── services/
│   └── apiClient.js
│
├── assets/
├── styles/
└── main.jsx

Use features/ for feature-specific code and shared/ for genuinely reusable code. Do not move a component into shared/ just because it is used by two files if it is still tightly coupled to one feature.

3.4 Avoid overengineering folders

Do not create many empty folders for a small project. Start simple and introduce feature boundaries as the application grows.


4. Props and component communication

Props are the normal way for a parent to pass data and callbacks to a child.

Good

function UserCard({ user, onSelect }) {
  return (
    <button onClick={() => onSelect(user.id)}>
      {user.name}
    </button>
  );
}

function UsersList({ users }) {
  function handleSelect(userId) {
    console.log("Selected:", userId);
  }

  return users.map((user) => (
    <UserCard
      key={user.id}
      user={user}
      onSelect={handleSelect}
    />
  ));
}

Avoid unnecessary prop drilling

Passing a value through many components that do not use it can make the component tree difficult to maintain.

Before introducing global state, consider: - Moving the state closer to the components that need it. - Passing a value through a small number of component levels. - Using component composition. - Using Context for genuinely shared values. - Using Redux Toolkit when the application needs a central client-state store.

Avoid passing huge objects when a component needs only a few values

Prefer a focused interface:

<UserAvatar
  name={user.name}
  imageUrl={user.avatarUrl}
/>

rather than passing a large object if the component only needs two fields. Passing an object is still appropriate when the component genuinely represents that object.


5. State management fundamentals

React state is a snapshot for a particular render. Calling a setter requests another render; it does not change the value in the currently running function.

5.1 Keep state minimal

Bad: store values that can be calculated

const [price, setPrice] = useState(100);
const [quantity, setQuantity] = useState(2);
const [total, setTotal] = useState(200);

Now every price or quantity update must also keep total in sync.

Good: derive the value

const [price, setPrice] = useState(100);
const [quantity, setQuantity] = useState(2);

const total = price * quantity;

5.2 Never mutate state directly

Bad

user.name = "Asha";
setUser(user);

items.push(newItem);
setItems(items);

React may not detect a changed reference, and mutation can cause difficult-to-debug bugs.

Good

setUser((previousUser) => ({
  ...previousUser,
  name: "Asha",
}));

setItems((previousItems) => [
  ...previousItems,
  newItem,
]);

For an array item update:

setItems((previousItems) =>
  previousItems.map((item) =>
    item.id === updatedItem.id ? updatedItem : item
  )
);

For deletion:

setItems((previousItems) =>
  previousItems.filter((item) => item.id !== itemId)
);

5.3 Do not store values in state if they are not part of the UI

Use a normal variable for a value that does not need to trigger a render. Use useRef for mutable values that must persist between renders without triggering a render.


6. useState best practices

Use useState for simple component state such as input values, open/closed flags, selected tabs, and small independent values.

6.1 Basic usage

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

6.2 Use functional updates when the next value depends on the previous value

Risky when multiple updates use the same render snapshot

function incrementThreeTimes() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Each call reads the same count snapshot.

Good

function incrementThreeTimes() {
  setCount((previous) => previous + 1);
  setCount((previous) => previous + 1);
  setCount((previous) => previous + 1);
}

6.3 Lazy initialization for expensive initial calculations

const [settings, setSettings] = useState(() => {
  return loadInitialSettings();
});

The initializer is used to calculate the initial state. Do not pass loadInitialSettings() if you do not want it evaluated on every render.

A few related fields can be stored in one object:

const [form, setForm] = useState({
  name: "",
  email: "",
});

function updateField(field, value) {
  setForm((previous) => ({
    ...previous,
    [field]: value,
  }));
}

Do not combine unrelated values into one giant state object just to reduce the number of Hooks.


7. useEffect best practices

useEffect is for synchronizing a component with something outside React, such as a network connection, browser event, timer, or third-party widget.

It is not a general-purpose place to calculate values or respond to every user action.

7.1 Do not use an Effect for derived values

Bad

const [firstName, setFirstName] = useState("Maya");
const [lastName, setLastName] = useState("Rao");
const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

Good

const fullName = `${firstName} ${lastName}`;

7.2 Do not use an Effect for a user event

Bad

const [shouldSave, setShouldSave] = useState(false);

useEffect(() => {
  if (shouldSave) {
    saveUser();
  }
}, [shouldSave]);

Good

function handleSave() {
  saveUser();
}

The save is caused by a button click, so handle it in the event handler.

7.3 Correct dependency handling

Include every reactive value used inside the Effect. Do not silence dependency warnings just to make an Effect run less often. Restructure the code if the dependencies are causing an unwanted loop.

useEffect(() => {
  document.title = `Users (${users.length})`;
}, [users.length]);

7.4 Clean up subscriptions and browser events

useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

7.5 Cancel or ignore outdated requests

useEffect(() => {
  const controller = new AbortController();

  async function loadUser() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });

      if (!response.ok) {
        throw new Error("Could not load user");
      }

      const data = await response.json();
      setUser(data);
    } catch (error) {
      if (error.name !== "AbortError") {
        setError(error.message);
      }
    }
  }

  loadUser();

  return () => controller.abort();
}, [userId]);

This prevents an obsolete request from updating the component after the userId changes or the component unmounts.

7.6 Avoid Effects that update their own dependencies without a clear reason

An Effect that updates a value it depends on can create an infinite render loop. Check whether the update belongs in an event handler or can be calculated during render.


8. Other important Hooks

8.1 useRef

Use useRef for a DOM reference or a mutable value that should persist across renders but should not itself trigger a render.

DOM example:

import { useRef } from "react";

function SearchBox() {
  const inputRef = useRef(null);

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>Focus</button>
    </>
  );
}

Mutable value example:

const requestCount = useRef(0);
requestCount.current += 1;

Do not use a ref for data that must immediately appear in the UI. Use state for that.

8.2 useMemo

Use useMemo to cache the result of a costly calculation between renders when its dependencies have not changed.

const filteredUsers = useMemo(() => {
  return users.filter((user) =>
    user.name.toLowerCase().includes(search.toLowerCase())
  );
}, [users, search]);

For a small array, calculate it directly:

const filteredUsers = users.filter((user) =>
  user.name.toLowerCase().includes(search.toLowerCase())
);

Do not use useMemo by default for every variable.

8.3 useCallback

Use useCallback when a stable function reference is useful, for example when passing a callback to a memoized child or using it as a dependency of another Hook.

const handleSelect = useCallback((id) => {
  setSelectedId(id);
}, []);

Do not wrap every event handler in useCallback. It adds complexity and does not automatically make the application faster.

8.4 React.memo

React.memo can skip a child render when its props have not changed.

const UserCard = memo(function UserCard({ user, onSelect }) {
  return (
    <button onClick={() => onSelect(user.id)}>
      {user.name}
    </button>
  );
});

It is most useful when the component renders often, rendering is expensive, and props are stable. It is not a substitute for good state placement.

8.5 useId

Use useId to create stable IDs for accessibility relationships, such as connecting a label and input. Do not use it for list keys.

const inputId = useId();

return (
  <>
    <label htmlFor={inputId}>Email</label>
    <input id={inputId} type="email" />
  </>
);

8.6 useLayoutEffect

Use useLayoutEffect only when you must measure or adjust layout before the browser paints, such as measuring an element to position a tooltip. Prefer useEffect for normal synchronization.

8.7 Rules of Hooks

  • Call Hooks at the top level of a function component or custom Hook.
  • Do not call Hooks inside conditions, loops, callbacks, or regular utility functions.
  • Call Hooks in the same order on every render.
  • Use the use prefix for custom Hooks.
  • Follow the React Hooks ESLint rules.

Bad

if (isLoggedIn) {
  const [user, setUser] = useState(null);
}

Good

const [user, setUser] = useState(null);

if (!isLoggedIn) {
  return <Login />;
}

9. Custom Hooks

Create a custom Hook when logic needs to be reused or when extracting it makes a component easier to read. A custom Hook shares logic, not one shared state instance: each call has its own state unless it uses a shared store or Context.

Example: useWindowWidth

import { useEffect, useState } from "react";

export function useWindowWidth() {
  const [width, setWidth] = useState(() => window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return width;
}

Usage:

function ResponsiveMessage() {
  const width = useWindowWidth();

  return <p>Window width: {width}px</p>;
}

For server-rendered applications, account for the fact that window is not available during server rendering. Initialize safely or use an appropriate client-only approach.

Bad: custom Hook that only renames a built-in Hook

function useMyState(initialValue) {
  return useState(initialValue);
}

This adds no meaningful abstraction. Create custom Hooks to encapsulate useful behaviour, not merely to wrap one line.


10. Context API and useContext

Context lets a parent provide a value to many descendants without manually passing it through every intermediate component.

Good use cases: - Current authenticated user and authentication actions. - Theme. - Locale or language. - A small, stable application-wide setting.

Context is not automatically a replacement for Redux or a server-state library.

10.1 Create a Context

// context/ThemeContext.jsx
import { createContext, useContext, useState } from "react";

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  function toggleTheme() {
    setTheme((current) =>
      current === "light" ? "dark" : "light"
    );
  }

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

export function useTheme() {
  const context = useContext(ThemeContext);

  if (context === null) {
    throw new Error("useTheme must be used inside ThemeProvider");
  }

  return context;
}

10.2 Wrap the application

// main.jsx
import { createRoot } from "react-dom/client";
import App from "./App";
import { ThemeProvider } from "./context/ThemeContext";

createRoot(document.getElementById("root")).render(
  <ThemeProvider>
    <App />
  </ThemeProvider>
);

10.3 Consume Context

import { useTheme } from "../context/ThemeContext";

function ThemeButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}

10.4 Bad Context usage

Do not put every changing value in one giant Context:

<AppContext.Provider
  value={{
    user,
    theme,
    cart,
    filters,
    modal,
    notifications,
    formValues,
  }}
>
  {children}
</AppContext.Provider>

This makes the provider difficult to maintain. Consumers can re-render when the provider value changes, even if they only need one part of it.

Better

  • Split Contexts by responsibility, such as AuthContext and ThemeContext.
  • Keep frequently changing local state inside the feature that owns it.
  • Memoize provider values only when it helps and dependencies are correct.
  • Consider Redux Toolkit for complex shared client state.

10.5 When should you use Context?

Use Context when: - Many descendants need the same value. - The value is conceptually application-wide or subtree-wide. - Prop drilling is making the component tree harder to maintain. - The update frequency and complexity are manageable.

Avoid Context when: - Only one or two nearby components need the value. - The value changes very frequently and causes broad re-rendering. - You need advanced store features, middleware, DevTools workflows, or complex update patterns.


11. useReducer

useReducer is a Hook for managing state through actions. It is useful when state transitions are more involved than a few independent setter calls.

11.1 Basic structure

A reducer receives the current state and an action, then returns the next state.

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };

    case "decrement":
      return { count: state.count - 1 };

    case "reset":
      return { count: 0 };

    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

Use it in a component:

import { useReducer } from "react";

const initialState = { count: 0 };

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <>
      <h2>{state.count}</h2>

      <button onClick={() => dispatch({ type: "increment" })}>
        +
      </button>

      <button onClick={() => dispatch({ type: "decrement" })}>
        -
      </button>

      <button onClick={() => dispatch({ type: "reset" })}>
        Reset
      </button>
    </>
  );
}

11.2 Practical example: form state

When several related fields and transitions are involved, a reducer can make the update rules explicit.

const initialState = {
  name: "",
  email: "",
  errors: {},
  status: "idle",
};

function formReducer(state, action) {
  switch (action.type) {
    case "fieldChanged":
      return {
        ...state,
        [action.field]: action.value,
        errors: {
          ...state.errors,
          [action.field]: "",
        },
      };

    case "validationFailed":
      return {
        ...state,
        errors: action.errors,
        status: "idle",
      };

    case "submitting":
      return { ...state, status: "loading" };

    case "submitted":
      return { ...state, status: "success" };

    case "failed":
      return { ...state, status: "error" };

    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}
function RegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  function handleChange(event) {
    const { name, value } = event.target;

    dispatch({
      type: "fieldChanged",
      field: name,
      value,
    });
  }

  return (
    <form>
      <input
        name="name"
        value={state.name}
        onChange={handleChange}
      />

      <input
        name="email"
        value={state.email}
        onChange={handleChange}
      />

      <p>Status: {state.status}</p>
    </form>
  );
}

This is only the state-management part of a form; add validation, submission, and accessible labels for a production form.

11.3 useReducer best practices

  • Keep reducers pure: no API calls, timers, random values, or DOM operations inside them.
  • Do not mutate the existing state.
  • Use descriptive action names, such as itemAdded or requestFailed.
  • Keep initial state in a clearly named constant.
  • Keep reducer logic outside the component when it is substantial.
  • Do not use a reducer for trivial state that is clearer with useState.

11.4 When should you use useReducer?

Choose useReducer when: - Several state values change together. - Many event handlers update the same state object. - The next state depends on the current state and the action. - You want transitions to be centralized and easy to test. - A form, wizard, cart, or local workflow has multiple states and transitions.

Use useState when: - State is simple and independent. - A boolean, input value, selected tab, or small value is all you need. - A reducer would add more code than clarity.


12. Choosing useState, useReducer, Context, or Redux Toolkit

These tools solve related but different problems. They are not mutually exclusive: for example, Context can provide a reducer's state and dispatch to descendants.


Tool Best for Example


useState Simple local state Input value, modal open

useReducer Related state with Multi-step form, local multiple transitions cart logic

Context + useState Shared, relatively Theme, current user simple state

Context + useReducer Shared state with Small workflow shared structured transitions by many descendants

Redux Toolkit Complex shared client Large app with shared state across features entities and workflows

TanStack Query / RTK Server state and API Fetching, caching, Query caching refetching API data

Decision guide

Use useState if one component owns a simple value.

Use useReducer if one feature owns a group of related values and has several meaningful transitions.

Use Context if many descendants need the same value and passing props is becoming awkward.

Use Context + useReducer if shared state has several related transitions but the app does not need a full Redux store.

Use Redux Toolkit if many features need coordinated shared client state, predictable action-based updates, middleware, strong Redux DevTools support, or a standard store architecture.

Use a server-state tool when the hard problem is fetching, caching, synchronizing, and invalidating remote data. Redux is not automatically the best place for all API response data.

Example choices


Requirement Good starting point


Open/close one dialog useState

Filter a table on one page useState

Multi-step checkout state inside useReducer one feature

Theme across the app Context

Authenticated user shared across Context or Redux Toolkit, depending many components on complexity

Shopping cart shared across many Redux Toolkit may be appropriate routes with complex actions

Cache product API results TanStack Query or RTK Query

Server-side form validation API + form state/validation library

Do not choose Redux just because the application has many components. Choose it when the state-sharing and update requirements justify a centralized store.


13. Redux Toolkit

Redux Toolkit (RTK) is the recommended way to write Redux logic. It reduces boilerplate and provides a standard approach to reducers, actions, and store setup.

Use it for complex shared client state. For remote API data, consider RTK Query (included with Redux Toolkit) or another server-state library.

13.1 Install

npm install @reduxjs/toolkit react-redux

13.2 Create a slice

// features/cart/cartSlice.js
import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  items: [],
};

const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    itemAdded(state, action) {
      state.items.push(action.payload);
    },

    itemRemoved(state, action) {
      state.items = state.items.filter(
        (item) => item.id !== action.payload
      );
    },

    cartCleared(state) {
      state.items = [];
    },
  },
});

export const { itemAdded, itemRemoved, cartCleared } =
  cartSlice.actions;

export default cartSlice.reducer;

RTK uses Immer internally, so this reducer syntax is safe inside createSlice. Do not copy this mutation-style syntax into ordinary React state updates or plain reducers.

13.3 Configure the store

// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";

export const store = configureStore({
  reducer: {
    cart: cartReducer,
  },
});

13.4 Add the Provider

// main.jsx
import { Provider } from "react-redux";
import { store } from "./app/store";

createRoot(document.getElementById("root")).render(
  <Provider store={store}>
    <App />
  </Provider>
);

13.5 Read state and dispatch actions

import { useDispatch, useSelector } from "react-redux";
import { itemAdded } from "./cartSlice";

function Cart() {
  const items = useSelector((state) => state.cart.items);
  const dispatch = useDispatch();

  return (
    <>
      <p>Items: {items.length}</p>
      <button
        onClick={() =>
          dispatch(itemAdded({ id: 1, name: "Keyboard" }))
        }
      >
        Add item
      </button>
    </>
  );
}

13.6 Redux Toolkit best practices

  • Create slices around features or meaningful state domains.
  • Keep reducers synchronous and pure in intent; use RTK's async patterns for asynchronous workflows.
  • Use selectors to read only the state a component needs.
  • Avoid copying the same data into multiple slices.
  • Do not put temporary input state in Redux without a good reason.
  • Keep non-serializable values such as DOM nodes, functions, and class instances out of the store.
  • Use Redux DevTools to inspect actions and state changes.
  • Consider RTK Query for API fetching and caching rather than manually building every request lifecycle.

13.7 When Redux Toolkit is unnecessary

For a small app with a few local states, adding Redux creates extra setup without much benefit. Start with component state and Context where appropriate, then introduce Redux Toolkit when the actual requirements justify it.


14. API calls and data fetching

Keep network communication out of large UI components. Put reusable request configuration in an API client and feature-specific endpoints in service modules.

14.1 Example folder structure

src/
├── services/
│   └── apiClient.js
└── features/
    └── users/
        ├── services/
        │   └── userService.js
        ├── hooks/
        │   └── useUsers.js
        └── UsersPage.jsx

14.2 Centralize the API base URL

For a Vite project:

VITE_API_BASE_URL=https://api.example.com
// services/apiClient.js
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

export async function apiRequest(path, options = {}) {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...options.headers,
    },
  });

  if (!response.ok) {
    const message = `Request failed: ${response.status}`;
    throw new Error(message);
  }

  if (response.status === 204) {
    return null;
  }

  return response.json();
}

This is a simple starting point. Production clients may also need authentication handling, structured error parsing, request IDs, timeouts, and special handling for file uploads. Do not set Content-Type: application/json for FormData; let the browser set its multipart boundary.

14.3 Create feature-specific API functions

// features/users/services/userService.js
import { apiRequest } from "../../../services/apiClient";

export function getUsers({ signal } = {}) {
  return apiRequest("/users", { signal });
}

export function getUserById(id, { signal } = {}) {
  return apiRequest(`/users/${id}`, { signal });
}

export function createUser(payload) {
  return apiRequest("/users", {
    method: "POST",
    body: JSON.stringify(payload),
  });
}

14.4 Bad: API calls scattered through UI components

function UsersPage() {
  async function handleClick() {
    const response = await fetch("https://api.example.com/users");
    const data = await response.json();
    // More request and transformation logic here
  }

  return <button onClick={handleClick}>Load</button>;
}

This becomes difficult to reuse, test, and maintain when many components repeat the same URL, headers, and error handling.

14.5 Good: service layer plus a Hook

// features/users/hooks/useUsers.js
import { useEffect, useState } from "react";
import { getUsers } from "../services/userService";

export function useUsers() {
  const [users, setUsers] = useState([]);
  const [status, setStatus] = useState("loading");
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUsers() {
      setStatus("loading");
      setError(null);

      try {
        const data = await getUsers({
          signal: controller.signal,
        });

        setUsers(data);
        setStatus("success");
      } catch (err) {
        if (err.name === "AbortError") return;

        setError(err);
        setStatus("error");
      }
    }

    loadUsers();

    return () => controller.abort();
  }, []);

  return { users, status, error };
}

Component:

function UsersPage() {
  const { users, status, error } = useUsers();

  if (status === "loading") return <p>Loading users...</p>;
  if (status === "error") return <p>{error.message}</p>;
  if (users.length === 0) return <p>No users found.</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

This example is suitable for a straightforward request. If the application needs caching, request deduplication, background refetching, pagination, or cache invalidation, use a data-fetching library instead of building all of that yourself.

14.6 When to use TanStack Query or RTK Query

Consider a server-state library when you need: - Caching and request deduplication. - Background refetching and stale-data handling. - Cache invalidation after mutations. - Pagination or infinite queries. - Consistent loading and error states. - Avoiding repeated manual request logic.

Useful resources: - TanStack Query: https://tanstack.com/query/latest - RTK Query: https://redux-toolkit.js.org/rtk-query/overview

14.7 API call rules

  • Check response.ok when using fetch; fetch does not reject just because the server returns 404 or 500.
  • Handle network errors and server errors.
  • Cancel obsolete requests when appropriate.
  • Do not expose secret API keys in frontend environment variables.
  • Do not assume the response always contains valid data.
  • Avoid triggering the same request from multiple unrelated components when a shared query cache is more suitable.
  • Keep request and response types/contracts clear.

15. Loading, error, empty, and success states

Every API-driven screen should deliberately handle each state.

State What to show


Loading Spinner, skeleton, or progress message Success with data Actual content Success with no data Helpful empty state Error Clear message and retry option where useful

Bad

return <div>{users.map((user) => user.name)}</div>;

This may fail if users is undefined and gives no feedback while the request is running or fails.

Good

if (status === "loading") {
  return <p>Loading...</p>;
}

if (status === "error") {
  return (
    <div role="alert">
      <p>Unable to load users.</p>
      <button onClick={refetch}>Try again</button>
    </div>
  );
}

if (users.length === 0) {
  return <p>No users are available yet.</p>;
}

return (
  <ul>
    {users.map((user) => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
);

Do not show raw stack traces or internal server details to end users. Log useful diagnostic details through an approved logging system.


16. Forms and validation

16.1 Use controlled inputs when React state should own the value

function NameForm() {
  const [name, setName] = useState("");

  function handleSubmit(event) {
    event.preventDefault();
    console.log(name);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input
        id="name"
        value={name}
        onChange={(event) => setName(event.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

16.2 Avoid unnecessary state for every derived form value

Calculate simple values from the current form state rather than maintaining duplicate values.

16.3 Use form libraries for complex forms

Useful options: - React Hook Form: https://react-hook-form.com/ - Zod: https://zod.dev/ - Yup: https://github.com/jquense/yup

Use a schema validation library when forms have multiple fields, conditional rules, nested data, or shared validation requirements.

16.4 Validate on both client and server

Client-side validation improves the user experience. Backend validation is still required because client code can be bypassed.

16.5 Handle submission state

Prevent accidental duplicate submissions, show progress, display field-level errors where possible, and provide a clear success result.


17. Rendering and performance

17.1 Use stable keys

Bad

users.map((user, index) => (
  <UserCard key={index} user={user} />
))

Good

users.map((user) => (
  <UserCard key={user.id} user={user} />
))

Use a stable unique ID from the data. Index keys are only reasonable for static lists that never reorder, insert, or delete items.

17.2 Avoid unnecessary re-renders

  • Keep state close to the components that use it.
  • Avoid changing Context values unnecessarily.
  • Avoid recreating large derived data without need.
  • Use memoization when profiling shows it helps.
  • Split large components when it improves ownership and rendering boundaries.

17.3 Lazy-load route-level components

import { lazy, Suspense } from "react";

const ReportsPage = lazy(() => import("./pages/ReportsPage"));

function App() {
  return (
    <Suspense fallback={<p>Loading page...</p>}>
      <ReportsPage />
    </Suspense>
  );
}

Use a router's route-level lazy-loading support where available.

17.4 Virtualize large lists

For thousands of rows, rendering every row can be expensive. Use pagination or a virtualization library such as: - TanStack Virtual: https://tanstack.com/virtual/latest

17.5 Optimize assets

  • Compress images.
  • Prefer modern formats such as WebP or AVIF where suitable.
  • Lazy-load offscreen images.
  • Set image width and height to reduce layout shifts.
  • Avoid shipping large assets that are not needed on the current page.

17.6 Measure before optimizing

Use React Developer Tools Profiler and browser performance tools to identify actual bottlenecks. Avoid adding memo, useMemo, and useCallback everywhere.


18. Routing and code splitting

For multi-page applications, use a routing library such as React Router.

Good routing practices: - Keep route definitions in a clear location. - Use layouts for shared navigation and page structure. - Handle unknown routes with a not-found page. - Protect private routes based on authentication and authorization checks. - Lazy-load large route components where appropriate. - Do not treat hiding a frontend route as backend authorization; protect API endpoints on the server too.

React Router documentation: https://reactrouter.com/


19. Error boundaries and error handling

19.1 Use error boundaries for render errors

Error boundaries catch errors thrown while rendering descendant components and show a fallback UI. They do not automatically catch errors in event handlers or asynchronous callbacks.

A class-based boundary remains a common implementation:

import { Component } from "react";

class ErrorBoundary extends Component {
  state = { hasError: false };

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

  componentDidCatch(error, info) {
    // Send diagnostic details to an approved logging service.
    console.error(error, info);
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong.</h2>;
    }

    return this.props.children;
  }
}

Usage:

<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

Place boundaries around meaningful parts of the application so one broken area does not necessarily take down the entire UI.

19.2 Handle event and API errors explicitly

async function handleSave() {
  try {
    setStatus("loading");
    await saveUser(formData);
    setStatus("success");
  } catch (error) {
    setStatus("error");
    setErrorMessage("Unable to save your changes. Please try again.");
  }
}

Do not assume an error boundary will catch this asynchronous failure. Handle it in the request or event flow.

19.3 Do not swallow errors

Bad:

try {
  await saveUser();
} catch (error) {
  // Nothing happens
}

Good:

try {
  await saveUser();
} catch (error) {
  setErrorMessage("Save failed. Please try again.");
  reportError(error);
}

Use an appropriate error-reporting mechanism for production; avoid logging sensitive data.


20. Security and accessibility

20.1 Security

  • Never put private API secrets in frontend code or frontend environment variables.
  • Validate and authorize sensitive operations on the backend.
  • Treat API responses and user input as untrusted.
  • Avoid dangerouslySetInnerHTML unless HTML rendering is genuinely required and the content is sanitized with a trusted sanitizer.
  • Use secure authentication and session practices appropriate to the application.
  • Avoid putting sensitive information in URLs, logs, or browser storage.
  • Keep dependencies updated and review security advisories.

Bad: rendering untrusted HTML

<div dangerouslySetInnerHTML={{ __html: userProvidedHtml }} />

Better: render text normally

<p>{userProvidedText}</p>

If rich HTML is a requirement, sanitize it using a trusted library and a carefully defined policy.

20.2 Accessibility

Use semantic HTML:

<button type="button" onClick={handleSave}>
  Save
</button>

Avoid using a clickable div when a button is appropriate.

Also: - Associate labels with form fields. - Provide useful alternative text for meaningful images. - Ensure keyboard navigation works. - Use visible focus styles. - Use aria-live or suitable status semantics for important dynamic feedback. - Give dialogs correct focus management and keyboard behaviour. - Do not rely on colour alone to communicate status.


21. Testing

Test behaviour that matters to users, not just implementation details.

21.1 Useful tools

21.2 Component test example

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";

test("increments the counter when clicked", async () => {
  const user = userEvent.setup();

  render(<Counter />);

  await user.click(
    screen.getByRole("button", { name: /count/i })
  );

  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

21.3 What to test

  • Important user interactions.
  • Form validation and submission.
  • Loading, error, empty, and success states.
  • Conditional rendering.
  • Reducer transitions and utility functions.
  • Critical end-to-end flows such as login, checkout, and record creation.

Avoid tests that depend heavily on internal component state or exact implementation details. Prefer queries by role and accessible name.


22. Code quality and maintainability

22.1 Use ESLint

ESLint catches potential bugs and inconsistent patterns. For React projects, configure the React and Hooks rules and follow the project's framework recommendations.

22.2 Use Prettier

Prettier formats code consistently. Agree on formatting rules and run formatting automatically where possible.

22.3 Use clear names

Bad:

const d = users.filter((x) => x.a);

Good:

const activeUsers = users.filter((user) => user.isActive);

22.4 Avoid magic values

Bad:

if (user.role === 3) {
  // ...
}

Good:

const USER_ROLES = {
  ADMIN: "admin",
  MEMBER: "member",
};

if (user.role === USER_ROLES.ADMIN) {
  // ...
}

Use constants when they make meaning clearer; do not create a constants file for every literal.

22.5 Avoid duplicate logic

If multiple features repeat the same meaningful business rule, extract it into a utility, custom Hook, or service at the appropriate level.

22.6 Keep dependencies intentional

Before installing a package: - Check whether the platform or React already provides the feature. - Check maintenance activity and compatibility. - Check package size and security. - Avoid adding a library for a trivial one-line task.


23. Example feature structure

Here is a practical structure for a Users feature in a medium-sized application:

src/
├── app/
│   ├── App.jsx
│   ├── routes.jsx
│   └── store.js
│
├── features/
│   └── users/
│       ├── components/
│       │   ├── UserCard.jsx
│       │   ├── UserFilters.jsx
│       │   └── UserForm.jsx
│       │
│       ├── hooks/
│       │   └── useUsers.js
│       │
│       ├── services/
│       │   └── userService.js
│       │
│       ├── utils/
│       │   └── userHelpers.js
│       │
│       ├── UsersPage.jsx
│       └── UserDetailsPage.jsx
│
├── shared/
│   ├── components/
│   │   ├── Button.jsx
│   │   ├── Modal.jsx
│   │   └── Spinner.jsx
│   ├── hooks/
│   └── utils/
│
├── services/
│   └── apiClient.js
│
├── assets/
├── styles/
└── main.jsx

Responsibility of each area

Folder Responsibility


app/ Application setup, routes, providers, store features/ Feature-specific pages, components, Hooks, services shared/components/ Reusable UI components shared/hooks/ Reusable cross-feature Hooks shared/utils/ General helper functions services/ Shared API client and network configuration assets/ Images, icons, fonts styles/ Global styles and design tokens

Do not force every feature to have every folder. Add files and folders as the feature needs them.


24. Code review checklist

Use this checklist before raising a pull request or completing a feature.

Components and structure

  • Components have clear responsibilities.
  • Large components are split where it improves clarity.
  • Component and file names follow the project convention.
  • Feature-specific code is grouped together.
  • Reusable components are genuinely reusable.

State and Hooks

  • State is kept as local as possible.
  • Derived values are calculated instead of duplicated in state.
  • State arrays and objects are updated immutably.
  • Functional updates are used when the next value depends on the previous value.
  • Hooks are called only at the top level.
  • useEffect is used for external synchronization, not routine calculations.
  • Effects have correct dependencies and cleanup where required.
  • Memoization is used only when useful.

Context and state management

  • Context is used only for values needed by multiple descendants.
  • Context providers are not overloaded with unrelated state.
  • useReducer is used when transitions are complex enough to justify it.
  • Redux Toolkit is used only when shared client-state needs justify a store.
  • Server data is handled with an appropriate fetching and caching strategy.

API and error handling

  • API calls are separated into service or query modules.
  • HTTP error responses are checked.
  • Loading, error, empty, and success states are handled.
  • Obsolete requests are cancelled or safely ignored where needed.
  • Errors are not silently swallowed.
  • No private API secrets are exposed in frontend code.

UI, performance, and quality

  • Lists use stable keys.
  • Forms have labels and validation.
  • Interactive elements are keyboard accessible.
  • Large lists use pagination or virtualization when needed.
  • Images and route bundles are optimized where appropriate.
  • Important behaviour has tests.
  • ESLint and formatting checks pass.
  • No unnecessary dependencies or dead code are introduced.

25. Useful official resources

Use the official documentation as the source of truth, especially because React APIs and recommended patterns evolve.

React fundamentals and Hooks

State management

Quality and testing


Final guidance

Do not try to introduce every React tool into every project.

A sensible starting point for most applications is:

  1. Function components with clear responsibilities.
  2. useState for simple local state.
  3. useEffect only for synchronization with external systems.
  4. Custom Hooks for reusable behaviour.
  5. Context for a small number of shared values.
  6. useReducer for complex local state transitions.
  7. Redux Toolkit when shared client-state complexity justifies it.
  8. TanStack Query or RTK Query for server-state fetching and caching.
  9. A feature-based folder structure as the application grows.
  10. Tests, accessibility, and error handling as part of normal development---not as last-minute additions.

No comments yet. Be the first to comment.