Building React Components For Enterprise Projects

Cover Image for Building React Components For Enterprise Projects
Christopher C
Christopher C

Building enterprise react components can be challenging but the added layer of robust logic will make developing your application much easier. By implementing these topics, you can expect better developer and user experience.

Accessibility

User interfaces should be catered to your targeted audience. If you are developing a sensitive application (e.g. healthcare, banking), depending on your location, it may be required by law to build your software to be accessible to those with disabilities

Read about the A11y accessibility standards here. https://www.a11yproject.com/

  • Use semantic html (<nav>, <button>, <form>, <input>, <table>)
  • Add aria attributes
  • Ensure Keyboard Accessibility
  • Handle Focus Management
  • Provide Clear and Concise Text
  • Test with Screen Readers
  • Use Accessible Component Libraries

The best way to build components is to begin with writing tests right away as you build the application. Making the components code as small as possible will make it much easier to scale and test due to fewer dependencies and side effects. You can do this by decoupling.

Decoupling

Decoupling refers to the process of reducing or eliminating dependencies between different components, modules, or subsystems of a software system. It involves designing and organizing the code in a way that minimizes the interdependence between various parts of the system.

Decoupling is an important principle in software development as it promotes modularity, flexibility, and maintainability. By reducing dependencies, changes to one component are less likely to have a ripple effect on other components, making the system more robust and easier to evolve.

import React from 'react';

interface Props {
  name: string;
  age: number;
}

const TestableComponent: React.FC<Props> = ({ name, age }) => {
  const getGreeting = (): string => {
    if (age >= 18) {
      return `Hello, ${name}! You're an adult.`;
    } else {
      return `Hello, ${name}! You're a minor.`;
    }
  };

  return <div>{getGreeting()}</div>;
};

export default TestableComponent;

vs less testable.

import React, { useEffect, useState } from 'react';

const UntestableComponent: React.FC = () => {
  const [data, setData] = useState<string>('');

  useEffect(() => {
    fetchData();
  }, []);

  const fetchData = async () => {
    try {
      // Make an API call to fetch some data
      const response = await fetch('https://example.com/api/data');
      const data = await response.json();
      setData(data);
    } catch (error) {
      console.error('Error fetching data:', error);
    }
  };

  return <div>{data}</div>;
};

export default UntestableComponent;

Example one has no side effects and depends on props only. Example two makes an API call that may return unexpected values or perform actions that the component is unable to handle.

What you can do instead is place the function into its own module and then import the function into the component.

Here's an example

// utils.js
export const calculateTotal = items  => 
  return items.reduce((total, item) => total + item.price * item.quantity, 0);


// ShoppingCart.js
import React from 'react';
import { calculateTotal } from './utils';

const ShoppingCart = ({ items }) => {
  const total = calculateTotal(items);

  return (
    <div>
      <h2>Shopping Cart</h2>
      <ul>
        {items.map((item, index) => (
          <li key={index}>
            {item.name} - ${item.price} x {item.quantity}
          </li>
        ))}
      </ul>
      <p>Total: ${total}</p>
    </div>
  );
}

export default ShoppingCart;

Performance Optimization

Mounting check

Using use effect, you can check to make sure component is mounted before re-rendering data. This allows for React to check if that data has changed and to prevent a function from running when it doesn't have to .

import {useEffect, useRef} from 'react'

// inside component
const mounted = useRef(false)

useEffect(() => {
	mounted.current = true
	
	if (mounted.current) {
		// execute business logic here	
	}
	return () => mounted.current = false
},[' any needed dependencies'])

useMemo

Wrap components with React.memo to memoize them and prevent re-rendering. Utilize the useMemo hook to memoize expensive computations or values to avoid recalculating them on every render.

import React, { useMemo } from 'react';

const MyComponent = () => {
  // Expensive function that is memoized
  const expensiveFunction = useMemo(() => {
    // Perform expensive computations or operations here
    let result = 0;
    for (let i = 0; i < 1000000000; i++) {
      result += i;
    }
    return result;
  }, []); // Empty dependency array to run the computation only once

  return (
    <div>
      <h1>My Component</h1>
      <p>Expensive Computation Result: {expensiveFunction}</p>
    </div>
  );
};

export default MyComponent;

Memoizing is a great performance tool that can be used with useCallback to prevent child components from re-rendering if the props haven't changed.

Unit Testing

Organize tests

Create a separate folder (e.g., __tests__) within each component directory to store test files. Use a naming convention like ComponentName.test.tsx to make it easier to locate tests.

Test file placement

Place your test files alongside the component they are testing. This allows for better organization and makes it easier to find associated tests.

Use meaningful test descriptions

Write descriptive test descriptions that explain the behavior being tested. This makes it easier to understand the purpose of the test, especially when the test suite grows larger.

Arrange, Act, Assert (AAA) pattern

Structure your tests using the AAA pattern. The "Arrange" section sets up the test environment, the "Act" section performs the action being tested, and the "Assert" section verifies the expected outcome.

Mock dependencies

Use Jest's mocking capabilities to isolate the component under test from its dependencies. This ensures that tests focus on the specific behavior of the component and are not affected by external factors.

Use TypeScript types

Leverage TypeScript's type-checking in your tests to catch potential errors early. Ensure that your test files have access to the necessary types and interfaces to properly check the component's behavior.

Test both positive and negative scenarios

Test both expected (positive) and unexpected (negative) scenarios to ensure that the component handles edge cases correctly. This helps uncover bugs and improves overall code reliability.

