DEV Community

Cover image for Solved: What actually gets hard in large React / Next.js apps?
Darian Vance
Darian Vance

Posted on • Originally published at wp.me

Solved: What actually gets hard in large React / Next.js apps?

🚀 Executive Summary

TL;DR: The core problem in large React/Next.js applications is state management, specifically ‘prop drilling,’ which leads to tight coupling and performance-killing re-render cascades. Solutions range from component composition and dedicated state managers like Zustand to comprehensive architectural refactors, focusing on matching the solution to the problem’s scale.

🎯 Key Takeaways

  • Prop drilling in large React applications leads to tight coupling between components and triggers unnecessary re-renders across the component tree, significantly degrading performance.
  • Component composition, using patterns like the ‘slot pattern’ with children props, effectively solves localized prop drilling by inverting control and making intermediate components ‘dumber’ and more reusable.
  • For truly global state, dedicated, hook-based state managers like Zustand or Jotai offer precise subscription mechanisms, ensuring components only re-render when the specific piece of state they consume changes, unlike the broader re-renders often seen with React Context.

In large React applications, state management and “prop drilling” quickly become the biggest bottleneck, causing performance degradation and making the codebase brittle and difficult to maintain.

What Actually Gets Hard in Large React / Next.js Apps? Spoiler: It’s Always State.

I still remember the “Great Re-render Debacle of Q3”. We were getting performance alerts from our prod-perf-dashboard that our main application layout was taking over 500ms to render on certain interactions. The culprit? A seemingly innocent change. A product manager asked us to add the user’s name to a deeply nested HelpModal component. The user object was fetched at the top of the component tree, and by the time we passed it down twelve levels deep, we had inadvertently caused a re-render cascade that was firing on every single keystroke in a completely unrelated search input. It was a brutal reminder: how you manage and pass data isn’t just a developer convenience issue; it’s a critical performance and architectural problem waiting to explode.

The Root Cause: The “Waterfall” of Props

In a small app, passing props down from a parent to a child component is simple and effective. It’s the React way. But as your app grows, you end up with components that don’t need the data themselves but are forced to act as a “pass-through” for a child, or grandchild, component deep down the tree. This is called prop drilling.

The core problem isn’t just the tediousness of typing const { user } = props;. The real issue is coupling and unnecessary re-renders. Every component in that chain is now coupled to the shape of that user prop. If the user object changes at the top, React will re-evaluate every single component in that chain, even if they don’t use the data. This is what killed our performance in the story above.

The Solutions: From Duct Tape to a New Foundation

We’ve fought this battle many times at TechResolve. Here are the three levels of solutions we typically turn to, from a quick patch to a full architectural shift.

1. The Quick Fix: Component Composition (aka “The Slot Pattern”)

Sometimes, you don’t need a heavy-duty state management library. You just need to rethink how you build your components. Instead of passing data *down*, you can pass the component that needs the data *in* as a children prop. It’s a form of inversion of control.

Before (Prop Drilling):

function Page({ user }) {
  return <Layout user={user} />;
}

function Layout({ user }) {
  return <Header user={user} />;
}

function Header({ user }) {
  return <div>Welcome, {user.name}!</div>;
}
Enter fullscreen mode Exit fullscreen mode

After (Composition):

function Page({ user }) {
  const userGreeting = <div>Welcome, {user.name}!</div>;
  return <Layout headerContent={userGreeting} />;
}

// Layout and Header no longer know or care about the 'user' object.
function Layout({ headerContent }) {
  return <Header>{headerContent}</Header>;
}

function Header({ children }) {
  return <nav>{children}</nav>;
}
Enter fullscreen mode Exit fullscreen mode

This is a fantastic, lightweight pattern that solves the problem for localized state. Layout and Header are now dumber, more reusable, and aren’t re-rendered just because the user object changes.

2. The Permanent Fix: A Dedicated State Manager (or Context, used wisely)

When state is truly global—like the current user, theme, or shopping cart—composition isn’t enough. The data needs to be available anywhere in the app without drilling. This is where a state management tool comes in.

Your first instinct might be React’s built-in Context API. It’s great for low-frequency updates (like a theme toggle). However, it has a major weakness: any component consuming the context will re-render whenever *any* value in the context changes, even if it doesn’t use that specific value.

Pro Tip: If you use Context, keep your contexts small and focused. Avoid creating one massive AppContext with everything in it. Create a UserContext, a ThemeContext, etc. This prevents a change in the user’s name from re-rendering a component that only cares about the theme.

For more complex state, we’ve had huge success with small, hook-based libraries like Zustand or Jotai. They provide the benefits of a global store without the boilerplate of Redux. They are also smart about subscriptions, so a component only re-renders when the specific piece of state it’s subscribed to actually changes.

Example with Zustand:

import { create } from 'zustand';

// 1. Create a store. This lives outside your components.
const useUserStore = create((set) => ({
  user: null,
  setUser: (newUser) => set({ user: newUser }),
}));

// 2. Use it anywhere without prop drilling.
function Header() {
  const user = useUserStore((state) => state.user); // Selects only the user object
  return <div>Welcome, {user?.name}!</div>;
}

function UpdateUserButton() {
    const setUser = useUserStore((state) => state.setUser); // Selects only the setter function
    return <button onClick={() => setUser({ name: 'Darian' })}>Log In</button>;
}
Enter fullscreen mode Exit fullscreen mode

Notice how UpdateUserButton only subscribes to the setUser function. It will never re-render when the user data changes. This is the surgical precision you need in a large application.

3. The ‘Nuclear’ Option: An Architectural Refactor

Sometimes, the problem isn’t the tool; it’s the lack of rules. In massive applications with dozens of engineers, you reach a point where ad-hoc state management creates chaos. The “nuclear” option is to stop, define a clear data-fetching and state management architecture, and refactor towards it.

This means establishing strict rules like:

  • All server-side state is managed by a dedicated data-fetching library (like React Query or SWR). No more random useEffect fetch calls.
  • All truly global client-side state (UI state, etc.) lives in designated stores (like our Zustand example).
  • Local component state (useState) is only used for things that are truly local, like form input values or the open/closed state of a modal.

This isn’t a quick fix. It’s a project-level decision. It involves creating documentation, patterns, and custom hooks for your team (useCurrentUser(), useActiveProject()) to make doing the right thing the easy thing. It’s a heavy lift, but for applications that need to scale for years, it pays for itself in reduced bugs and faster development velocity.

Choosing Your Weapon

There’s no single right answer. The key is to match the solution to the scale of the problem. Here’s how I typically decide:

Solution Best For Complexity Our Take
Composition Passing UI elements or state through 1-3 levels of “pass-through” components. Low Your first line of defense. Use it before reaching for a global solution.
State Manager (Zustand) Truly global data needed in many unrelated parts of the app (e.g., user auth, theme). Medium Our go-to for most global state needs. Scalable and performant.
Architectural Refactor Large, multi-team projects where consistency and predictability are paramount. High A necessary “reset” for legacy codebases or a foundational plan for new, large-scale apps.

Scaling a React or Next.js app isn’t about finding one magical tool. It’s about understanding the trade-offs and building a layered strategy for managing data. Start simple, and only introduce complexity when the pain of not having it becomes greater than the cost of implementing it.


Darian Vance

👉 Read the original article on TechResolve.blog


☕ Support my work

If this article helped you, you can buy me a coffee:

👉 https://buymeacoffee.com/darianvance

Top comments (0)