DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

React and TypeScript Best Practices: Build Reliable Applications

React and TypeScript are a powerful combination for building maintainable, scalable frontend applications. React makes it easier to create reusable user interfaces, while TypeScript catches many common mistakes before your code reaches production.

However, simply adding TypeScript to a React project does not automatically make the codebase reliable. The real benefits come from defining precise types, designing reusable components, handling asynchronous states correctly, and avoiding unnecessary complexity.

In this guide, we will explore practical best practices for React and TypeScript, using JavaScript examples to demonstrate the underlying patterns. These concepts help developers write more predictable components, simplify debugging, and build applications that remain manageable as requirements evolve.

1. Practical Best Practices for React and TypeScript

Define Explicit Types for Component Props

Well-defined component props make component contracts easier to understand and maintain. Instead of passing loosely structured objects, describe the required properties, optional values, and callback signatures using TypeScript interfaces or type aliases.

For example, a reusable button component might accept a label, a disabled state, and an event handler. Explicit types help prevent missing properties and incompatible callback arguments during development, while keeping the component's public API clear.

Avoid Using Any and Prefer Unknown for Untrusted Data

The any type disables most useful type checking, allowing mistakes to spread throughout a codebase. Prefer unknown when handling values from APIs, external libraries, or other untrusted sources, and validate those values before using them.

TypeScript types disappear at runtime, so declaring an API response as a particular interface does not verify that the server actually returned the expected structure. Runtime validation is important whenever data crosses an application boundary, especially when dealing with user input or third-party services.

Model UI States Explicitly

React applications frequently handle loading, success, empty, and error states. Representing these states explicitly prevents contradictory combinations, such as showing an error and a successful result simultaneously.

Discriminated unions are particularly useful because TypeScript can narrow the available properties based on a shared literal field. This approach makes state transitions easier to reason about and helps ensure that each UI state contains the information it actually needs.

Keep Components Small and Focused

A component should have a clear responsibility, whether that means displaying a product, handling a form, or presenting an error message. Extracting repeated behavior into custom hooks can reduce duplication without forcing every small piece of logic into a separate abstraction.

Keep business logic separate from presentation when doing so improves clarity or makes behavior easier to test. Avoid excessive abstraction, however, because a simple component does not need multiple layers of indirection to become maintainable.

Use Type-Safe Event Handlers and Immutable State Updates

React event handlers should use appropriate event types when working with inputs, forms, and other interactive elements. Functional state updates are especially useful when the next state depends on the previous state, because React can apply the update against the latest queued value.

Treat arrays and objects in state as immutable values. Create updated copies with methods such as map, filter, and object spread instead of modifying existing state directly, so React can reliably detect changes and render the correct UI.

Understand What TypeScript Cannot Guarantee

TypeScript provides compile-time checks, but it does not replace runtime validation, application testing, or accessibility checks. A correctly typed component can still contain a logic bug, render an inaccessible interface, or mishandle a failed network request.

Combine static typing with focused tests, linting, strict compiler settings, and clear error handling. A strong React and TypeScript codebase uses these tools together rather than relying on a single layer of protection.

const assert = require('node:assert/strict');

// This runnable JavaScript example demonstrates patterns that can be
// modeled with TypeScript in a real React application.
// JavaScript itself does not enforce TypeScript interfaces or unions.

