How Big Should a Component Be? Composition, Decomposition, and Handing Over Dependencies

Every code review eventually gets the comment: "this component is too big, split it up." It's good advice roughly half the time. The other half, someone dutifully chops a working 200-line component into eight files, and now understanding it means opening all eight, following props through four of them, and reassembling the whole thing in your head. The component wasn't fixed. It was smeared.
So "split it up" isn't the rule. Size is a symptom, not the disease. The real question underneath — the one the last three articles have been circling — is about dependencies: what does this component need to do its job, who gives it those things, and does the component go reach for them itself or get handed them? Get that right and the size mostly sorts itself out. Get it wrong and no amount of splitting helps.
Decomposition isn't about line count
Let me start with the part people get backwards. You don't split a component because it's long. You split it because it's doing more than one thing — because two different reasons to change are living in the same file.
Here's a component that's long but shouldn't be split:
function PolicyDetails({ policy }: { policy: Policy }) { // 180 lines of one coherent thing: rendering the details of a policy. // Header, status, coverage table, footer. All one job. All change // together, for the same reason ("we redesigned the policy view"). }
It's 180 lines and I'd leave it alone. There's one reason for it to change, and everything in it changes for that reason. Splitting it into <PolicyHeader>, <PolicyStatus>, <PolicyFooter> buys you nothing but four files to keep in sync — the classic mistake I wrote about in the smeared component. Now here's a component that's short but absolutely should be split:
function UserBadge({ userId }: { userId: string }) { const { data: user } = useQuery(['user', userId], () => fetchUser(userId)); const theme = useContext(ThemeContext); const track = useAnalytics(); useEffect(() => { track('badge_shown', { userId }); }, [userId]); if (!user) return <Spinner />; return <span style={{ color: theme.accent }}>{user.name}</span>; }
Forty lines, and it's doing four unrelated jobs: fetching data, reading theme, firing analytics, and rendering. Four reasons to change, tangled together. This one I'd split — not because it's big, but because it's mixed.
That's the actual rule: decompose along reasons to change, not along line count. One component, one job, one reason to change. A long file doing one thing is fine. A short file doing four things is a problem waiting to surprise you.
The reach-out anti-pattern
Look again at UserBadge. The thing that makes it hard to reuse and impossible to test in isolation isn't its length. It's that it reaches out and grabs everything it needs from the ambient environment: it calls fetchUser directly, pulls theme from a context, grabs the analytics client from a hook. It's wired straight into the world.
This is the component equivalent of an arrow pointing the wrong way — the exact problem from the dependency-direction article, now one level down, inside a single component. UserBadge claims to be a small presentational thing, but it depends on your data layer, your theme system, and your analytics pipeline. Drop it into a Storybook story or a test and it explodes, because it demands a whole app around it just to render a name.
You feel this the moment you try to reuse it. "I just want to show a user's name in a different color" — but you can't, because the color is hard-wired to ThemeContext.accent, and the name is hard-wired to this query. The component grabs its dependencies instead of accepting them, so it only works in the one spot it was born in.
Hand it over instead
The fix is old and boring and it's called dependency injection, which sounds like a Java framework and is actually just this: don't let a component fetch its own dependencies — hand them in. On the frontend, "inject" mostly means "pass as a prop" or "provide through context," and the entire idea is that the component stops knowing where things come from.
// UserBadge now just renders. It's handed everything it needs. function UserBadge({ name, color }: { name: string; color: string }) { return <span style={{ color }}>{name}</span>; }
That's it. It doesn't fetch, doesn't read context, doesn't track. It takes a name and a color and renders. Now it works in a test with two strings, works in Storybook with no providers, works anywhere. The fetching, theming, and analytics didn't disappear — they moved up to a component whose job is exactly that wiring:
function ConnectedUserBadge({ userId }: { userId: string }) { const { data: user } = useQuery(['user', userId], () => fetchUser(userId)); const theme = useContext(ThemeContext); const track = useAnalytics(); useEffect(() => { track('badge_shown', { userId }); }, [userId, track]); if (!user) return <Spinner />; return <UserBadge name={user.name} color={theme.accent} />; }
We split UserBadge — but notice how we split it. Not into "the top half and the bottom half." Along a dependency seam: one component that knows about the outside world (fetching, context, side effects), and one that knows nothing and just renders what it's given. That seam is the useful cut. The line-count cut isn't.
Injection is a spectrum, not a switch
You don't have to reach for a DI container or some ceremony. Frontend dependency injection is mostly choosing, per dependency, how far to push it out of the component. From least to most flexible:
Hard-coded (no injection). The component imports and calls the thing directly. Fine for genuinely stable, app-agnostic dependencies — a component importing clsx or a pure formatDate doesn't need those injected. Not everything is a dependency worth managing.
Props. The default and the one you'll use most. Pass the value, the callback, the data. Explicit, type-checked, trivially testable. If you're not sure how to inject something, pass it as a prop and stop overthinking it.
// the data source is injected — the list doesn't know or care where rows come from function DataTable<T>({ rows, onRowClick }: { rows: T[]; onRowClick: (row: T) => void }) { /* ... */ }
Context. For dependencies that are truly cross-cutting and tedious to thread — theme, current user, a query client, an analytics sink. Context is injection at a distance: the component pulls from context, but what's in that context is decided at the provider, so tests and stories can hand it a fake. The danger is overusing it — every context dependency is an invisible input that doesn't show up in the props, and a component that reads six contexts is just as wired-in as one that imports six modules. Use context for the few things that are genuinely app-wide, and props for everything else.
Render props / children as a function / slots. Inject behavior or markup, not just values. This is how you build something reusable without it knowing what it renders — a <List> that takes a renderItem, a <Form> that takes children. It's dependency injection where the dependency is a piece of UI.
The skill isn't picking one. It's matching each dependency to the lightest mechanism that still lets you swap it when you need to. Most things: props. A few app-wide things: context. Behavior you want to vary: a function. Genuinely fixed, generic utilities: just import them.
The payoff you actually feel
This all sounds abstract until the day it isn't. Three moments where injecting dependencies instead of grabbing them pays for itself:
Testing. A component handed its dependencies is a component you test with plain values — no mocking the fetch layer, no wrapping in five providers, no fighting the framework. render(<UserBadge name="Ada" color="#22c55e" />) and you're done. The reach-out version needs a mock server and a provider tree just to assert on a name.
Reuse. The whole reason the "small" UserBadge couldn't be reused was that it grabbed its color from one specific context. Hand the color in and it drops into any screen, any theme, any story. Injected dependencies are what make a component portable — which, not coincidentally, is the same portability promise that decides whether something belongs in shared/ at all.
Changing your mind. Swap fetchUser for a different endpoint, replace the analytics provider, feed the table rows from a websocket instead of a query — and the leaf components don't change at all, because they never knew where their data came from. You only touch the wiring layer. The thing you were afraid to change turns out to have one owner, in one place.
The rule I actually carry around
I've stopped asking "is this component too big." It's the wrong question and it leads to smearing. The questions I ask instead are two: how many reasons does this component have to change (that tells me whether to split, and where), and does it reach for its dependencies or get handed them (that tells me whether it'll be testable and reusable, or welded to one spot).
Composition, in the end, isn't about making things small. It's about making the seams fall in the right places — cutting along dependencies, not along line counts — and being deliberate about which component owns the wiring and which just renders what it's told. Do that and your big components stay big and fine, your small ones stay focused, and the word "reusable" starts meaning something instead of being a hope you wrote in a PR description.
Next I want to take this exact idea — the split between "knows about the world" and "just renders" — and hold it up against the pattern that's been arguing about it for a decade: container versus presentational components, and whether that split still makes sense now that we have hooks and Server Components. If you've got a component you're scared to reuse, look at what it reaches for instead of what it's handed. That list is usually the whole reason — tell me what's on yours.