open atlas
↑ Back to track
React patterns, senior RXP · 14 · 01

God components & prop drilling

God components and deep prop drilling are React's forms of low cohesion and high coupling; cure them with composition, custom hooks, and state colocation — and resist over-correcting drilling with an app-wide context or global store when passing children was the lighter fix.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

Open almost any aging React codebase and you’ll find the same two shapes. One is the god component: a single file that fetches data, owns a dozen useStates, runs the business rules, and lays out the screen — 500 lines that the whole team is afraid to touch because every concern lives in one blast radius. The other is prop drilling: a value threaded through six components that don’t use it, just to reach the one at the bottom that does.

Neither is a React bug. They’re the React shapes of two ancient design smells — low cohesion (one unit doing unrelated things) and high coupling (a change here forces edits over there). The good news: you already learned the cures in earlier units. This lesson is about recognizing the smell and reaching for the lightest fix — not the heaviest.

Goal

After this lesson you can name a god component as low cohesion and deep prop drilling as the coupling it creates; decompose a god component into focused pieces using composition, custom hooks, and state colocation; choose between the three drilling cures — passing JSX as children, lifting structure, and context — by the pressure each answers; and avoid the senior trap of “fixing” drilling with an app-wide context or a global store when composition was the correct, lighter solution.

1

A god component is low cohesion: it owns unrelated concerns, so it has many reasons to change. Fetching, local UI state, business logic, and layout are four different jobs. Bundle them and every one of them — a new endpoint, a new filter, a rule change, a redesign — edits the same file, and every edit risks the others. The tell is a component you can’t summarize in one sentence without saying “and”.

// the smell: one component, four jobs, no summary without "and"
function OrdersPage() {
  const [orders, setOrders] = useState<Order[]>([]);
  const [status, setStatus] = useState("all");
  const [sort, setSort] = useState("date");
  const [page, setPage] = useState(1);
  const [selected, setSelected] = useState<string[]>([]);

  useEffect(() => { /* fetch + parse + retry */ }, [status, page]);

  const visible = orders /* filter by status, then sort, then slice page */;
  const revenue = visible.reduce((s, o) => s + o.total, 0); // business rule

  return <div>{/* toolbar + table + pagination + summary, all inline */}</div>;
}

Cohesion is the question “does everything in this unit belong together?” Here the answer is plainly no.

2

Extract the non-rendering jobs into custom hooks — fetching and derived state are not layout. Data loading, retries, filtering, and the revenue calculation are logic, not markup. A custom hook gives that logic a name, a testable surface, and a reuse point, and it shrinks the component to what it actually renders. This is the cohesion cure for the behavioral half of the god component.

// one hook owns "what orders to show"; the page no longer cares how
function useOrders(filter: { status: string; sort: string; page: number }) {
  const [orders, setOrders] = useState<Order[]>([]);
  useEffect(() => { /* fetch + retry, keyed on filter */ }, [filter.status, filter.page]);
  const visible = useMemo(() => sortBy(filterByStatus(orders, filter.status), filter.sort), [orders, filter]);
  return { visible, revenue: visible.reduce((s, o) => s + o.total, 0) };
}

The page imports useOrders and stops being a database client. One concern, one home.

3

Split the remaining markup by composition — each visual piece becomes a component with one job. The toolbar, table, pagination, and summary are independent renderers. Pulled apart, each has a tiny prop surface, is reusable, and re-renders only when its inputs change. Crucially, colocate state with the piece that owns it: selection lives in the table, not the page, so toggling a row doesn’t re-render the toolbar.

function OrdersPage() {
  const [filter, setFilter] = useState({ status: "all", sort: "date", page: 1 });
  const { visible, revenue } = useOrders(filter);
  return (
    <>
      <OrdersToolbar filter={filter} onChange={setFilter} />
      <OrdersTable rows={visible} />        {/* owns its own selection state */}
      <OrdersSummary revenue={revenue} />
      <Pagination page={filter.page} onPage={(page) => setFilter((f) => ({ ...f, page }))} />
    </>
  );
}

The god component is gone: a 12-line orchestrator over four cohesive pieces and one hook.

4

Prop drilling is the coupling smell — and the lightest cure is passing JSX as children, not context. When a value is threaded through layers that never read it, those layers are coupled to a prop that isn’t their business; renaming or removing it touches all of them. The reflex is “drop in a context”. But often the layers in between are layout, and layout can be inverted: let the parent that has the value render the consumer and pass it down as children, so the middle layers never see the prop at all.

// drilling: user threaded through Layout -> Sidebar that don't use it
<Layout user={user}><Sidebar user={user} /></Layout>

