In 05-04 you learned the context mechanism, and in 05-05 you combined it with useReducer, but both lessons ended with the same deferred warning: "context performance and its role as a global state strategy are covered in 07-02." This is that lesson. Here context stops being "the trick for not passing props" and becomes what it really is: a state management strategy with a defined usage pattern, a measurable performance cost, and a clear ceiling. You'll see the complete pattern for a context module per domain, truly understand why a change in a provider re-renders all of its consumers even when they only care about part of the value, apply the three known solutions — splitting contexts, stabilizing the value, and moving state down — refactor CicloUrbano's four contexts and compose them into a single Providers, and finish with an honest list of what context does not solve. That list is the bridge to Redux.
Contents
- Five-line refresher: the mechanism
- The complete pattern of a context module
NoticesProviderin full, annotated- The performance problem, explained properly
- Why an object literal
valuechanges on every render - Solution 1: split contexts by rate of change
- Solution 2: stabilize the value with memoization
- Solution 3: move state down or pass
children - The three solutions compared
- CicloUrbano refactor and the
Providerscomponent - Where context stops: what it doesn't solve
- Context +
useReducer: "homemade Redux" and where it falls short
- Five-line refresher: the mechanism
Without repeating 05-04, here's everything worth keeping in mind:
createContext(defaultValue)creates a channel; the provider<MyContext value={…}>publishes a value to a subtree.useContext(MyContext)looks up the nearest provider above it and returns its value; if there is none, it returns the default value.- In React 19, the context itself is used as the provider:
<ThemeContext value={value}>, with no.Provider. - Context is not state: it's a transport mechanism. State still lives in a
useStateoruseReducerinside the provider. - When the published value changes, every component that calls
useContexton that context re-runs.
That last line is what this lesson unpacks, because it holds the entire cost.
- The complete pattern of a context module
A well-built context isn't a loose createContext call: it's a module per domain with five pieces, always in the same order. It's the structure UserContext and ThemeContext already follow, now formalized.
| Piece | What it is | Rule |
|---|---|---|
| The context | const XContext = createContext(null) |
Not exported. That way nobody can bypass the access hook |
| The state | useState or useReducer inside the provider |
The real owner of the data |
| The exposed value | The object that gets published | Decided deliberately: data, derived values, and actions |
| The provider | export function XProvider({ children }) |
Its own component, in its own file, with its own CSS if it needs one |
| The access hook | export function useX() |
Throws if the context is null: the provider is missing |
// src/contexts/ExampleContext.jsx — the skeleton of the pattern
import { createContext, useContext, useState } from 'react';
// 1) The context: private to the module
const ExampleContext = createContext(null);
// 2), 3) and 4) The provider: owns the state and the value
export function ExampleProvider({ children }) {
const [value, setValue] = useState(null);
const content = { value, setValue };
return <ExampleContext value={content}>{children}</ExampleContext>;
}
// 5) The access hook: the only entry point
export function useExample() {
const context = useContext(ExampleContext);
if (context === null) {
throw new Error('useExample must be used inside <ExampleProvider>');
}
return context;
}Why the throwing hook matters more than it looks: without it, a component placed outside the provider would get the default value — usually null — and would fail later with a Cannot read properties of null, at some other point in the code, with a message that says nothing useful. With the throw, the failure shows up exactly where the mistake is, with the name of the missing provider.
One file per domain. Not a single GlobalContext.jsx bundling session, theme and notices together: that's one provider whose value changes every time any of the three changes, and it's the root cause of the problem in section 4.
NoticesProvider in full, annotated
NoticesProvider in full, annotatedThis is CicloUrbano's notices module written with the full pattern. It appeared sketched out in 05-04; here's the version that stays in the project.
// src/contexts/NoticesContext.jsx
import { createContext, useContext, useState, useCallback } from 'react';
import Notice from '../components/Notice.jsx';
import styles from './NoticesContext.module.css';
const NoticesContext = createContext(null);
const DEFAULT_DURATION = 5000;
export function NoticesProvider({ children }) {
// The real state: a stack of { id, tone, text }
const [notices, setNotices] = useState([]);
// useCallback keeps the function's identity stable across renders.
// The details of why it works are in 08-03; here it's enough to know
// that without it these functions would be new on every render.
const dismissNotice = useCallback((id) => {
setNotices((previous) => previous.filter((notice) => notice.id !== id));
}, []);
const showNotice = useCallback((tone, text, duration = DEFAULT_DURATION) => {
const id = `notice-${crypto.randomUUID().slice(0, 8)}`;
setNotices((previous) => [...previous, { id, tone, text }]);
if (duration > 0) {
setTimeout(() => {
setNotices((previous) => previous.filter((notice) => notice.id !== id));
}, duration);
}
return id;
}, []);
const value = { notices, showNotice, dismissNotice };
return (
<NoticesContext value={value}>
{children}
<div className={styles.stack} role="status" aria-live="polite">
{notices.map((notice) => (
<Notice
key={notice.id}
tone={notice.tone}
text={notice.text}
onClose={() => dismissNotice(notice.id)}
/>
))}
</div>
</NoticesContext>
);
}
export function useNotices() {
const context = useContext(NoticesContext);
if (context === null) {
throw new Error('useNotices must be used inside <NoticesProvider>');
}
return context;
}Decisions worth justifying:
setNoticeswith an updater function in all three calls. It's essential: asetTimeoutthat fires five seconds later can't rely on thenoticescaptured in that render's closure (05-01).- The id is generated in the provider, not passed by the caller. Whoever shows a notice shouldn't have to worry about that.
- The stack renders inside the provider, after
{children}. That way any component in the tree can trigger notices, and there's a single place where they get rendered. role="status"witharia-live="polite"(03-06): notices appear without moving focus, so they need to be announced to screen readers.useCallbackon both actions. It's the first piece of the solution to the performance problem, and the reason section 6 can split the context into an actions context.
- The performance problem, explained properly
Here's the real cost of context, worth stating precisely because it gets repeated inaccurately all the time.
When a provider's value changes, React re-runs every component that consumes that context, even if it only cares about a part of the value that didn't change.
There's no granular selection. useContext means "subscribe me to the entire value." If the value is { notices, showNotice, dismissNotice } and only notices changes, a component that only uses showNotice still re-runs.
Let's look at CicloUrbano's actual case. BookingsContext publishes { state, dispatch }, and state contains bookings, draft, submitState and error. The draft changes on every keystroke in the booking form.
flowchart TD
A["The user types a letter<br/>in BookingForm"] --> B["dispatch draft_updated"]
B --> C["bookingsReducer returns<br/>a new state"]
C --> D["BookingsProvider runs<br/>and publishes a new value"]
D --> E["BookingsPanel<br/>uses bookings"]
D --> F["Header<br/>uses the bookings counter"]
D --> G["BookingsPage<br/>uses bookings"]
D --> H["BookingForm<br/>uses draft"]
D --> I["BookingDialog<br/>only uses dispatch"]
E --> J["Unnecessary re-render"]
F --> J
G --> J
I --> J
H --> K["Necessary re-render"]
style J fill:#fde68a,stroke:#b45309
style K fill:#bbf7d0,stroke:#12805c
Four of the five consumers just re-ran without anything they read having changed. Multiply that by the letters in "Electric Pro" and you get thirteen rounds of re-rendering most of the app just to type a model name.
An important nuance so as not to overstate the problem: re-running a component isn't the same as touching the DOM. React runs the function, diffs the result against the previous one, and only applies to the DOM what actually changed (01-05). If the components are cheap, you won't notice anything. The problem shows up when consumers are expensive — long lists, calculations, large subtrees — or when the value changes very often. And in CicloUrbano's catalogue, with BikeList rendering cards, both conditions are met.
- Why an object literal
value changes on every render
value changes on every renderThere's a second problem, more subtle and very easy to fall into without noticing: the value can "change" without the state changing.
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
function signIn(id) { /* … */ }
function signOut() { setUser(null); }
// ⚠️ New object literal on EVERY render of the provider
const value = {
user,
isOperator: user?.role === 'operario',
signIn,
signOut
};
return <UserContext value={value}>{children}</UserContext>;
}React decides whether to notify consumers by comparing the new value with the previous one via Object.is, i.e. by reference identity. And { user, … } builds a new object every time the function runs. Even if user is exactly the same object, value is a different reference, and every consumer re-renders.
The same happens with signIn and signOut: they're functions redeclared on every render, with a new identity each time.
When does the provider re-run? Whenever its parent re-renders. And here's the trap: if UserProvider sits inside a component that re-renders for some unrelated reason — a route change, a theme change — it will publish a new value, and every session consumer will re-render without the session having changed at all.
// Identity comparison between two provider renders
// Render 1: value === { user: usr01, isOperator: false, … } ← reference A
// Render 2: value === { user: usr01, isOperator: false, … } ← reference B
// Object.is(A, B) → false ⇒ React notifies every consumerThis is the flaw solution 2 fixes. But first, the one that gives the most performance for the least code.
- Solution 1: split contexts by rate of change
The idea is simple and very effective: if a value has parts that change at different rates, publish them in separate contexts. Each consumer subscribes only to what it needs.
In BookingsContext there's an obvious, free split: state changes constantly, while dispatch is stable by React's guarantee — you saw this in 05-05: useReducer always returns the same dispatch function for the entire lifetime of the component. Splitting them makes every component that only dispatches immune to state changes.
// src/contexts/BookingsContext.jsx
import { createContext, useContext, useReducer } from 'react';
import { bookingsReducer, INITIAL_BOOKINGS_STATE } from '../reducers/bookings.js';
// Two contexts: one for what changes and one for what doesn't
const BookingsStateContext = createContext(null);
const BookingsActionsContext = createContext(null);
export function BookingsProvider({ children }) {
const [state, dispatch] = useReducer(bookingsReducer, INITIAL_BOOKINGS_STATE);
return (
// dispatch is STABLE: this provider never publishes a new value for it
<BookingsActionsContext value={dispatch}>
<BookingsStateContext value={state}>
{children}
</BookingsStateContext>
</BookingsActionsContext>
);
}
/** Reads the bookings state. Re-renders when the state changes. */
export function useBookingsState() {
const context = useContext(BookingsStateContext);
if (context === null) {
throw new Error('useBookingsState must be used inside <BookingsProvider>');
}
return context;
}
/** Gets dispatch. NEVER causes a re-render from a state change. */
export function useBookingsActions() {
const context = useContext(BookingsActionsContext);
if (context === null) {
throw new Error('useBookingsActions must be used inside <BookingsProvider>');
}
return context;
}Key points of this version:
- The actions context publishes
dispatchdirectly, not{ dispatch }. A wrapping object would reintroduce the identity problem from section 5 — precisely the one we're avoiding. - The nesting order barely matters functionally, but the actions context sits outside for clarity: it's the one that never changes.
- The throwing hook is kept, now duplicated. It's acceptable repetition: each hook documents its cost in its own name.
The effect on the diagram from section 4 is direct: BookingDialog, which only dispatches, stops re-rendering when typing in the form. And in a component that dispatches from a handler — the most common case — the improvement multiplies by however many "write-only" consumers the app has.
The split also applies by domain, not just state/actions. If you had a single GlobalContext today with session, theme and notices, every notice would re-render the session's readers. That CicloUrbano has had four separate contexts since 05-04 is, in fact, already an application of this rule.
Compatibility with existing code. If you'd rather not touch every current consumer at once, keep a convenience useBookings() for the duration of the migration:
/** Compatibility: returns { state, dispatch } as before.
* Subscribes to BOTH contexts, so it saves no re-renders.
* Use it only while you migrate components. */
export function useBookings() {
return { state: useBookingsState(), dispatch: useBookingsActions() };
}It's honest to admit what it does: it doesn't optimize anything. It's there to migrate incrementally, and it should disappear once the last component uses the granular hooks.
- Solution 2: stabilize the value with memoization
The second solution attacks the problem from section 5: preventing the value from changing identity when its contents haven't changed.
// src/contexts/UserContext.jsx — stabilized value
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { users } from '../data/domain.js';
const UserContext = createContext(null);
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loadingSession, setLoadingSession] = useState(true);
// Stable identity: not redeclared on every render
const signIn = useCallback((id) => {
const found = users.find((candidate) => candidate.id === id);
if (found) setUser(found);
}, []);
const signOut = useCallback(() => setUser(null), []);
// The object only gets rebuilt if user or loadingSession changes
const value = useMemo(
() => ({
user,
isOperator: user?.role === 'operario',
loadingSession,
signIn,
signOut
}),
[user, loadingSession, signIn, signOut]
);
return <UserContext value={value}>{children}</UserContext>;
}What it achieves: if UserProvider re-runs for a reason unrelated to the session, useMemo returns the same object as before, Object.is returns true, and React doesn't notify any consumer.
What it does not achieve: if the session does change, every consumer re-renders just the same, including the ones that only use signOut. Stabilizing avoids spurious re-renders; it doesn't give granular selection. That's what solution 1 is for.
useMemo and useCallback get a full treatment in 08-03: what exactly they memoize, when they pay off, and when they're noise. Here, the operating rule is enough:
Every provider that publishes an object literal as
valuemust stabilize it withuseMemo, along with any functions that object contains, withuseCallback. It's one of the few places where memoizing is the default choice, not a premature optimization.
- Solution 3: move state down or pass
children
childrenThe third solution doesn't touch the context at all: it changes the structure so fewer components depend on it.
8.1 Move state down
If a piece of state living in a provider is actually only used by a small area of the tree, move it down. It's the "move it down" idea from section 5 of 07-01, applied to a context.
// BEFORE: the search term in a context read by the whole app
// AFTER: in the component that uses it
function BikeSearch({ onSearch }) {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 400);
// …
}If the data doesn't need to live up there, pulling it out of the context eliminates the problem instead of mitigating it. It's the only one of the three solutions that reduces complexity instead of adding to it.
8.2 Pass children to isolate subtrees
This trick is less well known and very powerful. A component that re-runs doesn't force the children it received as children to re-run, because those elements were already created from outside: their identity hasn't changed.
// BEFORE: the provider builds its own content, so everything re-runs with it
function ThemeProvider() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={{ theme, setTheme }}>
<Header />
<Catalogue /> {/* re-runs on every theme change, whether it uses it or not */}
</ThemeContext>
);
}
// AFTER: the content comes from outside and doesn't re-run on a theme change
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext value={value}>{children}</ThemeContext>;
}With the second version, a theme change re-runs ThemeProvider, but children is the same React element that already existed: React skips it, and only the components that actually consume ThemeContext re-run. That's the technical reason a provider should always receive children instead of building its own content, and why the pattern in section 2 requires it.
- The three solutions compared
| 1. Split contexts | 2. Stabilize with memoization | 3. Move state down / children |
|
|---|---|---|---|
| What problem it solves | Consumers re-rendering for parts of the value they don't use | Spurious re-renders from a new identity with no real change | Having extra consumers at all |
| Effort | Medium: two contexts and two hooks per domain | Low: useMemo + useCallback in the provider |
Variable: may require restructuring |
| Payoff | High when there are many "write-only" consumers | Medium: removes the noise, not the real cascade | The biggest: the problem disappears |
| Risk | More files and hooks to remember | Misdeclared dependencies in useMemo |
Reintroducing prop drilling if you overdo it |
| When to use it | State that changes often + stable actions | Always, in any provider with an object literal | When the data didn't need to live up there |
They're not mutually exclusive: CicloUrbano applies all three at once. Solution 2 is mandatory in every provider, solution 1 only where the value has mixed rates of change, and solution 3 is the review you do when classifying state (07-01).
- CicloUrbano refactor and the
Providers component
Providers componentWith the rules applied, here's how the context map looks.
| Context | State | Rate of change | Treatment |
|---|---|---|---|
ThemeContext |
theme |
Very low | useMemo on the value. No need to split |
UserContext |
user, loadingSession |
Very low | useMemo + useCallback. No need to split |
NoticesContext |
notices |
Medium | useCallback on the actions + state/actions split |
BookingsContext |
Reducer state | High (every keystroke) | Split is mandatory: state and dispatch separated |
And the problem that's left: main.jsx and Layout accumulate a pyramid of nested providers that grows with every new domain. The solution is a composition component.
// src/contexts/Providers.jsx
import { ThemeProvider } from './ThemeContext.jsx';
import { UserProvider } from './UserContext.jsx';
/**
* CicloUrbano's global providers, in their correct order.
* The ones that depend on the router (notices, bookings) live in Layout.
*/
export function Providers({ children }) {
return (
<ThemeProvider>
<UserProvider>
{children}
</UserProvider>
</ThemeProvider>
);
}// src/main.jsx — with the composition applied
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { router } from './routes.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import { Providers } from './contexts/Providers.jsx';
import { reportError } from './utils/monitoring.js';
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<ErrorBoundary title="CicloUrbano isn't available right now" onLog={reportError}>
<Providers>
<RouterProvider router={router} />
</Providers>
</ErrorBoundary>
</StrictMode>
);flowchart TD
A["StrictMode"] --> B["ErrorBoundary"]
B --> C["Providers<br/>(Theme + User)"]
C --> D["RouterProvider"]
D --> E["Layout (root route)"]
E --> F["BookingsProvider<br/>state + actions separated"]
F --> G["NoticesProvider"]
G --> H["Header · Outlet · Footer"]
Two placement rules you already know from 06-02 and 06-03, and that the composition mustn't break:
ThemeProviderandUserProvidergo outside the router, because they must survive absolutely everything, including the route error screen.BookingsProviderandNoticesProvidergo insideLayout, the root route, because they belong to the app's frame andLayoutdoesn't unmount on navigation.
The nesting order has one more rule: if a provider consumes another, it goes further in. BookingsProvider might need the user to associate a booking with them; the user, on the other hand, needs nothing from bookings. That's why user goes outside.
- Where context stops: what it doesn't solve
Context is an excellent tool for what it does, and there's no need to replace it out of habit. But it has a ceiling, and these are the five specific things it does not provide.
| Gap | What it means in practice |
|---|---|
| Debugging tools | There's no panel showing you what changed, when, and who triggered it. Debugging means console.log and the Profiler |
| Middleware | There's no single point to intercept every change to log, measure, persist, or check permissions. You have to repeat it in every provider |
| Time travel | You can't rewind to a previous state to reproduce a bug. No undo/redo without building it yourself |
| Granular selection | useContext subscribes you to the whole value. Splitting contexts mitigates this, it doesn't solve it: there's no "subscribe me only to state.bookings" |
| Organized async logic | Each provider handles its own requests its own way. There's no shared convention for loading, errors, and cancellation |
The signs that it's time to consider a dedicated library:
- You've already split your contexts and you're still seeing re-renders that shouldn't happen.
- You have more than six or eight providers and the nesting order between them starts to matter in subtle ways.
- You need to know what caused a state change, and
console.login the reducer has stopped being enough. - Several providers need to react to the same event — "the session ended, clear bookings, notices, and drafts" — and you're passing functions from one provider to another.
- The team has grown and you need a shared convention, not each domain inventing its own pattern.
- You want to persist part of the state, or log every change for diagnostics, and you don't want to write it four times.
If none of that sounds like you, context is enough, and adding Redux would be premature globalization (07-01). If three or more sound like you, keep reading the module.
- Context +
useReducer: "homemade Redux" and where it falls short
useReducer: "homemade Redux" and where it falls short05-05 said that Redux's model is exactly useReducer's model, and that's literally true. With useReducer + context you already have:
- A centralized state per domain (the reducer's).
- Changes made only through descriptive actions, never loose assignments.
- A pure reducer that concentrates every transition in one place and can be tested without React.
- A stable
dispatchavailable across the whole tree.
Those are the three principles of Redux, which you'll see spelled out in the next lesson. The difference isn't conceptual, it's about infrastructure:
Context + useReducer |
Redux Toolkit | |
|---|---|---|
| Mental model | Identical | Identical |
| Scope | One reducer per domain, each in its own provider | A single store combining several reducers |
| Subscription | To the entire context value | Per selector, with identity comparison |
| Debugging | Manual | DevTools with history and time travel |
| Intercepting changes | No single point | Middleware |
| Async logic | Each domain solves it its own way | createAsyncThunk with a shared pattern |
| Lives outside React | No: the state is in the tree | Yes: the store is an independent object |
| Cost | Zero dependencies | One dependency and a vocabulary |
The last row of the comparison matters more than it looks. In context + useReducer, the state lives inside the React tree: if the provider unmounts, the state disappears, and nothing outside a component can read it. Redux's store is a plain JavaScript object that exists independently of React; it can be read from a utility, from a request interceptor, or from a test, without mounting anything.
That said, and to close honestly: context + useReducer is a perfectly valid solution for a great many applications, and CicloUrbano would work fine with it indefinitely. What comes next in the module isn't a correction of a mistake: it's what you gain as the application and the team grow.
Common Mistakes and Tips
Mistake 1: publishing an object literal without useMemo. It's the most common mistake, and the quietest one, because the app still works — it's just slower than it should be, and nobody knows why. Fixed rule: object literal in value ⇒ useMemo.
Mistake 2: a single GlobalContext for everything. It bundles data with incompatible rates of change and guarantees that a notice re-renders the session's readers. One file per domain.
Mistake 3: exporting the context in addition to the hook. The moment it's exported, someone will use it with useContext directly and bypass the provider check. Keep it private to the module.
Mistake 4: having the provider build its own content instead of receiving children. It cancels out the isolation from section 8.2 and forces the entire subtree to re-run with every provider change.
Mistake 5: wrapping dispatch in an object. value={{ dispatch }} creates a new reference on every render and throws away the stability React was giving you for free. Publish it as is.
Mistake 6: believing that splitting contexts gives you granular selection. Split by rate of change, not into a hundred single-field contexts. If you genuinely need to subscribe to one specific field of a large object, that's exactly sign 1 from section 11.
Tip 1: name hooks after their cost. useBookingsState and useBookingsActions tell you in their name what you're subscribing to. Whoever reads the component knows whether it will re-render.
Tip 2: check the effect with the Profiler. Before and after splitting a context, record with React DevTools Profiler (08-05) and compare. It's the only way to know whether the refactor actually helped.
Tip 3: don't split ahead of time. Start with one context per domain and a memoized value. Split into state and actions once the state starts changing often, not before.
Exercises
Exercise 1. This provider has four of the problems covered in the lesson. Identify them and rewrite it applying the full pattern.
// src/contexts/ThemeContext.jsx
import { createContext, useState } from 'react';
export const ThemeContext = createContext('light');
export function ThemeProvider() {
const [theme, setTheme] = useState('light');
function toggleTheme() {
setTheme(theme === 'light' ? 'dark' : 'light');
}
return (
<ThemeContext value={{ theme, toggleTheme }}>
<Header />
<Catalogue />
<Footer />
</ThemeContext>
);
}Exercise 2. Split NoticesContext into state and actions following the BookingsContext pattern, and explain which CicloUrbano components benefit from the split. Keep in mind that showNotice and dismissNotice are two functions, not one: think about how to publish the actions context without reintroducing the identity problem.
Exercise 3. In CicloUrbano, Header shows a badge with the number of active bookings, and UserMenu shows the user's name. With the current implementation, typing a letter in BookingForm re-renders both. Explain why it happens in each case, and propose the specific fix for each one.
Solutions
Solution 1. The four problems:
- The context is exported (
export const ThemeContext), so anyone can use it withuseContextand bypass the check. - There's no access hook that throws if the provider is missing. And the default value is
'light', a string, while the real value is an object: whoever uses it outside the provider gets'light'and fails ontheme.theme, with an unintelligible error. - The provider builds its own content instead of receiving
children, so every theme change re-runsHeader,CatalogueandFootereven though they don't consume the context. - The value is an unmemoized object literal, and
toggleThemeis redeclared on every render. Also,toggleThemereadsthemefrom the closure instead of using the updater function.
// src/contexts/ThemeContext.jsx — fixed
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
const ThemeContext = createContext(null); // 1) private to the module
export function ThemeProvider({ children }) { // 3) receives children
const [theme, setTheme] = useState('light');
// 4) stable identity + updater function
const toggleTheme = useCallback(() => {
setTheme((previous) => (previous === 'light' ? 'dark' : 'light'));
}, []);
// Syncs the attribute used by the CSS variables in index.css
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
// 4) stabilized value
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext value={value}>{children}</ThemeContext>;
}
// 2) access hook that throws
export function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used inside <ThemeProvider>');
}
return context;
}Solution 2. The key is in the prompt: since there are two functions, publishing them as { showNotice, dismissNotice } would create a new object on every render. That actions object needs to be memoized too — and now it really is stable, because both of its members carry useCallback with empty dependencies.
// src/contexts/NoticesContext.jsx — split
const NoticesStateContext = createContext(null);
const NoticesActionsContext = createContext(null);
export function NoticesProvider({ children }) {
const [notices, setNotices] = useState([]);
const dismissNotice = useCallback((id) => {
setNotices((previous) => previous.filter((notice) => notice.id !== id));
}, []);
const showNotice = useCallback((tone, text, duration = DEFAULT_DURATION) => {
const id = `notice-${crypto.randomUUID().slice(0, 8)}`;
setNotices((previous) => [...previous, { id, tone, text }]);
if (duration > 0) {
setTimeout(() => {
setNotices((previous) => previous.filter((notice) => notice.id !== id));
}, duration);
}
return id;
}, []);
// Memoized object with effectively empty deps: never changes identity
const actions = useMemo(
() => ({ showNotice, dismissNotice }),
[showNotice, dismissNotice]
);
return (
<NoticesActionsContext value={actions}>
<NoticesStateContext value={notices}>
{children}
<NoticeList />
</NoticesStateContext>
</NoticesActionsContext>
);
}
export function useNoticesState() { /* … throws if null … */ }
export function useNoticesActions() { /* … throws if null … */ }Who benefits from the split: every component that only triggers notices and never reads them, which in CicloUrbano is almost everyone — BookingForm, NewBookingPage, BookingsPanel, SignInPage, BookingDialog. Before, every notice shown would re-render all of them; now only NoticeList re-renders, since it's the only one reading the stack. The gain is significant because notices appear and disappear on their own with a timer: every notice used to trigger two rounds of app-wide re-rendering.
Solution 3. These are two different causes, and that's why they need two different fixes.
Header with the bookings counter. It consumes BookingsContext to compute bookings.filter((b) => b.status === 'activa').length. Since useContext subscribes to the whole value and draft is part of that value, every keystroke publishes a new state and re-renders the entire Header — with its nav, its NavLinks and its UserMenu.
Solutions, from smallest to biggest:
- Split the state inside the reducer: publish
bookingsanddraftin separate contexts, soHeaderonly subscribes to the list. It's solution 1 taken one step further, and it works, but you can feel yourself fighting the lack of granular selection. - Extract the badge into its own component,
BookingsBadge, which consumes the context, and letHeadernot consume it. That way the re-render stays contained to a<span>instead of the whole header. Cheap and effective. - The real solution is the one from section 11: this need — "I want to subscribe only to
state.bookings" — is exactly sign 1. WithuseSelector(07-05) it's expressed in one line, with no splitting needed.
UserMenu with the user's name. This one shouldn't re-render at all, since it consumes UserContext and the session hasn't changed. If it does, it's for one of these two reasons:
UserProvider's value isn't memoized (section 5): the provider re-runs for an unrelated reason and publishes a new object. Fix:useMemo+useCallback, as in section 7.UserMenualso consumesBookingsContext— for example to show "you have 2 active bookings." Then the re-render isn't coming from the session, and the fix is the same asHeader's.
The conclusion that matters: Header is a structural problem of context that can only be mitigated; UserMenu is an avoidable bug fixed with memoization. Telling the two apart is what keeps you from useless refactors.
Conclusion
Context is a complete state management strategy when used with the full pattern: one file per domain, the context private to the module, the state in a useState or useReducer inside the provider, an exposed value decided deliberately, the provider as its own component that receives children, and an access hook that throws if the provider is missing. NoticesProvider is the complete example of that pattern, and Providers is the answer to the pyramid of nesting that grows with every new domain.
On cost, now you know what really happens: any consumer re-runs when the provider's value changes, even if it only cares about part of it, because useContext offers no granular selection; and on top of that, the value can "change" without changing, because an object literal has a new identity on every render and React compares with Object.is. The three solutions are complementary: splitting contexts by rate of change and separating state from actions — taking advantage of dispatch being stable by React's guarantee — stabilizing the value with useMemo and useCallback, which in a provider isn't premature optimization but an obligation, and moving state down or passing children so the subtree stops depending on the provider, which is the only one of the three that reduces complexity instead of adding to it. CicloUrbano applies all three: theme and user memoized, notices and bookings split into state and actions, and everything composed in a Providers that respects the placement rules from 06-02 and 06-03.
And you know where the ceiling is. Context doesn't give you debugging tools, a single point to intercept changes, time travel, granular selection, or a shared convention for async logic. Context + useReducer is, conceptually, Redux: centralized state, changes only through actions, a pure reducer. What it's missing isn't the model, it's the infrastructure built around that model, starting with a store that lives outside the React tree and a panel that shows you which action changed what. That's what comes next: what Redux is, exactly what problem it solves, why Redux Toolkit is today the only sensible way to use it, and how CicloUrbano's store is set up. The next lesson is Redux: Introduction and Setup.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
