Samuel Fajreldines

I am a specialist in the entire JavaScript and TypeScript ecosystem.

I am expert in AI and in creating AI integrated solutions.

I am expert in DevOps and Serverless Architecture

I am expert in PHP and its frameworks.

+55 (51) 99226-5039 samuelfajreldines@gmail.com

How to Split React Components for Easy Unit Testing

Building scalable React applications often involves managing user interfaces with components that can grow complex over time. When a component spans hundreds of lines, it becomes more challenging to understand, maintain, and unit test. Splitting large components into smaller, focused units is one of the most effective ways to boost maintainability and ensure higher test coverage. Below is a deep dive into why modularizing React components is crucial, how to implement it, and what best practices can make your code more testable in real-world scenarios.

Why Splitting Components Matters

Large, monolithic components can become a tangled mess. Functionality, rendering logic, and state management may all intertwine, creating a codebase that is brittle and difficult to test. Unit tests often fail to isolate specific functionality because too many concerns coexist in one place. By splitting components, each piece of functionality stands alone, minimizing side effects and making it straightforward to write robust, isolated tests.

Benefits of Modular Components

  1. Maintainability: Changes can be isolated to smaller sections of the code, minimizing the risk of unexpected side effects.
  2. Reusability: Smaller components focused on single responsibilities are more likely to be reused in different parts of the application.
  3. Enhanced Testability: Isolated logic becomes simpler to mock or spy on in unit tests, significantly increasing coverage and reliability.
  4. Clarity: A smaller component allows developers to see exactly what is happening within a piece of the UI, making the code self-documenting.

From a broader software engineering perspective, the skill to decompose large pieces of logic into smaller, testable parts is fundamental in various contexts, whether working with JavaScript, TypeScript, PHP, or even across DevOps pipelines. The same principles—simplicity, single responsibility, and clear boundaries—apply to serverless functions, containerized deployments, and backend services.

Identifying When to Split a Component

Not all larger components warrant an immediate split; however, there are red flags that signal you should consider refactoring:

  • Multiple State Variables: If a component holds a wide range of states that don't directly relate to each other, it's a clue that more than one responsibility is at play.
  • Excessive Lines of Code: Although there is no strict rule on line count, components hitting several hundred lines are prime candidates for decomposition.
  • Complex Conditional Rendering: Nested conditionals and complicated logic can make a component difficult to comprehend. Splitting out specialized subcomponents often alleviates the confusion.
  • Difficult Testing: If unit tests require numerous mocks, stubs, and setup just to verify a simple feature, that's a strong indication the component is doing too much.

Strategy #1: Container and Presentational Components

A well-known approach to simplifying React code is following the container and presentational component pattern. This is especially effective when the application relies heavily on state management libraries such as Redux.

  • Container Component: Focuses on data fetching, state management, and passing data to child components. Usually, these components do not contain elaborate UI elements or styles.
  • Presentational Component: Dedicated to rendering. Receives data and callbacks via props and focuses on HTML and CSS.

By separating these concerns, each component is straightforward to test. Presentational components usually require minimal mocking, as they only render data passed through props. Container components, on the other hand, can be tested in isolation to ensure they fetch and manage data correctly. Testing responsibilities shrink dramatically when each of these parts is wholly responsible for a specific task.

Example

// ContainerComponent.js
import React, { useEffect, useState } from 'react';
import PresentationalComponent from './PresentationalComponent';
import axios from 'axios';

const ContainerComponent = () => {
  const [items, setItems] = useState([]);

  useEffect(() => {
    axios.get('/api/items')
      .then(response => setItems(response.data))
      .catch(error => console.error(error));
  }, []);

  return <PresentationalComponent items={items} />;
};

export default ContainerComponent;
// PresentationalComponent.js
import React from 'react';

const PresentationalComponent = ({ items }) => {
  if (!items.length) {
    return <div>No items available</div>;
  }
  
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
};

export default PresentationalComponent;