Use beforeEach and afterEach

Utilize Jest's beforeEach and afterEach functions to set up and clean up any common test dependencies or state. This promotes a clean test environment and reduces code duplication.

Test user interactions

Simulate user interactions such as clicks, input changes, or form submissions to test how the component responds. Use tools like Jest's simulate or React Testing Library's utilities (fireEvent, userEvent) to trigger these interactions.

Test async code

When testing asynchronous operations like API requests or promises, use Jest's async/await syntax or .resolves / .rejects matches to ensure that the asynchronous code behaves as expected.

Snapshot testing

Take advantage of Jest's snapshot testing feature to capture and compare the rendered output of your components. Snapshots help detect unintended changes to the UI and make it easier to identify regressions.

Run tests in watch mode

Use Jest's watch mode during development to automatically rerun tests when file changes are detected. This speeds up the feedback loop and allows you to catch issues quickly.

Elastic testing

Your tests should be elastic, meaning that if your layout changes, it won't affect the value of a unit test. Here's an example showing the wrong and correct way to write an elastic unit test.

Examples

/* Incorrect, what if my link is not at index 1? */
const myLink = component.children[1]
expect(myLink.src).to.beEqual('https://example.com')

/* Correct, because you are selecting 
the element based on a unique identifier */

const myLink = getByText('click me!', component) as HTMLAnchorElement;
expect(myLink.src).to.beEqual('https://example.com')

Preventing Race Conditions

Preventing race conditions in React can be challenging but not impossible. Here are some approaches you can take to mitigate race conditions:

Use useEffect Cleanup: Make sure to clean up any pending asynchronous tasks or event listeners when a component is unmounted or the dependencies of useEffect change. This helps avoid situations where a component sets state or performs an action after it has been unmounted.

useEffect(() => {
  const fetchData = async () => {
    // Perform asynchronous task
  };

  fetchData();

  return () => {
    // Cleanup any pending tasks or event listeners
  };
}, [/* dependencies */]);

Cancel or Ignore Stale Requests

When making asynchronous requests, it's important to handle scenarios where the response of a previous request may arrive after subsequent requests have been made. One way to address this is by canceling or ignoring stale requests. You can achieve this by using libraries or techniques that support request cancellation, such as Axios with cancellation tokens or the AbortController API.

Use Debouncing or Throttling

When dealing with events that trigger frequent updates, like window resizing or input typing, consider using debouncing or throttling techniques to limit the number of function invocations. Debouncing ensures that the function is executed after a certain period of inactivity, while throttling limits the number of function calls within a specific time interval. These techniques help reduce unnecessary updates and can prevent race conditions caused by rapid consecutive events.

Sequential State Updates

If you have multiple state updates that depend on the previous state, use the functional form of setState to ensure sequential updates. This guarantees that each state update relies on the latest state value, avoiding race conditions.

setState(prevState => {
  // Compute new state based on previous state
  return /* updated state */;
});
  1. Use Locking Mechanisms: You can implement locking mechanisms to prevent multiple asynchronous actions from executing simultaneously. This can be achieved using flags or locks that indicate whether a particular action is already in progress. By checking the lock before executing an action, you can prevent race conditions caused by concurrent requests or actions.
const [isFetching, setIsFetching] = useState(false);

const fetchData = async () => {
  if (isFetching) {
    return; // Already fetching, ignore the request
  }

  setIsFetching(true);

  try {
    // Perform asynchronous task
  } finally {
    setIsFetching(false);
  }
};

By employing these strategies, you can minimize the occurrence of race conditions and improve the stability and reliability of your React applications. However, it's important to carefully analyze your specific use cases and implement the appropriate approach accordingly.

Creating Custom Hooks

A custom hook is a reusable function in React that allows you to extract component logic into a separate function, enabling you to reuse that logic across multiple components. Custom hooks are created by prefixing the function name with the word "use" (as per the React convention) and can be used just like any other React built-in hooks.

Here are some reasons why you should consider using custom hooks:

Reusability

Custom hooks promote code reuse by allowing you to extract and share logic across multiple components. This can help reduce code duplication and improve maintainability.

Logic encapsulation

By encapsulating certain logic within a custom hook, you can separate concerns and make your components more focused on rendering and UI-related tasks.

Stateful logic

Custom hooks can encapsulate complex stateful logic, allowing you to handle side effects, manage state, and perform computations within the custom hook itself, making your components cleaner and easier to understand.

Now, let's look at a simple example of a custom hook using React and TypeScript:

import { useState, useEffect } from 'react';

// Custom hook for fetching data
const useFetchData = (url: string) => {
  const [data, setData] = useState<any>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const json = await response.json();
        setData(json);
      } catch (error) {
        setError('An error occurred while fetching the data.');
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
};

// Usage of the custom hook
const MyComponent = () => {
  const { data, loading, error } = useFetchData('https://api.example.com/data');

  if (loading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <div>
      {/* Render the fetched data */}
    </div>
  );
};

In this example, the useFetchData custom hook encapsulates the logic for fetching data from an API. It utilizes the useState and useEffect hooks to manage the data, loading state, and error state. The custom hook is then used in the MyComponent component to fetch data and render the appropriate UI based on the loading and error states.

By using this custom hook, you can easily reuse the data-fetching logic across multiple components without duplicating code and keep your components focused on rendering the UI.

Enjoying the content?

Learn more about me and get access to exclusive content about software engineering and best business practices.

Subscribe