Table of Contents
- Introduction to Clean Code in Frontend Development
- Single Responsibility Principle (SRP) in React Components
- Avoid Over-Rendering with Memoization
- Clean Code Practices for Hooks
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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>;
};
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>;
};
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} />;
};
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} />;
};
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
-
React.memo
: Wraps a component and prevents re-rendering unless its props change. -
useMemo
: Memoizes a computed value, avoiding expensive recalculations on every render. -
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>
);
};
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>
);
};
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>
);
};
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;
};
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;
};
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);
});
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)