Testing Example

For the presentational component, a unit test can verify how it renders the list:

// PresentationalComponent.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import PresentationalComponent from './PresentationalComponent';

test('renders list of items', () => {
  const mockItems = [{ id: 1, name: 'Test Item' }];
  render(<PresentationalComponent items={mockItems} />);
  expect(screen.getByText('Test Item')).toBeInTheDocument();
});

Testing is streamlined because there’s no state or side effects to simulate. The container component test can focus on ensuring data is fetched and passed to the child properly, typically by mocking the network request.

Strategy #2: Composing Components Based on Responsibility

Another approach involves dissecting a large component by the user flows and responsibilities. For example, a user profile page might contain multiple features—profile information, settings, and a list of recent activities. Each feature can live in its own component, each responsible for rendering or manipulating a single slice of data.

When building large React applications in either JavaScript or TypeScript, it is common to combine multiple frameworks or libraries for tasks like form handling, routing, and global state management. Even in full-stack scenarios—using frameworks like Laravel or CodeIgniter on the back end—maintaining clear boundaries in the front-end leads to code that is more maintainable and test-friendly across the stack.

Steps to Break Down by Responsibility

  1. Identify Logical Boundaries: Determine which sections of the UI belong together conceptually (e.g., a settings panel, a user profile card, a notifications list).
  2. Create Dedicated Components: Each logical boundary becomes a small, independent component.
  3. Extract Shared Logic: If several sections share logic (like data fetching for different endpoints), consider extracting that into a custom hook or a higher-order component.
  4. Write Dedicated Tests: Unit tests focus on validating each boundary separately, rather than verifying the entire page at once.

Strategy #3: Custom Hooks

In React, custom hooks are often used to share logic between functional components. If a large component merges complex rendering with data manipulation or side effects, extracting the logic into custom hooks simplifies both your component and your testing strategy. When the complexity of state transitions, data fetching, or authentication balloons, custom hooks can keep the primary component clean.

Hook Example

Consider a component that fetches user data, manages loading states, and handles potential errors. Splitting this into a hook builds a separation:

// useUserData.js
import { useState, useEffect } from 'react';
import axios from 'axios';

export const useUserData = (userId) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;
    axios.get(`/api/user/${userId}`)
      .then(response => {
        if (isMounted) {
          setData(response.data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (isMounted) {
          setError(err);
          setLoading(false);
        }
      });
    return () => {
      isMounted = false;
    };
  }, [userId]);

  return { data, loading, error };
};
// UserProfile.js
import React from 'react';
import { useUserData } from './useUserData';

const UserProfile = ({ userId }) => {
  const { data, loading, error } = useUserData(userId);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;
  return <div>{data.name}</div>;
};

export default UserProfile;

Testing the Hook

Since custom hooks are pure functions that rely on React’s life cycle, they can be tested using specialized test utilities like React Testing Library’s renderHook. By isolating the logic for fetching user data, the test can focus solely on the hook:

// useUserData.test.js
import { renderHook } from '@testing-library/react-hooks';
import { useUserData } from './useUserData';
import axios from 'axios';

jest.mock('axios');

test('returns user data after fetching', async () => {
  const mockData = { name: 'Alice' };
  axios.get.mockResolvedValueOnce({ data: mockData });

  const { result, waitForNextUpdate } = renderHook(() => useUserData(1));
  expect(result.current.loading).toBe(true);

  await waitForNextUpdate();
  
  expect(result.current.data).toEqual(mockData);
  expect(result.current.loading).toBe(false);
  expect(result.current.error).toBeNull();
});

This level of isolation wouldn’t be possible if everything lived in one large component. By decoupling rendering from data-fetching logic, each part can be tested separately.

Strategy #4: Leveraging TypeScript for Better Structure

Using TypeScript in React can make component splitting even more robust. Strong typing ensures each subcomponent adheres to a defined contract, minimizing the chances of introducing bugs when refactoring. When each smaller component specifies explicit prop types, the application becomes easier to scale without losing track of data relationships or possible null references.

Tips for TypeScript Integration

  1. Define Interfaces or Types for Props: Always be explicit about what props a component expects.
  2. Use Utility Types: TypeScript provides Partial, Pick, and Omit to shape component props as needed.
  3. Separate Type Declarations: Store types in dedicated files or directories for clarity, especially if they’re used across multiple components.
  4. Combine with ESLint and Prettier: A consistent linting and formatting setup ensures that smaller TypeScript components remain legible and bug-free.

Practical Considerations for DevOps and Serverless Deployments

Though component splitting is often framed in purely front-end terms, it has broader implications for overall software engineering workflows, including DevOps and serverless environments. Clear, testable React components assist continuous integration pipelines in two ways:

  1. Faster Test Runs: Smaller, focused tests reduce runtime in environments like GitLab CI, Jenkins, or GitHub Actions.
  2. More Reliable Deployments: Clear test feedback loops minimize the risk of deploying broken code to AWS Lambda, Google Cloud Functions, or Azure Functions front ends.

Modular front-end code can seamlessly integrate into microservices or serverless architectures where each function or service handles a specific responsibility. When the front end is equally modular, the entire application stack benefits from simpler debugging and more predictable release cycles.

Strategy #5: Code Reviews and Automated Tooling

Refactoring large React components into smaller, testable units can turn into a continuous practice. Automated tooling and best-practice guidelines make it easier to maintain standards across the team:

  • Linting Rules: Tools like ESLint can flag components exceeding a specified number of lines or complexity, prompting refactoring.
  • Code Reviews: Encourage peer reviews focusing on component size, clarity, and testability.
  • Continuous Integration: Automate unit tests for each pull request to ensure new features don’t balloon components unnecessarily.

Over time, these automated and collaborative practices lead to a front-end architecture that remains clean, scalable, and easy to test.

Putting It All Together

Splitting React components is about more than just code aesthetics: it fundamentally improves maintainability, reusability, and testability. By leveraging container and presentational components, composing by responsibility, creating custom hooks, and using TypeScript, a React codebase can remain flexible and reliable. This approach extends beyond simple UI considerations, tying into larger engineering practices like DevOps, microservices, and serverless deployments, which all demand modularity and clarity.

Adopting these strategies involves an initial investment of time to refactor and set up the necessary tests. However, the payoff is well worth it: fewer bugs escape into production, new team members ramp up more quickly, and continuous integration pipelines run more smoothly. With a well-structured React application, the testing process becomes an asset rather than a chore, ultimately accelerating development cycles across the entire project.

A disciplined commitment to splitting components and writing high-quality unit tests supports more predictable, maintainable software. Whether dealing with TypeScript or JavaScript frameworks, whether deploying on AWS, Google Cloud, or Azure, the fundamental principle holds: keep components small, focused, and testable. Following this principle consistently paves the way for seamless scaling and a more confident development process.


Resume

Experience

  • SecurityScoreCard

    Nov. 2023 - Present

    New York, United States

    Senior Software Engineer

    I joined SecurityScorecard, a leading organization with over 400 employees, as a Senior Full Stack Software Engineer. My role spans across developing new systems, maintaining and refactoring legacy solutions, and ensuring they meet the company's high standards of performance, scalability, and reliability.

    I work across the entire stack, contributing to both frontend and backend development while also collaborating directly on infrastructure-related tasks, leveraging cloud computing technologies to optimize and scale our systems. This broad scope of responsibilities allows me to ensure seamless integration between user-facing applications and underlying systems architecture.

    Additionally, I collaborate closely with diverse teams across the organization, aligning technical implementation with strategic business objectives. Through my work, I aim to deliver innovative and robust solutions that enhance SecurityScorecard's offerings and support its mission to provide world-class cybersecurity insights.

    Technologies Used:

    Node.js Terraform React Typescript AWS Playwright and Cypress