DEV Community

Ugo VENTURE
Ugo VENTURE

Posted on • Edited on

Part 1: Clean Code in React

Table of Contents

Introduction to Clean Code in Frontend Development

Clean code is a universal principle that transcends programming languages and paradigms, but in React, it holds particular importance due to the component-based architecture and declarative nature of the framework. React makes it easy to quickly create dynamic and interactive UIs, but without attention to clean code principles, your components can become bloated, hard to maintain, and prone to bugs.

This section focuses on how the principles of clean code, readability, simplicity and maintainability can be applied to React components. We will look at the Single Responsibility Principle (SRP), handling performance with memoization, and how to separate concerns using hooks. This will ensure that the code you write remains flexible and testable, improving overall quality of your React applications.


Single Responsibility Principle (SRP) in React Components

The Principle:
In object-oriented design, the Single Responsibility Principle (SRP) states that a class should have only one reason to change. In React, this principle applies to components: each component should ideally have one, clear responsibility. The component should focus on doing one thing well whether that's rendering UI, managing a small piece of state, or handling side effects.

Why SRP matters in React:

  • Readability: Components with a single responsibility are easier to read and understand. If a component does only one thing, it's much easier to reason about its behavior.
  • Testability: Smaller, focused components are eaasier to test in isolation. The more a component tries to do, the harder it becomes to cover all edge cases and behaviors in tests.
  • Reusability: When components focus on one thing, they are more likely to be reusable across different parts of the app.

Example - Refactoring a Bloated Component:

Let's start with a typical example where a component violates SRP by doing too much: handling state, rendering UI, and dealing with side effects like data fetching.

// BEFORE - Violates SRP
const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

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

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

This component is responsible for rendering the UI, fetching the data, and managing the loading state all of which could be separated.

Refactored Example Using SRP:

// Custom hook for fetching user data
const useUserData = (userId) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  return { user, loading };
};

// UserProfile component focused on UI rendering
const UserProfile = ({ userId }) => {
  const { user, loading } = useUserData(userId);

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

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Explanation:
Here, the responsibility of data fetching is moved to a custom hook useUserData, while the UserProfile component only focuses on rendering the UI. This makes the component easier to understand, test, and reuse. The custom hook can now also be tested in isolation.


Avoid Over-Rendering with Memoization

The problem:
One of the most common performance pitfalls in React is unnecessary re-rendering. Every time a component re-renders, its children also re-render by default, even if their props haven't changed. This can lead to performance degradation, especially in large applications or components that perform expensive calculations.

Memoization in React:
React provides several tools to help prevent over-rendering:

  • React.memo: Prevents a functional component from re-rendering if its props have not changed.
  • useMemo: Caches a computed value to avoid recalculating it on every render.
  • useCallback: Memoizes a callback function so that it is not re-created on every render.

When to Use Memoization

  • Components that receive large or complex data via props.
  • Components that rely on expensive computations.
  • Callbacks passed to child components that might cause unnecessary re-renders.

Example - Preventing Over-Rendering using React.memo:
Let's start with an example of a component that could suffer from over-rendering:

const ItemList = ({ items }) => {
  return (
    <div>
      {items.map((item) => (
        <Item key={item.id} item={item} />
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

If the parent of ItemList re-renders, even though the items array hasn't changed, each Item will still re-render.

Refactored Example using React.memo:

const Item = React.memo(({ item }) => {
  console.log("Rendering item:", item.name);
  return <div>{item.name}</div>;
});

const ItemList = ({ items }) => {
  return (
    <div>
      {items.map((item) => (
        <Item key={item.id} item={item} />
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Explanation:
By wrapping the Item component in React.memo, we ensure that it only re-renders if the item prop changes. This can be significantly improve performance, especially when rendering large lists or complex UI components.

Example - Avoiding Expensive Recalculations using useMemo:
Let’s say we have a component that computes a value based on a large dataset:

const ExpensiveComponent = ({ numbers }) => {
  const expensiveCalculation = (nums) => {
    console.log("Calculating...");
    return nums.reduce((acc, num) => acc + num, 0);
  };

  const total = expensiveCalculation(numbers);

  return <div>Total: {total}</div>;
};
Enter fullscreen mode Exit fullscreen mode

In this case, every time ExpensiveComponent renders, the expensiveCalculation function runs even if the numbers array hasn't changed. This can quickly become inefficient in performance critical applications.

Refactored Example using useMemo:

const ExpensiveComponent = ({ numbers }) => {
  const expensiveCalculation = (nums) => {
    console.log("Calculating...");
    return nums.reduce((acc, num) => acc + num, 0);
  };

  const total = useMemo(() => expensiveCalculation(numbers), [numbers]);

  return <div>Total: {total}</div>;
};
Enter fullscreen mode Exit fullscreen mode

Explanation:
In this refactored example, useMemo ensures that the expensiveCalculation function is only called when the numbers array changes. If numbers stays the same, the previously computed result is returned, avoiding the cost of re-running the calculation on every render.

Example - Avoiding Unnecessary Re-Creation of Functions using useCallback:

Let's say we have a Button component that accepts an onClick handler:

const ParentComponent = () => {
  const handleClick = () => {
    console.log("Button clicked");
  };

  return <Button onClick={handleClick} />;
};
Enter fullscreen mode Exit fullscreen mode

In this example, every time ParentComponent re-renders, the handleClick function gets recreated, which will force the Button component to re-render, even though the function behavior is the same.

Refactored Example using useCallback:

const ParentComponent = () => {
  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []);

  return <Button onClick={handleClick} />;
};
Enter fullscreen mode Exit fullscreen mode

Explanation:
With useCallback, the handleClick function is memoized and will only be re-created if its dependencies (none in this case) change. This prevents unnecessary re-renders of the Button component since the reference to the onClick function remains stable.


Recap of Memoization Techniques

  1. React.memo: Wraps a component and prevents re-rendering unless its props change.
  2. useMemo: Memoizes a computed value, avoiding expensive recalculations on every render.
  3. useCallback: Memoizes a callback function to prevent it from being recreated unnecessarily.

Clean Code Practices for Hooks

React's hooks API, introduced in React 16.8, revolutionized how developers manage state and side effects within function components. However, hooks can easily become a dumping ground for logic, leading to unmaintainable code if not used carefully.

This section will cover how to structure and encapsulate hooks, keeping them clean, readable, and testable.


1. Separation of Concerns with Custom Hooks

The Problem:
One of the most common anti-patterns in React is overloading components with both UI and logic responsibilities. When a component is responsible for rendering UI and managing complex state or side effects, it quickly becomes difficult to maintain, test, or extend.

Solution:
Custom hooks allow you to extract the logic from components and encapsulate it within reusable, isolated functions. This keeps your components clean and focused solely on rendering.

Example - Before Extracting a Custom Hook:
Let's look at an example of a component that handles both data fetching and UI rendering:

const UserList = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error loading users</div>;

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

This component handles fetching data, managing loading and error states and rendering the UI. While not overly complex, the code becomes harder to manage as the logic grows.

Refactored Example using a Custom Hook

// Custom hook for fetching data
const useUsers = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []);

  return { users, loading, error };
};

// Component focused solely on rendering
const UserList = () => {
  const { users, loading, error } = useUsers();

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error loading users</div>;

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

Explanation:
Th useUsers hook encapsulates the data-fetching logic and manages state. The UserList component is now concerned only with rendering the UI based on the current state. This separation of concerns improves readability and testability and promotes reuse across different components.

2. Resusability: Creating Parameterized Custom Hooks

When creating custom hooks, aim for reusability by parameterizing them, so they work with different data or inputs. This follows the DRY (Don't Repeat Yourself) principle by avoiding duplicating similar logic accross different parts of your app.

Example - Parameterized Custom Hook for Data Fetching:
Let's extend the previous useUsers hook to be more generic, so it can fetch any resource by URL.

const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
};

// Using the parameterized hook
const UserList = () => {
  const { data: users, loading, error } = useFetch("/api/users");

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error loading users</div>;

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

const ProductList = () => {
  const { data: products, loading, error } = useFetch("/api/products");

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error loading products</div>;

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

Explanation:
By parameterizing the useFetch hook, we've turned it into a reusable piece of logic that can be used in different components (UserList and ProductList). This reduces code duplication and keeps the app architecture cleaner.

3. Avoid Side Effects in Return Values

Custom hooks should not introduce side effects (such as triggering network requests or interacting with the DOM) in their return values. This make the look predictable and testable. Always isolate side effects inside the useEffect hook, keeping the return values pure.

Anti-Pattern - Triggering Side Effects in Return Values:

const useWindowSize = () => {
  let size = { width: window.innerWidth, height: window.innerHeight };

  window.addEventListener("resize", () => {
    size = { width: window.innerWidth, height: window.innerHeight };
  });

  return size;
};
Enter fullscreen mode Exit fullscreen mode

This example causes a side effect (addEventListener) to occur directly when the useWindowSize hook is called, which is problematic as it's not well-contained and can lead to memory leaks or unpredictable behavior.

Correct Pattern:

const useWindowSize = () => {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

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

  return size;
};
Enter fullscreen mode Exit fullscreen mode

Explanation:
The useWindowSize hook now keeps the side effects (adding and removing event listeners) inside the useEffect hook, while the return value remains pure. This makes the hook easier to reason about and test, while avoiding potential memory leaks.

4. Testing Custom Hooks

Ensuring your custom hooks are testable is crucial to maintain clean code. Hooks like useState and useEffect can complicate testing, but libraries like @testing-library/react-hooks make this straightforward.

Example - Testing a Custom Hook:

Here's how you can write a test for a custom hook like useFetch using @testing-library/react-hooks:

import { renderHook, act } from "@testing-library/react-hooks";
import { useFetch } from "./useFetch";

test("should fetch data correctly", async () => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve([{ id: 1, name: "John Doe" }]),
    })
  );

  const { result, waitForNextUpdate } = renderHook(() => useFetch("/api/users"));

  // Assert that it's loading initially
  expect(result.current.loading).toBe(true);

  // Wait for the hook to finish fetching data
  await waitForNextUpdate();

  // Assert that it fetched the data correctly
  expect(result.current.data).toEqual([{ id: 1, name: "John Doe" }]);
  expect(result.current.loading).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

Explanation:
This test verifies that the useFetch hook behaves correctly: it starts with a loading state, fetches the data, and updates the state accordingly. It uses @testing-library/react-hooks to render the hook in isolation, making the hook itself easy to test.


Key Takeways

  • Custom Hooks: Encapsulate logic inside custom hooks to maintain separation of concerns.
  • Reusability: Parameterize custom hooks to reduce duplication and maximize reusability.
  • Avoid Side Effects in Return Values: Isolate side effects within useEffect and keep return values pure.
  • Testability: Ensure custom hooks are testable in isolation using appropriate tools like @testing-library/react-hooks.

Conclusion:

Hooks are like the Swiss Army knives of React—versatile, powerful, and a little too easy to misuse if you're not careful! By crafting custom hooks, you're essentially sharpening your blade and keeping your components from getting bogged down in messy logic. When you avoid side effects in return values, you're ensuring things stay nice and predictable—no surprise explosions! And don't forget the golden rule: make those hooks reusable, because no one likes reinventing the wheel. Finally, make sure to test your hooks in isolation. After all, what's the point of all this magic if we can’t prove it works? Keep it clean, keep it sharp, and may your components live long and re-render less!

Top comments (0)