Introduction: Debug, Debug Again, Debug ForeverâŚ
Hello fellow warriors! đ If youâre a React developer, youâve probably tasted the nightmare that is debugging. Code runs smoothly on your machine, but it crashes in production for no apparent reason. Or those intermittent errors that play hide-and-seek, giving you headaches and sleepless nights. Trust me, Iâve been through all those ordeals, especially in the prehistoric era of 1-2 years ago, when ChatGPT wasnât around to save us. đ
This article, the first in the Debugging React series, will be a diary of my classic debugging battles. Hopefully, it will help you, especially the newbies, avoid my mistakes and survive the bug storms.
Chapter 1: Pitfalls to Avoid When Using useEffect with Asynchronous Operations
1.1. Racing with useEffect
Before hooks, lifecycle methods in class components were the solution for side effects. But since React 16, hooks have taken over, and useEffect has become the king of side effects. However, with great power comes great responsibility (and sometimes, problems!). useEffect with asynchronous operations (like fetching data) can trap you in some nasty errors.
Classic Example:
import React, { useState, useEffect } from "react";
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
async function fetchData() {
const response = await fetch("https://api.example.com/data");
const result = await response.json();
setData(result); // The problem could be here!
}
fetchData();
}, []);
// ... render data
}
The Issues:
- Race conditions: If fetchData takes a long time, the component might unmount before the data arrives. Calling setData on an unmounted component will cause the error âCanât perform a React state update on an unmounted componentâŚâ.
- Stale closure: If the useEffect dependency array is not set up correctly, setData might âcatchâ the old state value, leading to unpredictable bugs.
Survival Solutions:
- Cleanup function: Use the cleanup function in useEffect to âclean upâ (e.g., cancel the request) when the component unmounts.
- Dependency array: âDeclareâ the exact dependencies in the array so that useEffect runs at the right time.
- useRef to the rescue: In some more complex cases, we need to use useRef to solve it.
useEffect(() => {
let isMounted = true; // Flag to check if the component is mounted
async function fetchData() {
const response = await fetch("https://api.example.com/data");
const result = await response.json();
if (isMounted) {
setData(result); // Only update state if the component is still mounted
}
}
fetchData();
// Cleanup function: Set isMounted to false when the component unmounts
return () => {
isMounted = false;
};
}, []); // Dependency still empty, the effect will run once after the component mounts
Summary:
- Cleanup Function: This technique helps avoid problems related to calling setState after a component has been destroyed.
- Dependency Array: Ensures that state or props are updated correctly later on.
1.2. âLosing Memoryâ With Asynchronous Updates
State updates in React are asynchronous. This means that after calling setState (or the setter function from useState), the state will not change immediately.
Painful Example:
function MyComponent() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // Old value, not the new one!
setCount(count + 1);
console.log(count); // Still the old value
}
//... render button with onClick={handleClick}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increase Count</button>
</div>
);
}
Solution:
- Use functional updates in setState (already mentioned in the previous section).
- Use useEffect to âtrackâ state changes if you need to perform a side effect based on the new value.
function MyComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Count is now: ", count); // Log the new value every time count changes
}, [count]); // Dependency array contains count
function handleClick() {
setCount((prevCount) => prevCount + 1); // Use functional update
setCount((prevCount) => prevCount + 1); // Reduce both to one to get +2
}
//... render button with onClick={handleClick}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increase Count</button>
</div>
);
}
Summary:
- Functional Update: This approach helps avoid issues related to retrieving the old state value.
- useEffect: This technique is useful when you need to perform side effects based on state changes.
Chapter 2: State Management - The âMessâ Called useState
2.1. âHow to Update This?â - The Pain of Updating Complex State
useState is convenient, but when the state becomes complex (nested objects, arrays), updating state can become a nightmare.
Example:
const [user, setUser] = useState({
name: "John",
address: {
street: "123 Main St",
city: "Anytown",
},
});
function updateCity(city) {
// Incorrect: No merge, will lose other address properties
// setUser({ address: { city } });
// Correct:
setUser((prevUser) => ({
...prevUser,
address: {
...prevUser.address,
city,
},
}));
}
Solution:
- Always use functional updates when the new state depends on the old state.
- Divide the state into smaller useState hooks, which are easier to manage (if possible).
- Consider using useReducer for complex state logic.
Splitting State:
const [name, setName] = useState("John");
const [address, setAddress] = useState({
street: "123 Main St",
city: "Anytown",
});
function updateCity(city) {
setAddress((prevAddress) => ({
...prevAddress,
city,
}));
}
useReducer for complex cases:
const initialState = {
user: {
name: "John",
address: {
street: "123 Main St",
city: "Anytown",
},
},
};
function reducer(state, action) {
switch (action.type) {
case "UPDATE_CITY":
return {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: action.payload,
},
},
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
function updateCity(city) {
dispatch({ type: "UPDATE_CITY", payload: city });
}
Summary:
- Merging State: Prevents losing other properties when updating the state.
- useReducer: Suitable for complex use cases, helping to manage the entire state naturally.
2.2. Prop Drilling - âTiringâ Prop âPassingâ
Prop drilling occurs when you have to pass props down multiple levels of components, even if the intermediate components donât use them. This is a common issue as applications grow.
Solutions:
- Context API: Suitable for state used in many places in the application.
- Component Composition: Passing components as children instead of passing props.
- Render Props: Passing a function as a prop to render the children, this function receives state from the parent.
A. Context API:
Helps share values between components without passing props through every level.
Example:
// UserContext.js
import React, { createContext, useState } from "react";
const UserContext = createContext();
export function UserProvider({ children }) {
const [user, setUser] = useState({ name: "John", age: 30 });
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
export default UserContext;
Usage:
// UserProfile.js
import React, { useContext } from "react";
import UserContext from "./UserContext";
function UserProfile() {
const { user, setUser } = useContext(UserContext);
return (
<div>
<h1>User Profile</h1>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
</div>
);
}
export default UserProfile;
// App.js
import React from "react";
import UserProvider from "./UserContext";
import UserProfile from "./UserProfile";
function App() {
return (
<UserProvider>
<UserProfile />
</UserProvider>
);
}
export default App;
B. Component Composition:
Pass components as children instead of passing props. This reduces the number of props that need to be passed through multiple layers.
Example:
// Layout.js
import React from "react";
function Layout({ children }) {
return (
<div>
<header>
<h1>My Header</h1>
</header>
<main>{children}</main>
<footer>
<p>My Footer</p>
</footer>
</div>
);
}
export default Layout;
Usage:
// UserProfile.js
import React from "react";
import Layout from "./Layout";
function UserProfile() {
return (
<Layout>
<div>
<h1>User Profile</h1>
<p>User Details</p>
</div>
</Layout>
);
}
export default UserProfile;
C. Render Props:
Pass a function as a prop to render the children, this function receives the state from the parent.
Example:
// UserProvider.js
import React, { useState } from "react";
function UserProvider({ children }) {
const [user, setUser] = useState({ name: "John", age: 30 });
return <div>{children({ user, setUser })}</div>;
}
export default UserProvider;
Usage:
// UserProfile.js
import React from "react";
import UserProvider from "./UserProvider";
function UserProfile() {
return (
<UserProvider>
{({ user, setUser }) => (
<div>
<h1>User Profile</h1>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<button onClick={() => setUser({ ...user, age: user.age + 1 })}>
Increase Age
</button>
</div>
)}
</UserProvider>
);
}
export default UserProfile;
Chapter 3: Null vs. Undefined - âTwo Sides of the Same Coinâ
JavaScript has null and undefined, and sometimes the difference between them gives us headaches.
Common Error:
function MyComponent({ data }: { data: any }) {
// Error if data is null
// return <div>{data.map(...)}</div>;
// Fix:
return (
<div>
{data?.map((item) => (
<div>{item.name}</div>
))}
</div>
);
}
Explanation of the differences:
- undefined: A variable has not been assigned a value. It is often encountered with variables that have been declared but not yet assigned a value, or the non-existent property of an object.
- null: A variable has been assigned a value, but that value is ânothingâ. It is often used to represent a value that does not exist or is invalid.
How to handle:
- Use the optional chaining operator (?.) to check for existence before accessing a property.
- Use the nullish coalescing operator (??) to provide a default value if a variable is null or undefined.
Example:
function UserProfile({
user,
}: {
user: { name?: string; age?: number } | null;
}) {
return (
<div>
<h1>User Profile</h1>
<p>Name: {user?.name ?? "Unknown"}</p>
<p>Age: {user?.age ?? "Unknown"}</p>
</div>
);
}
Summary:
- Optional Chaining Operator (?.): Helps check existence before accessing properties.
- Nullish Coalescing Operator (??): Provides a default value instead of null or undefined.
Chapter 4: âLostâ Token and the Lesson Iâll Never Forget
The story is, in one project, I ânaivelyâ stored a token in sessionStorage, thinking it was a âfeatureâ of next-auth. The consequence was that my token was âexposedâ and at risk of XSS attacks.
My experience:
- Never store tokens (or sensitive information) in localStorage or sessionStorage.
- Understand how a library works, donât âguess.â
- Use HTTP-only cookies to store tokens more securely.
Using HTTP-only Cookies:
// Server side - Express Example
app.get("/auth", (req, res) => {
const token = createToken(req.user);
res.cookie("auth_token", token, {
httpOnly: true,
secure: true, // When using HTTPS
sameSite: "strict",
maxAge: 24 * 60 * 60 * 1000, // 24 hours
});
res.redirect("/dashboard");
});
Explanation:
- HTTP-only: Allows cookie access only via HTTP/HTTPS, preventing access from JavaScript, reducing the risk of XSS.
- Secure: Only sends the cookie over HTTPS, not HTTP.
- Same-site: Only sends cookies within the same website, preventing CSRF attacks.
- maxAge: Sets the lifespan for the cookie.
Warning:
Using HTTP-only cookies means that you cannot access them from the client side via JavaScript, so you need to proactively handle cases where the token expires or needs to be refreshed.
Chapter 5: Performance Optimization - âRacingâ With Re-renders
5.1. Unnecessary Re-renders: The âEnemyâ of Performance
Every time state or props change, React re-renders the component. Excessive re-renders will reduce the applicationâs performance.
Example:
function ParentComponent() {
const [count, setCount] = useState(0);
const expensiveCompute = () => {
console.log("Computing...");
// Simple simulation of time-consuming calculation
for (let i = 0; i < 1e7; i++) {}
return count * 2;
};
return (
<div>
<ChildComponent value={expensiveCompute()} />
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
function ChildComponent({ value }: { value: number }) {
console.log("Rendering ChildComponent");
return <div>Value: {value}</div>;
}
In the example above, each time setCount is called, ParentComponent will re-render, so expensiveCompute will also be re-executed, causing performance degradation.
Solution:
- React.memo: Wrap components to prevent re-renders if props donât change.
- useMemo: Memoize computed values.
- useCallback: Memoize callback functions.
Using React.memo
const ChildComponent = React.memo(({ value }: { value: number }) => {
console.log("Rendering ChildComponent");
return <div>Value: {value}</div>;
});
In the code above, ChildComponent only re-renders when the value changes, avoiding unnecessary re-renders.
Using useMemo:
function ParentComponent() {
const [count, setCount] = useState(0);
const computedValue = useMemo(() => {
console.log("Computing...");
for (let i = 0; i < 1e7; i++) {}
return count * 2;
}, [count]); // Only recalculate when count changes
return (
<div>
<ChildComponent value={computedValue} />
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
const ChildComponent = ({ value }: { value: number }) => {
console.log("Rendering ChildComponent");
return <div>Value: {value}</div>;
};
Using useMemo helps avoid re-executing the expensiveCompute function each time ParentComponent re-renders, only executing when count changes.
Note: I know that React 19 already optimizes this automatically, weâll discuss it later.
5.2. âRedundant Codeâ - The âSilent Culpritâ That Slows Down Websites
One of the nagging problems that few people talk about is the amount of extra code you still have to âcarryâ in your bundle. These can be:
- Unnecessary imports: This is the most common occurrence because IDEs today have supported us a lot in importing, sometimes we donât use those components, functions, libraries but they are still imported.
- âBloatedâ Dependencies: The libraries you use may contain a lot of code that you will never use. Using large libraries for small tasks can unnecessarily inflate the bundle size. Over time, these libraries are constantly updated, with the newer version being bigger than the previous one, so be careful.
- Dead Code: Code that is never executed (e.g., functions and variables that are not used) can also âsneakâ into your bundle.
Why is it important?
- Increased load time: A larger bundle means that the browser has to download more code, slowing down the page load time.
- Wasted bandwidth: Users have to download more data, especially on slow connections or mobile devices.
- Reduced performance: The browser takes longer to parse and compile JavaScript.
Solutions:
- Tree-shaking: Webpack and other modern bundlers support âtree-shakingâ, which helps remove unused code during the build process. Make sure you have this feature turned on. But remember that tree-shaking only works effectively when libraries are written in a way that supports tree-shaking (e.g., using ES modules).
- Using Dynamic Imports
import()
: As mentioned in the code-splitting section, dynamic imports help you load code only when needed. - Analyze Bundle Size: Use tools like
webpack-bundle-analyzer
to âdissectâ your bundle, see which code is taking up the most space. From there, you can find ways to optimize or replace âheavyâ libraries. - Check dependencies: Be careful with âhugeâ libraries, only using a few small features in them. Consider using smaller, more specialized libraries, or write your own replacement code if possible.
- Remove redundant code from dependencies: After analyzing the bundle size in detail, you can find ways to remove redundant code from dependencies by using webpack plugins (e.g., IgnorePlugin to skip unnecessary modules).
- Check for redundant imports: This is the time you need to check the code files again to see if there are any unused imports, delete them if you are sure you wonât use them, donât leave redundant imports in your code.
- Example: Instead of importing the entire lodash library, you only need to import the specific functions that you need:
// Instead of:
// import _ from 'lodash';
// Import like this:
import debounce from "lodash/debounce"; // Only import the debounce function
- Example: Use webpack-bundle-analyzer to analyze the bundle:
# Install:
npm install --save-dev webpack-bundle-analyzer
# Add to the webpack.config.js file:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
// ...
plugins: [
new BundleAnalyzerPlugin()
]
};
Then, run npm run build
, webpack-bundle-analyzer
will open a website that visually displays the structure of your bundle, making it easy to identify the âculpritsâ that are causing the bundle size to inflate.
Real Story:
In one project, I found that my bundle size was too large, even though I had already applied code-splitting. After analyzing with webpack-bundle-analyzer
, I realized that a UI library I was using took up most of the space, even though I only used a few small components from it. I decided to replace that library with a more compact, more specialized one, and as a result, the bundle size was significantly reduced, which helped improve page load speed âa lotâ! đ
Advice:
- Check the bundle size regularly and find ways to optimize it.
- Be careful with âgiantâ libraries.
- Remove redundant code and unnecessary dependencies.
- Use bundle size analysis tools to âhunt downâ the culprits.
Remember: Optimizing bundle size is an ongoing process that requires meticulousness and patience. But believe me, the results will be âwell worth the effortâ! đ
Conclusion: âGrowingâ Through âFallsâ
Debugging is an essential part of programming. Falling is normal; what matters is what you learn from those falls. Hopefully, this series of articles will help you steer better on your path to becoming an awesome React developer.
Remember:
- Always keep up to date with new knowledge (React 19 is out đ).
- Practice, practice, and practice.
- Donât be afraid to share your experiences and learn from the community.
Next Article:
In part 2 of the âDebugging Reactâ series, we will delve into issues related to server-side rendering and client-side rendering, and explore how to optimize applications when using React Server Components. See you next month!
References: