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

  1. Five-line refresher: the mechanism
  2. The complete pattern of a context module
  3. NoticesProvider in full, annotated
  4. The performance problem, explained properly
  5. Why an object literal value changes on every render
  6. Solution 1: split contexts by rate of change
  7. Solution 2: stabilize the value with memoization
  8. Solution 3: move state down or pass children
  9. The three solutions compared
  10. CicloUrbano refactor and the Providers component
  11. Where context stops: what it doesn't solve
  12. Context + useReducer: "homemade Redux" and where it falls short

  1. 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 useState or useReducer inside the provider.
  • When the published value changes, every component that calls useContext on that context re-runs.

That last line is what this lesson unpacks, because it holds the entire cost.

  1. 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.

  1. NoticesProvider in full, annotated

This 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:

  • setNotices with an updater function in all three calls. It's essential: a setTimeout that fires five seconds later can't rely on the notices captured 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" with aria-live="polite" (03-06): notices appear without moving focus, so they need to be announced to screen readers.
  • useCallback on 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.

  1. 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.

  1. Why an object literal value changes on every render

There'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 consumer

This is the flaw solution 2 fixes. But first, the one that gives the most performance for the least code.

  1. 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 dispatch directly, 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.

  1. 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 value must stabilize it with useMemo, along with any functions that object contains, with useCallback. It's one of the few places where memoizing is the default choice, not a premature optimization.

  1. Solution 3: move state down or pass children

The 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.

  1. 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).

  1. CicloUrbano refactor and the Providers component

With 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:

  • ThemeProvider and UserProvider go outside the router, because they must survive absolutely everything, including the route error screen.
  • BookingsProvider and NoticesProvider go inside Layout, the root route, because they belong to the app's frame and Layout doesn'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.

  1. 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:

  1. You've already split your contexts and you're still seeing re-renders that shouldn't happen.
  2. You have more than six or eight providers and the nesting order between them starts to matter in subtle ways.
  3. You need to know what caused a state change, and console.log in the reducer has stopped being enough.
  4. 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.
  5. The team has grown and you need a shared convention, not each domain inventing its own pattern.
  6. 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.

  1. Context + useReducer: "homemade Redux" and where it falls short

05-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 dispatch available 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:

  1. The context is exported (export const ThemeContext), so anyone can use it with useContext and bypass the check.
  2. 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 on theme.theme, with an unintelligible error.
  3. The provider builds its own content instead of receiving children, so every theme change re-runs Header, Catalogue and Footer even though they don't consume the context.
  4. The value is an unmemoized object literal, and toggleTheme is redeclared on every render. Also, toggleTheme reads theme from 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:

  1. Split the state inside the reducer: publish bookings and draft in separate contexts, so Header only 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.
  2. Extract the badge into its own component, BookingsBadge, which consumes the context, and let Header not consume it. That way the re-render stays contained to a <span> instead of the whole header. Cheap and effective.
  3. The real solution is the one from section 11: this need — "I want to subscribe only to state.bookings" — is exactly sign 1. With useSelector (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.
  • UserMenu also consumes BookingsContext — 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 as Header'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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved