Home / Articles / Cutting React Prop-Drilling and God Components Down to Size

This article is published in English.

Cutting React Prop-Drilling and God Components Down to Size

Learn seven concrete refactoring patterns for breaking apart bloated React components by isolating state, data fetching, permissions, and loading logic instead of just splitting files.

3299 words

There was a stretch when React complexity got judged purely by line count. Once a component crossed three hundred lines, the toolbar would get pulled into its own file. Once it hit four hundred, the table would get extracted too. The top-level file shrank, but the feature itself was no easier to reason about. The parent component still held every network request, every modal, a scattering of form fields and their validation state, checks for what the current user was allowed to do, and the shifting states a screen moves through as it loads, edits, saves, and sometimes fails.

What actually happened was a rearrangement of the furniture without any change to who owned the house.

That distinction is worth sitting with, because size alone isn't the enemy. A component can legitimately be large if it represents a single, cohesive piece of UI. A dense editor, a dashboard, or a reporting page can justifiably contain a lot of markup. The trouble starts when one component turns into the single place where completely unrelated decisions get made.

None of the patterns below are inherently bad React. Each has legitimate uses in a smaller or more intentional setting. They only become a liability when they show up over and over inside large components, because each occurrence adds coupling, expands the surface area affected by rerenders, and forces every future change to account for the entire screen at once.

Getting rid of them doesn't leave you with a pile of meaningless tiny components. It leaves you with clearer lines around state, data fetching, interaction, and rendering. The code gets easier to modify simply because fewer pieces of the feature can unintentionally reach into one another.

1. Components That Passed the Entire Feature Downward

One habit that shows up constantly in bigger screens is threading large objects and long lists of callbacks into almost every child component.

Picture a results table receiving the current user, a full permissions object, the active filters, the selected row, a loading flag, several mutation functions, modal setters, and notification handlers — plus a handful of values it never touches directly. That table then forwards some of these straight into row components, which forward them again into individual cells and buttons.

Looking at the file tree, everything seems nicely split apart. Looking at the actual data flowing between components tells a different story: every child is still wired into the whole feature.

This produces two distinct problems. First, comprehension suffers. A component accepting fifteen props is hard to reason about because its actual job is hidden underneath details that really belong to its parent. Second, changes ripple outward. Renaming a single permission field, or tweaking one action's function signature, means editing several layers of components that had no real ownership over that behavior in the first place.

The fix is to swap broad, feature-shaped props for narrow contracts scoped to what each child is actually responsible for. A table only needs rows, selection state, and something like an onRowSelected callback. An action menu only needs the specific actions available for a given record — not the entire permission model plus every mutation function in existence.

Getting there usually means building a small view model or action model before rendering. That extra preparation step is valuable, because it forces the parent to translate raw application state into a smaller, purpose-built interface before handing anything down.

The point is never to minimize prop counts for their own sake. The real win is that children stop needing to understand how the whole page works. They only need to know what data to display and what intent to report back upward.

A large component turns fragile the moment every descendant is carrying a slice of its parent's entire internal world. Narrow, purpose-built contracts let parts of the UI evolve independently instead of dragging the whole feature into every conversation.

2. Fresh Objects and Functions Created on Every Render

Every React component naturally produces new values while it renders. You pass an options object to a child, filter an array, or write an inline event handler. Most of the time none of this matters.

But inside a deep component tree, a freshly created reference can silently break memoization several layers below the component that produced it.

Consider a child component built with memo: it still renders again whenever the object it receives has a new identity on each parent pass, even if the actual field values inside that object never changed. An effect restarts because its dependency array contains a newly built configuration object. A table recalculates its columns because the column definitions got rebuilt after some completely unrelated modal state changed.

The code can look perfectly stable while hiding this problem:

<ResultsTable
  columns={[
    { key: "name", label: "Name" },
    { key: "status", label: "Status" },
  ]}
  options={{
    selectable: true,
    compact: false,
  }}
/>

From JavaScript's point of view, both the array and the objects inside it are brand new on every render. Whether that actually causes trouble depends entirely on what's consuming them.

Reaching for useMemo and useCallback as a blanket fix isn't the answer either. Wrapping everything in memoization just adds its own layer of noise and dependency-array headaches. A better starting point is asking whether the value needs to be constructed inside the render at all.

Static configuration can move outside the component entirely. Configuration that changes occasionally can move into a dedicated hook. When an object exists purely to bundle a few primitives together, passing the primitives directly is usually cleaner. Event handlers only need stabilizing with useCallback when their identity actually matters — for subscriptions, memoized children, or expensive recalculations downstream.

The goal is never referential purity for its own sake. Creating small values during render is normal and usually harmless. The care should be reserved for cases where reference identity is doing real work elsewhere in the tree.

In a large component, a single minor state update can rerender the parent and regenerate a whole batch of values along the way. If every child treats a changed reference as changed data, one small local update turns into an invalidation of the entire page.

3. Removing the Single Query That Powered the Entire Screen

Large pages often start with a single request designed to fetch everything the interface could possibly need. That one response bundles summary metrics, table rows, filters, related records, permission data, recent history, and even fields required only by a modal the user might never open.

This approach seems efficient at first glance, since the screen has exactly one loading state and one obvious data source. But it also chains every part of the page to the slowest, least dependable segment of that response.

If the history service fails, the main table might never render at all. A bulky related collection inflates the initial payload even for users who never open the panel that needs it. And refreshing just one section forces a full reload, because the whole page shares a single data boundary.

A better approach replaces these page-sized requests with data boundaries that match the actual visible responsibilities of the screen. Primary content loads first. Secondary panels fetch their own data only once they become relevant. Expensive details get retrieved when a user opens a specific record rather than being bundled into every row by default.

This doesn't mean spinning up a network call for every trivial widget. Overdoing this kind of separation creates its own problems — waterfalled requests, duplicate calls, and inconsistent loading states. The boundary that actually matters is usually a region with its own lifecycle and its own definition of what "failure" means.

A summary widget and a historical audit log don't need to succeed or fail as a single unit. A table and a rarely used editor panel don't have to share the same initial payload. Once these pieces are split apart, the page can remain functional even when one optional section breaks down.

The component itself also becomes far easier to follow, since it no longer has to model one massive, sprawling response shape. Every region gets the data contract it actually needs, and cache invalidation can target just the records that changed instead of the entire page.

Large components tend to become fragile exactly when their data model is dictated by page-level convenience instead of by the natural lifecycle of the individual features living inside them.

4. Removing Generic Components Controlled by Dozens of Flags

A common early instinct for avoiding duplication is to build extremely configurable components. A single panel can be made searchable, selectable, paginated, editable, exportable, and collapsible, all controlled through a growing pile of boolean props.

This feels reusable because it seems to cover a wide range of scenarios. In reality, every new screen just means adding one more condition to the pile.

<DataPanel
  searchable
  selectable
  showToolbar
  allowExport={canExport}
  inlineEdit={mode === "admin"}
  compact={isInsideModal}
  hidePagination={rows.length < 20}
  stickyHeader={!isMobile}
/>

The real issue isn't just the sheer number of props. The flags interact with each other in unpredictable ways. Inline editing behaves differently once selection mode is active. The compact layout needs its own toolbar logic. A sticky header breaks inside a particular overflow container. The number of possible flag combinations grows far faster than anyone's ability to actually test them.

The component quietly turns into a second application hiding inside the first one.

Replacing this flag-driven reuse means favoring smaller, composable pieces, along with explicit separate variants wherever the differences are substantial. A table can be paired with a toolbar, a pagination control, and a selection provider as needed. An editable table can exist as its own distinct feature rather than being just another boolean branch buried inside a generic table component.

Where two interfaces share a genuine underlying structure, that structure gets reused directly. Where they merely resemble each other in a screenshot but follow different workflows, forcing them through one shared abstraction stops making sense.

This brings back some duplication that had previously been hidden, which can feel like a step backward at first. In practice, that duplication is usually far cheaper than the branching architecture it replaces. Two small, separate components can change independently, without every edit needing to be verified against a dozen unrelated flag combinations.

Reuse pays off when it protects a single, stable contract. It turns risky the moment it's achieved by stretching one component to secretly represent several different products at once.

5. Removing Modal State From the Page That Opened the Modal

Large components frequently end up acting as modal managers. They track whether each dialog is open, which record it's currently editing, which step of a flow it's on, whether it's mid-save, and what error it last surfaced.

Often the page itself only contains a single line that triggers the modal to open, yet it ends up owning that dialog's entire lifecycle regardless.

This produces state that stays active even while the dialog is completely invisible. Closing it properly means clearing selected records, draft field values, validation errors, and pending requests, all in a specific order. Opening a different dialog afterward risks accidentally reusing leftover values from whatever was open before.

Treating any nontrivial dialog as a feature in its own right, rather than as conditional JSX bolted onto the page, changes this dynamic. The page's job becomes deciding which record the user intends to act on. The dialog itself owns the draft data, validation logic, internal steps, and the submission lifecycle.

In some cases, simply mounting the dialog only while it's actually open is enough to reset its temporary state automatically. In others, the state needs to persist across closes, so it moves into a dedicated draft owner instead of staying tangled up with the page's table and filter state.

This split also makes asynchronous behavior noticeably safer. An edit dialog can cancel or discard stale requests the moment it closes. A save action can define on its own whether the dialog stays open through a failure. The page no longer has to coordinate internal form mechanics it has no real understanding of.

The page still retains ownership of the connection between the table and the dialog — it knows which record is selected and what should happen once an edit succeeds. But it no longer owns every field and transition just because the dialog happened to be launched from that page.

A modal may appear visually layered on top of a screen, but that visual layering doesn't mean its full state lifecycle needs to live inside the screen's component.

6. Pulling Permission Checks Out of Scattered JSX

Authorization logic tends to creep into components in small increments. A button gets hidden for viewers, a menu item gets disabled once a record is archived, a section only renders for admins.

These conditions multiply because JSX makes it trivially easy to bolt on another check:

{user.role === "admin" && record.status !== "archived" && (
  <DeleteButton />
)}

By the time a component has grown large, it may contain several slightly different versions of what is supposedly the same rule. One condition governs whether something is visible, another governs whether its handler fires, and a third governs whether a menu entry is grayed out. Given enough time, these copies drift apart and stop agreeing with each other.

That drift is mainly a correctness bug, but it also hurts readability. Layout markup gets tangled up with business policy, and anyone reading the component has to mentally evaluate permission expressions scattered across the entire tree just to understand what the page does.

Instead of scattering raw policy checks through JSX, you can compute an explicit set of capabilities up front:

const capabilities = getRecordCapabilities({
  user,
  record,
  organization,
});

From there, the component can simply ask whether the current user is allowed to edit, archive, export, or delete the given record. The names describe actual product decisions instead of exposing raw database fields or role strings.

To be clear, this doesn't move security enforcement into the client. The backend remains the actual authorization boundary — nothing changes that. The capability object on the frontend exists purely to give the UI one consistent source of truth, instead of re-deriving the same rule in five different visual branches that could quietly fall out of sync.

It also makes the rules themselves far easier to test. You can exercise the permission logic against different roles, ownership configurations, statuses, and organization settings without rendering the full component tree. When the underlying policy changes, the surrounding component usually doesn't need to change at all.

Large JSX trees get brittle when they're also where business rules are being invented on the fly. Rendering should mostly be about consuming decisions that were already made, not reconstructing those decisions from scratch inside every conditional branch.

7. Retiring Page-Wide Loading and Error Flags

Most large components start out innocently with a single isLoading value and a single error. That's fine as long as the page only does one thing. But as a feature grows, those same two flags end up trying to describe several unrelated activities at once.

A page might be fetching its initial data, refreshing a table in the background, saving a form, deleting a row, and exporting a report — sometimes all in the same session. One shared loading boolean can't tell you which of those operations is actually in flight. One request finishes and flips the flag to false while a completely different request is still running. An error from a mutation overwrites the error that was meant to describe the initial page load. The whole interface locks up just because some unrelated background refresh happens to be running.

A better approach replaces these page-wide status flags with status that belongs to the specific operation it describes.

That means the initial query can be loading while the existing table stays fully visible and interactive. A single row can be in the middle of being deleted without disabling every other row on the page. The editor can be mid-submit while the page's filters remain usable. An export can show its own progress indicator without implying the entire screen has gone unavailable.

The result is more individual status values, but each one has an unambiguous meaning. Nothing in the code has to guess what a generic isLoading is actually referring to at any given moment.

The same logic applies to failures: not every one needs to funnel into a single top-level error screen. A failed optional panel can show its own retry option in place. A mutation error can stay contained to the workflow that triggered it. The main page itself only becomes unavailable when the data required to render that page specifically has failed to load.

This produces a more resilient interface and a component model that's easier to reason about. Status stops being treated as global just because the operation happens to live inside a large page component.

A large component often feels shaky because several distinct workflows are being forced to share a single signal. Giving each workflow its own status lets the interface stay honest about exactly what's working and what isn't at any given time.

The Point Was Never Just Smaller Files

Once these patterns are removed, many components do end up shorter — but that's a side effect, not the actual goal.

The real gain is that fewer unrelated decisions are being made inside a single rendering boundary. Child components receive contracts scoped to their own responsibilities instead of the entire feature's state. Reference identity stops silently invalidating huge subtrees on every render. Data gets fetched according to what's actually visible on screen rather than through one page-sized query. Reusable components stop accumulating a new flag for every possible variation anyone ever needed.

Modals take ownership of their own state. Permission rules turn into named capabilities instead of inline conditionals. Loading and error states belong to the specific operations that produce them.

Some screens stay large, simply because the interface itself is genuinely large — and that's fine. The code becomes easier to work with not because it got smaller, but because its size reflects real, visible structure instead of hidden coordination logic.

At this point, the question worth asking about a React component isn't how many lines it has. It's how many independent reasons could force it to change. If touching the editor means you also have to understand the table's query, the export state, the permission model, and every modal on the page, the problem isn't formatting or file length. The ownership boundaries are simply drawn in the wrong place.

Large React components stay manageable as soon as they stop trying to behave like the entire application by themselves.

The goal was never to keep splitting a file until every function was tiny.

The goal is to make sure each piece of a feature only knows about the decisions it's actually responsible for making.