console.log('Step 1: Define the application's data contract');

// In TypeScript, define this with an interface or type alias.
// interface User { id: number; name: string; email: string; }
function createUser(id, name, email) {
  return { id, name, email };
}

const initialUser = createUser(1, 'Ansh', 'ansh@example.com');
console.log('User created:', initialUser);
assert.equal(initialUser.id, 1);

console.log('\nStep 2: Validate untrusted API data at runtime');

// TypeScript annotations alone cannot validate a JSON response.
function parseUser(value) {
  if (typeof value !== 'object' || value === null) {
    throw new TypeError('User must be an object');
  }

  if (!Number.isInteger(value.id) || value.id <= 0) {
    throw new TypeError('User id must be a positive integer');
  }

  if (typeof value.name !== 'string' || value.name.trim() === '') {
    throw new TypeError('User name must be a non-empty string');
  }

  if (typeof value.email !== 'string' || !value.email.includes('@')) {
    throw new TypeError('User email must be valid');
  }

  return {
    id: value.id,
    name: value.name.trim(),
    email: value.email.trim(),
  };
}

const apiResponse = {
  id: 2,
  name: '  Priya  ',
  email: 'priya@example.com',
};

const validatedUser = parseUser(apiResponse);
console.log('Validated API user:', validatedUser);
assert.equal(validatedUser.name, 'Priya');

console.log('\nStep 3: Model UI states with a discriminated union pattern');

// A TypeScript version can use a union of loading, success, and error types.
// This JavaScript function demonstrates the same runtime branching.
function describeRequestState(state) {
  switch (state.status) {
    case 'loading':
      return 'Loading users...';
    case 'success':
      return `Loaded ${state.users.length} users.`;
    case 'error':
      return `Request failed: ${state.message}`;
    default:
      throw new Error(`Unknown status: ${state.status}`);
  }
}

const loadingState = { status: 'loading' };
const successState = {
  status: 'success',
  users: [validatedUser],
};
const errorState = {
  status: 'error',
  message: 'Unable to reach the server',
};

console.log(describeRequestState(loadingState));
console.log(describeRequestState(successState));
console.log(describeRequestState(errorState));
assert.match(describeRequestState(successState), /Loaded 1 users/);

console.log('\nStep 4: Update collections immutably');

const products = [
  { id: 1, name: 'Soap', price: 99, active: true },
  { id: 2, name: 'Shampoo', price: 199, active: true },
  { id: 3, name: 'Oil', price: 149, active: false },
];

// Never modify an existing React state array directly.
// map creates a new array and spread creates a new product object.
const updatedProducts = products.map((product) =>
  product.id === 2
    ? { ...product, price: 179 }
    : product
);

const activeProducts = updatedProducts.filter(
  (product) => product.active
);

console.log('Original shampoo price:', products[1].price);
console.log('Updated shampoo price:', updatedProducts[1].price);
console.log('Active products:', activeProducts);
assert.equal(products[1].price, 199);
assert.equal(updatedProducts[1].price, 179);

console.log('\nStep 5: Use predictable state transitions');

// This reducer can be shared by UI logic and unit tests.
function cartReducer(state, action) {
  switch (action.type) {
    case 'add':
      return {
        ...state,
        items: [...state.items, action.product],
      };
    case 'remove':
      return {
        ...state,
        items: state.items.filter(
          (item) => item.id !== action.id
        ),
      };
    default:
      throw new Error(`Unsupported action: ${action.type}`);
  }
}

const emptyCart = { items: [] };
const cartWithProduct = cartReducer(emptyCart, {
  type: 'add',
  product: { id: 10, name: 'Neem Soap', price: 99 },
});

console.log('Cart after adding product:', cartWithProduct);
console.log('Original cart remains unchanged:', emptyCart);
assert.equal(cartWithProduct.items.length, 1);
assert.equal(emptyCart.items.length, 0);

const emptyCartAgain = cartReducer(cartWithProduct, {
  type: 'remove',
  id: 10,
});

console.log('Cart after removing product:', emptyCartAgain);
assert.equal(emptyCartAgain.items.length, 0);

console.log('\nStep 6: Handle asynchronous operations safely');

// In React, manage loading and error states explicitly and consider
// AbortController or effect cleanup when a component unmounts.
async function loadUsers(fetchImplementation) {
  try {
    const response = await fetchImplementation();

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

    const payload = await response.json();

    if (!Array.isArray(payload)) {
      throw new TypeError('Expected an array of users');
    }

    return {
      status: 'success',
      users: payload.map(parseUser),
    };
  } catch (error) {
    return {
      status: 'error',
      message: error instanceof Error
        ? error.message
        : 'Unexpected error',
    };
  }
}

async function main() {
  const result = await loadUsers(async () => ({
    ok: true,
    status: 200,
    json: async () => [
      { id: 4, name: 'Riya', email: 'riya@example.com' },
    ],
  }));

  console.log('API operation result:', result);
  assert.equal(result.status, 'success');
  assert.equal(result.users.length, 1);

  const failedResult = await loadUsers(async () => ({
    ok: false,
    status: 503,
    json: async () => ({}),
  }));

  console.log('Handled API failure:', failedResult);
  assert.equal(failedResult.status, 'error');

  console.log('\nAll examples completed successfully.');
}

main().catch((error) => {
  console.error('Unexpected example failure:', error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

React and TypeScript work best when types communicate clear contracts, components have focused responsibilities, and state transitions remain predictable. Runtime validation is equally important because external data can violate the assumptions expressed by your TypeScript definitions.

Start by enabling strict TypeScript checks, typing component props and event handlers, avoiding unnecessary any types, and modeling asynchronous states explicitly. Add runtime validation where data enters the application, and test business logic independently from component rendering whenever practical.

The goal is not to add types everywhere simply to satisfy the compiler. It is to make incorrect states harder to represent, make component behavior easier to understand, and help your team deliver changes with confidence as the application grows.

Top comments (0)