// composition cure: middle layers take children; the parent supplies the consumer
function Layout({ children }: { children: React.ReactNode }) { return <div className="shell">{children}</div>; }
<Layout><Sidebar><UserBadge user={user} /></Sidebar></Layout>  // user never drilled

Context is for when many distant components need the same low-frequency value (theme, current user, locale) — not for skipping two layout layers. Reach for children first; it adds zero new concepts and no re-render machinery.

Worked example

A god Dashboard decomposed, and a drilling fix that doesn’t reach for context. Before: one component owns fetching, filter state, a business calculation, and the full layout, and it drills currentUser two layers down to a badge.

function Dashboard({ currentUser }: { currentUser: User }) {
  const [projects, setProjects] = useState<Project[]>([]);
  const [query, setQuery] = useState("");
  useEffect(() => { fetch("/api/projects").then(r => r.json()).then(setProjects); }, []);
  const visible = projects.filter(p => p.name.includes(query));
  const overBudget = visible.filter(p => p.spent > p.budget).length; // business rule
  return (
    <div>
      <Header><Nav currentUser={currentUser} /></Header>  {/* drills user through Header -> Nav */}
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ProjectList projects={visible} />
      <p>{overBudget} over budget</p>
    </div>
  );
}

After: behavior moves to useProjects, the layout layers take children so currentUser is no longer drilled, and each piece is cohesive.

function useProjects() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [query, setQuery] = useState("");
  useEffect(() => { fetch("/api/projects").then(r => r.json()).then(setProjects); }, []);
  const visible = useMemo(() => projects.filter(p => p.name.includes(query)), [projects, query]);
  return { visible, query, setQuery, overBudget: visible.filter(p => p.spent > p.budget).length };
}

function Dashboard({ currentUser }: { currentUser: User }) {
  const { visible, query, setQuery, overBudget } = useProjects();
  return (
    <DashboardShell header={<Header><Nav><UserBadge user={currentUser} /></Nav></Header>}>
      <ProjectFilter query={query} onChange={setQuery} />
      <ProjectList projects={visible} />
      <BudgetSummary overBudget={overBudget} />
    </DashboardShell>
  );
}

Notice what we did not add: no UserContext, no Zustand store. currentUser reaches the badge by composition — the parent that has it renders the consumer and passes it as children. We added a hook and split components; we added zero global state. That restraint is the senior move.

Common mistake

The classic over-correction: someone sees user drilled three layers, drops a UserProvider at the app root, and calls it fixed. Now the value is global, the provider re-renders a wide subtree whenever the user object changes, the data flow is invisible (you can’t trace it by reading the JSX), and testing any consumer needs the provider mounted. All to avoid passing children. Context and global stores are real tools — for genuinely shared, many-reader, low-frequency state. Using them to dodge two layout layers trades a small, local, visible coupling for a large, global, invisible one. That’s not a fix; it’s a worse smell wearing an architecture costume.

Edge cases

When is context the right drilling cure? When the value is read by many components scattered across the tree at different depths, changes rarely, and threading it via children would mean restructuring large chunks of layout you don’t otherwise want to touch — theme, locale, the authenticated user consumed in fifteen places, a design-system density setting. The test isn’t “is this drilled?” but “would composition require contorting the component tree?” If children is a clean fit, use it. If passing JSX down would force an unnatural shape on many call sites, context (or a reducer + context for shared writes) earns its cost. Senior judgment is picking by that pressure, not by reflex.

Check yourself
Quiz

A PR threads `theme` (used by one deep `<ThemedButton>`) through three pure layout wrappers via props. The author proposes adding a global `ThemeProvider` context at the app root to remove the drilling. The theme is used in exactly this one place. What's the senior call?

Recap

A god component is React’s form of low cohesion — one unit owning fetching, state, business logic, and layout, so it has many reasons to change and a large blast radius. Prop drilling is the coupling that god components and tall layouts produce — values threaded through layers that don’t use them. The cures are patterns you already know: extract behavior into custom hooks, split markup by composition, and colocate state with the piece that owns it, turning a 500-line god component into a thin orchestrator over focused pieces. For drilling, match the cure to the pressure: pass JSX as children when the in-between layers are just layout (lightest, zero new concepts), lift state for siblings, and reach for context only when many distant components read the same low-frequency value and composition would contort the tree. The senior failure mode to avoid is over-correcting: dropping an app-wide context or a global store to dodge two layout layers trades a small visible coupling for a large invisible one. Recognize the smell, then choose the lightest fix that the pressure actually demands.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.