Prop Drilling, Context and Global State
Three ways to get a value from where it lives to where it is needed. Each buys something and each charges for it, and none of them is the default answer.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
This value lives four levels up from where I need it. What is the honest cost of each way of getting it there?
A person changes a setting in one part of the interface and expects the rest of it to agree. The mechanism by which the value travels is invisible to them and decisive for everyone maintaining the code.
Prop drilling is a code smell, context is the fix, and once context gets awkward you install a global store. Move state upward and outward until the awkwardness stops.
The escalation has no brake. Each step trades a real, visible cost for a diffuse, invisible one, so it always feels like progress — right up to the point where every component reads from a store and nothing can be reasoned about locally.
- The escalation has no brake. Each step trades a real, visible cost for a diffuse, invisible one, so it always feels like progress — right up to the point where every component reads from a store and nothing can be reasoned about locally.
- Prop drilling through two components is not a smell; it is the most traceable data flow available. You can see every hop in the source, and the compiler checks each one (What a Component Owes Its Caller).
- Context solves the distance problem and creates a coupling problem: every consumer is now bound to a provider that must exist above it, so the component cannot be rendered, tested or storybooked without ceremony.
- Context in React re-renders every consumer when the value identity changes, so a provider holding an object literal re-renders the whole subtree on every parent render (What a Component Costs to Render).
- A global store makes every read invisible in the tree. "Who changes this" becomes a repository-wide search instead of a look at the props, and the answer is often "eleven places" (Debugging State).
- A store also outlives the components that use it, so state that should have died with a screen persists across navigation and shows up as a stale value on the third visit (State Synchronization).
What is actually happening
In the browser, not in the framework.
- Prop drilling passes a value explicitly through each intermediate component. Cost is proportional to depth and is entirely visible: every hop is a line of code, a type, and a place a reviewer can see the dependency.
- Context provides a value at a subtree root and lets any descendant read it without the intermediate components knowing. It removes the hops and introduces an ambient dependency — the consumer now requires an ancestor it does not name in its own signature.
- A global store puts the value outside the component tree entirely. Any component anywhere can read or write it, across route boundaries and across sibling trees that share no ancestor.
- The re-render behaviour is framework-specific and matters. In React, context propagation bypasses
memoand re-renders all consumers when the provider value changes identity. In Vue,provide/injectsupplies a reactive reference and only the effects that actually read it re-run. Signal-based systems (Solid, Svelte 5, Angular signals) subscribe at the value, so a store read is as fine-grained as a prop (Reactivity Models). - The real axis is not "how far does the value travel" but who is allowed to change it. Prop drilling has one writer and a visible path. Context usually has one writer and an invisible readership. A global store has an unbounded set of writers, and that is the property that makes it expensive rather than the storage location (Shared Mutable State).
- And the prior question is whether this is client state at all. A large share of "we need global state" is server state that wanted a cache keyed by request, not a store (Server State Is Not Your State).
What this makes the browser do
And which of it is avoidable.
- Prop drilling: none beyond the framework re-evaluating the intermediate components, which in a virtual-DOM framework is a diff that produces no DOM mutations when nothing changed.
- Context: none in the browser. In React, the cost is framework work — every consumer function re-runs — and the DOM only sees mutations where output actually differed.
- Global store: the subscription mechanism decides. A store that notifies every subscriber on every change makes the browser do nothing but makes the main thread do a lot, and on a slow device that is the same thing to the user (Long Tasks).
- All three produce identical DOM when the output is identical. The distinction is entirely about how much JavaScript runs to discover that nothing changed (The Real Cost of JavaScript).
Three mechanisms, three bills
The reason this question never settles is that the three options are not ranked. They are points on a trade between explicitness and reach, and moving along that trade is neither progress nor regression on its own — it depends on which of the two you currently need more.
The one thing worth being firm about: the escalation is not automatic. "This is getting annoying to pass down" is a reason to look at the tree shape first, and only then at a mechanism. Restructuring with composition solves a surprising share of these cases at no ongoing cost (Composition and Slots).
A value lives in one place and is needed in several. Which mechanism, and what does it cost?
when Two or three levels, a small number of readers, and the path is meaningful — the intermediate components are related to the value.
cost Verbosity, and every new value means editing every component on the path. The friction is real and it is also the signal.
when The intermediate components do not care about the value; they only pass it. Pass content instead and the hops disappear.
cost You are changing the shape of the tree, which is a bigger diff than adding a provider and requires understanding why the tree is shaped that way.
when The readers have a common ancestor much lower than where the state currently lives.
cost Nothing, usually — this is often the fix that was skipped. It fails when the readers genuinely have no useful common ancestor (Who Owns This State?).
when Genuinely ambient to a subtree, changes rarely, has one writer: theme, locale, current user, a form instance, compound-component coordination.
cost Consumers gain an unnamed ancestor dependency, tests gain setup, and in React every consumer re-renders on value-identity change unless you split or stabilise.
when The value came from the server. This is the most common misdiagnosis in the list.
cost A caching library and its key discipline — which you were going to reimplement in the store anyway, less well (Server State Is Not Your State).
when The value should survive a refresh, be shareable, or participate in back/forward: filters, tabs, pagination, selection.
cost Serialisation, validation of untrusted input, and a length limit. In exchange the state is shared, persistent and free (The URL Is Application State).
when Genuinely cross-tree: routes with no common ancestor, portals, widgets mounted separately. A specific justification, not a starting position.
cost Traceability. Any module can write it, it outlives the components that use it, it is visible to every script on the page, and "what changed this" becomes a repository-wide search.
What each one actually looks like in the tree
Drawing the three side by side makes the trade visible. Props are edges you can see. Context is an edge from a provider to consumers that the components in between never mention. A store is not in the tree at all, which is exactly the point and exactly the cost.
Note what changes about debugging in each. With props, you find the source by walking up. With context, you find it by locating the provider, which is at least one specific place. With a store, you find it by searching for every write, and the number of writes is unbounded by construction.
The failures each one actually produces
These are not hypothetical. Each row is a bug that a team hits after the mechanism has been in place long enough for the original decision to be forgotten, which is the timescale that matters.
The pattern across the rows: prop drilling fails loudly and locally, context fails at the provider boundary, and a store fails late, far from the write, and in a way that reproduces only after a particular sequence of navigations.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A new value is needed six levels down | Six files change; review is noisy; someone types the prop name wrong | Explicit paths cost proportional to depth | Check whether composition removes the middle entirely before adding a mechanism (Composition and Slots). |
| Provider value is an object literal | Every consumer re-renders on every parent render; typing lags | React compares context value by identity | Stabilise the value, split contexts by change frequency, and verify with a profile (What a Component Costs to Render). |
| One context holds many unrelated values | Toggling a sidebar re-renders the entire authenticated app | Consumers subscribe to the whole value, not to the field they read | Split by change frequency; ambient-and-rare and hot-and-local do not belong together. |
| Component rendered outside its provider | Works in the app, throws in a test or a storybook entry | The dependency is real but absent from the component's signature | Throw a named error from the hook, and provide a test helper alongside the provider. |
| Screen state kept in a global store | Filters from a previous visit reappear; stale data flashes on mount | Store lifetime is the tab, not the screen | Scope it to the route, or clear it on unmount and accept that this must be remembered every time (Long-Lived Clients and Version Skew). |
| Server data cached in a store by hand | Two views disagree; a mutation updates one list and not another | Manual invalidation across an unbounded set of writers | Move it to a server-state cache with real keys (Query Keys and Invalidation). |
| Two components write the same slice in one tick | Order-dependent result that reproduces on one machine only | Unbounded writers with no ordering guarantee | Give the slice one writer and an action-shaped API; readers can stay unbounded (Shared Mutable State). |
How to build it
Most important first.
- Start by asking what kind of state this is. Server data, URL state, form state and ephemeral UI state have different natural homes, and picking the home first removes most of this question (The Seven Kinds of State).
- Put state at the lowest common ancestor of its readers, and leave it there until something forces it up (Who Owns This State?).
- Accept prop drilling for two or three levels. It is explicit, type-checked, greppable, and it makes an ugly-looking dependency visible instead of hiding it.
- Before drilling further, try restructuring: passing
childrenthrough often removes the intermediate components from the path entirely, because the content is created where the data already is (Composition and Slots). - Use context for things that are genuinely ambient to a subtree and change rarely: theme, locale, the current user, a form instance, a compound component's coordination. Keep it private to the component that provides it where possible.
- Split contexts by change frequency. One context holding
{ user, theme, sidebarOpen }re-renders every consumer when the sidebar toggles; three contexts do not. - Reach for a store when state is genuinely cross-tree — shared by routes with no common ancestor, or by widgets rendered in different portals — and treat that as a specific justification, not a starting architecture.
- Whatever you choose, write down who is allowed to write. A single writer with many readers is manageable at any of the three; many writers is hard at all three, and only looks easy in the store (Immutability as a Concurrency Strategy).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Ambient state that affects presentation must reach every consumer, or part of the interface disagrees with the rest. A theme applied through context but read by one component from a stale copy produces a contrast failure in exactly one place (Contrast, Colour and Motion).
- Reduced-motion, high-contrast and font-size preferences are the classic ambient values. They belong in context or in CSS custom properties, not drilled — and they must be read live, because the user can change them while the page is open (Custom Properties).
- Focus state must never live in a global store. Focus is owned by the DOM; mirroring it into a store creates two sources of truth and the DOM always wins (Focus Management).
- A store-driven route change can unmount the element that currently has focus, dropping focus to
bodywith no announcement. Whatever moves state must also own moving focus (Focus Management). - Live-region text driven by a global store announces on every write, including writes from parts of the app the user is not looking at. Announcements need an owner as much as state does (Live Regions and Announcement).
- Locale is ambient and affects text direction, formatting and pluralisation. Drilling it is impractical; a store makes it changeable from anywhere, which is worse. Context is the honest fit here (Internationalization).
What can go wrong
- The mega-context: one provider holding everything, so a keystroke in a search field re-renders the navigation, the sidebar and the footer.
- The unstable provider value:
value={{ user, logout }}creates a new object every render, so every consumer re-renders even whenuseris unchanged. The fix is stabilising the value, and it has to be done deliberately. - The missing provider: a component works in the app, throws in a test, and the error names a context nobody wrote in that file.
- Store state that outlives its screen: a filter set on one page is still set when the user returns twenty minutes later, because nothing unmounted it (Long-Lived Clients and Version Skew).
- The store as a cache: server data in a global store, manually kept fresh, gradually reimplementing invalidation, deduplication and retry — badly (Query Keys and Invalidation).
- The mitigation failing: you split the context into five, and now a component that needs three of them has three providers to mock and three subscriptions, and the re-render win was inside noise anyway.
- Drilling as denial: fourteen levels of pass-through props, each one forwarded by a component that never reads it. That is a real smell, and it is the one this lesson does not defend (Over-Componentization).
- Two components writing the same store slice in the same tick produce a last-write-wins result that depends on subscription order, which is a genuine data race in a single-threaded language (Shared Mutable State).
- A store write during render — allowed by some libraries, warned about by others — can be observed inconsistently by components that already rendered in the same pass.
- Async updates landing after a route change write into a store that outlived the screen, so a stale response mutates state the user can still see (Out-of-Order Responses).
- With multiple tabs, a store persisted to storage can be written by another tab between two reads in this one (Auth Across Tabs).
- A global store is readable from every module in the bundle, including any third-party script that can reach the same JavaScript context. Nothing sensitive belongs there (Third-Party Scripts and the Supply Chain).
- Auth tokens in particular: a store puts them in a script-readable location by construction, which is the property that makes an
HttpOnlycookie a different security posture rather than a stylistic alternative (Cookies vs Script-Readable Tokens). - Persisting a store to
localStoragefor convenience persists whatever was in it, including user data that was only ever meant to live for a session (Storage Security and Durability). - None of the three is an authorization mechanism. A permissions object in context tells the UI what to render; the server decides what is allowed (Authorization-Aware UI).
- "Prop drilling is always bad." Two or three explicit hops are the most debuggable data flow in the codebase. Fourteen hops through components that do not read the value is the actual problem, and it is a decomposition problem (Over-Componentization).
- "Context is global state." It is subtree-scoped and dies with its provider. Treating it as a store is how a context ends up holding forty unrelated values.
- "A global store is the modern default." It is the option with the highest traceability cost, and this domain does not recommend it as a default. It is what you reach for when state is genuinely cross-tree.
- "Context is slow." Context propagation is a framework mechanism whose cost depends on the framework and on how many consumers there are. In Vue and in signal-based frameworks the question barely arises (Reactivity Models).
- "If it is used in three places, it is global." It might be server data that wanted a cache, or URL state that wanted the URL. Both are shared without being global (The URL Is Application State).
- "Putting it in a store makes it shared." It makes it reachable. Shared implies agreement about who writes it, and the store does not supply that (Shared Mutable State).
Measuring it, and what changes in the field
- A profiler recording of one interaction, reading which components re-evaluated and why. Context-driven re-renders show up as a wide band of components that produced no DOM change (What a Component Costs to Render).
- Count the consumers of each context. A context with two consumers did not need to be a context; one with two hundred is an architectural fact worth knowing.
- Count the writers of each store slice. Readers scale fine; writers are what make state untraceable, and the count is a grep away (Debugging State).
- Trace one value end to end in devtools. If you cannot answer "what set this" in under a minute, the traceability cost is already being paid (A Method for Frontend Bugs).
- Watch interaction latency on the interactions that touch shared state. If a store write shows up as a long task, the subscription granularity is the thing to look at (Interaction Responsiveness).
- On a slow device, wide context re-renders become visible input lag on typing, because every keystroke re-runs every consumer before the browser can paint (Long Tasks).
- On a large tree, the cost scales with consumer count rather than depth — a shallow tree with four hundred consumers is worse than a deep one with four.
- In a signals-based framework, most of the re-render argument evaporates and the traceability argument remains unchanged. That asymmetry is why this is a design question and not a performance question (Reactivity Models).
- In tests, context and stores are setup cost per test; props are not. A component that needs four providers to render is a component that is expensive to test and therefore under-tested (Component Testing).
- In a long-lived tab, store state accumulates across navigations. Anything scoped to a screen must be explicitly cleared, and "explicitly" is the part that gets forgotten (Long-Lived Clients and Version Skew).
- With server rendering, a module-level store is a shared mutable singleton on the server, where it is shared across requests unless the framework isolates it per request (Server-Side Rendering).
- Props cost verbosity and refactor friction: adding one value means touching every component on the path. That friction is also the feedback signal telling you the value is travelling too far.
- Context costs testability and locality. Components become non-portable, providers become setup, and in React the re-render granularity is coarse unless you split or stabilise deliberately.
- A store costs traceability, which is the most expensive currency in a codebase you did not write. It buys cross-tree sharing, which sometimes you genuinely need — and no amount of discipline fully restores what it spent.
- Restructuring with composition is often the cheapest fix and the least likely to be tried, because it means changing the shape of the tree rather than adding a mechanism to work around the shape.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe traceability trade — explicit hops versus ambient access versus unbounded reach — is a property of the data flow, not of any framework, and applies equally in a codebase with no framework at all.
- FRAMEWORK-SPECIFICThe render-cost half is not portable. React's context re-renders all consumers on value-identity change and bypasses
memo; Vue'sprovide/injecthands over a reactive ref so only the effects that read it re-run; Solid, Svelte 5 and Angular signals subscribe at the value, making a store read as fine-grained as a prop. Advice tuned to React's behaviour is simply wrong in the others.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — this is dependency direction wearing frontend clothes. A store lets a leaf depend on anything, which is exactly the coupling that module boundaries exist to prevent.
- — Distributed Systems — a global store shared across tabs has the same shape as replicated state: multiple writers, no total order, and a merge nobody designed.