Lazy Loading in React and Next.js
Modern React applications often become complex when components manage too much state or duplicate logic. One way to simplify components is by using derived state—state that can be computed from existing props or state instead of being stored separately. In addition to derived state, performance optimization techniques like lazy loading, code splitting, and dynamic imports help reduce bundle size and improve user experience. Let us delve into understanding lazy loading in React and Next.js.
1. What is Lazy Loading?
Lazy loading is a performance optimization technique where components, modules, or resources are loaded only when they are actually needed, instead of being included in the initial application bundle. This helps reduce initial load time and improves perceived performance for users. In React-based applications, lazy loading is commonly used for route-based splitting, heavy UI components, charts, dashboards, and third-party libraries that are not required immediately on page load.
By deferring non-critical code, the application becomes faster to load and more scalable as it grows in complexity. For more details on React performance optimization, you can refer to the official React documentation on code splitting: React.lazy documentation. You can also learn about dynamic imports in Next.js here: Next.js Lazy Loading Guide.
1.1 React.lazy vs next/dynamic: When to Use Each
| Feature | React.lazy | next/dynamic |
|---|---|---|
| Framework | React (generic, works in SPA) | Next.js specific optimization API |
| SSR support | No (client-side only execution) | Yes (can enable or disable SSR explicitly) |
| Suspense support | Yes (requires React.Suspense) | Optional fallback system built-in |
| Code splitting behavior | Manual, based on dynamic import() | Automatic + configurable splitting strategy |
| Best use case | Single Page Applications (CRA, Vite, React SPA) | Next.js applications with SSR, SSG, or hybrid rendering |
| Control over loading UI | Via Suspense fallback | Via loading option or custom component |
Use React.lazy when building standard React applications where client-side rendering is the primary model. It works best when you want simple code splitting with Suspense. Use next/dynamic when building Next.js applications that require more control over rendering behavior, especially when dealing with server-side rendering (SSR), hydration-sensitive components, or performance tuning at the framework level.
2. Code Example
2.1 How to Use React.lazy for Code Splitting
This example shows how React.lazy can be used to load a component only when it is needed, helping reduce the initial bundle size. It is a simple way to improve performance by splitting code at the component level.
// Lazy load a component
import React, { Suspense } from "react";
const HeavyComponent = React.lazy(() => import("./HeavyComponent"));
export default function App() {
return (
<div>
<h2>React Lazy Loading Example</h2>
<HeavyComponent />
</div>
);
}
This code demonstrates how React.lazy is used to implement code splitting by dynamically importing the HeavyComponent only when it is rendered, rather than including it in the initial JavaScript bundle. The React.lazy function wraps a dynamic import statement, allowing the component to be loaded asynchronously, which improves initial page load performance by reducing bundle size. When the App component renders, HeavyComponent is not immediately available in the main bundle and will only be fetched when React attempts to render it, triggering a network request for that module. Although Suspense is imported, it is not used in this snippet; in a real-world scenario, HeavyComponent should be wrapped inside a Suspense boundary to handle the loading state gracefully and avoid rendering issues while the component is being fetched. This pattern is especially useful for large components or rarely used UI sections that should not impact the initial render experience.
2.2 How to Use Suspense with React.lazy
This example demonstrates how React.Suspense acts as a loading boundary for components that are loaded asynchronously using React.lazy. It helps manage loading states in a clean and declarative way.
import React, { Suspense } from "react";
const HeavyComponent = React.lazy(() => import("./HeavyComponent"));
export default function App() {
return (
<Suspense fallback={<div>Loading component...</div>}>
<HeavyComponent />
</Suspense>
);
}
This code shows how React.Suspense is used as a boundary to handle the asynchronous loading of a lazily imported component. The HeavyComponent is dynamically loaded using React.lazy, which means it is not included in the initial bundle and is fetched only when it needs to be rendered. Since this loading happens asynchronously, Suspense provides a fallback UI—in this case, a simple “Loading component…” message—while the component is being downloaded and initialized. Once the component is successfully loaded, React automatically replaces the fallback UI with the actual HeavyComponent without requiring any manual state handling or lifecycle logic. This pattern simplifies loading states, improves user experience by providing immediate feedback, and works best for splitting large or infrequently used UI components in a React application.
2.3 How to Handle Errors with Error Boundaries
This example demonstrates how an Error Boundary can be used to gracefully handle runtime errors in React components and prevent the entire UI from crashing. It acts as a safety layer around child components.
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <h3>Something went wrong while loading component.</h3>;
}
return this.props.children;
}
}
This code defines a React Error Boundary using a class component that is designed to catch JavaScript errors in its child component tree during rendering, lifecycle methods, or constructor execution. The state property hasError is initially set to false and is updated to true when an error is detected using the static lifecycle method getDerivedStateFromError. When an error occurs, React triggers this method and updates the state, causing the component to re-render and display a fallback UI instead of crashing the entire application. In the render method, if hasError is true, a user-friendly error message is displayed; otherwise, it renders this.props.children, allowing normal application flow. This pattern is especially useful when wrapping lazy-loaded components or external modules where runtime failures might occur, ensuring the rest of the application remains stable and usable even if one part fails.
2.4 How to Use next/dynamic in Next.js
This example shows how Next.js supports dynamic imports using next/dynamic to load components only when they are required. It also allows control over server-side rendering and loading states.
// Next.js dynamic import
import dynamic from "next/dynamic";
const HeavyComponent = dynamic(() => import("../components/HeavyComponent"), {
loading: () => <p>Loading component...</p>,
ssr: false // disables server-side rendering
});
export default function Page() {
return (
<div>
<h2>Next.js Dynamic Import</h2>
<HeavyComponent />
</div>
);
}
This code demonstrates the use of Next.js dynamic imports via the next/dynamic function to load components on demand while providing fine-grained control over rendering behavior. The HeavyComponent is imported dynamically, meaning it is not included in the initial server or client bundle and is fetched only when it is required for rendering. The loading option defines a fallback UI that is displayed while the component is being loaded, ensuring users see immediate feedback instead of a blank screen. Additionally, setting ssr: false disables server-side rendering for this component, forcing it to render only on the client side, which is useful for components that rely on browser-specific APIs or are too heavy for SSR. This approach helps optimize performance in Next.js applications by reducing initial load time and improving control over when and how components are rendered.
4. Conclusion
Simplifying React components is not only about reducing code complexity but also about improving performance and maintainability. By combining derived state, lazy loading, and code splitting techniques, you can build applications that are both efficient and scalable. Use React.lazy for general React apps, and next/dynamic when working with Next.js. Always wrap lazy components with Suspense and protect them with Error Boundaries for robustness.

