Mert Özen Further With Every Line
Frontend React — #07

useRef, Context and Custom Hooks: DOM Access, Context Instead of DI and the Rules of Hooks

Mert Özen Nov 15, 2026 19 min 4 views
useRef, Context and Custom Hooks: DOM Access, Context Instead of DI and the Rules of Hooks

useRef is a box that does not trigger renders (a DOM node or a timer id), while Context carries a value deep into the tree without props and re-renders every component that reads it when it changes. We move the Bookstore cart, theme and user into Context, measure a version where a theme change renders 28 components instead of 2, and show an inline ref callback stealing focus, a conditional hook crashing the app and the search split into custom hooks.

Version note: The examples in this post were built and run with React 19.3, TypeScript 7.0, Vite 8.3, Oxlint 1.93 and Node.js 24; API calls went through the Vite dev proxy to a local copy of Bookstore.Api (ASP.NET Core 10), and render counts and console records were collected with Playwright 1.65 in headless Chromium.

Predict First

In the React client of Bookstore, the online bookstore used as the shared example across this site's series, the cart has so far lived in a useReducer in App, and props like quantities, cartItemCount and onAdd travelled down to the catalog page one by one. In this post we move the cart, the theme (light/dark) and the signed-in user into Contexts. In Angular you would put them into three services and get them with inject().

Two questions. When the "Dark theme" button in the header is clicked, how many components re-render: only the button, or the whole page with its 20 book cards? And what if the theme state were a useState in App instead of a Context, passed to the button as props? We will see the answers with real render counts in the middle of the post.

useRef: A Box That Does Not Trigger Renders

useRef gives you an object shaped like { current: ... }. React keeps this object the same for the lifetime of the component, and changing current does not trigger a render. It has two typical uses: reaching a DOM node, and keeping a value that is not shown on screen but has to survive between renders.

The first is the counterpart of @ViewChild or viewChild() in Angular. In the catalog we want to focus the search box when the user presses / anywhere on the page:

export function CatalogPage() {
  const [query, setQuery] = useState('');
  const [sortBy, setSortBy] = useState<SortBy>('title');
  const [selectedIsbn, setSelectedIsbn] = useState<string | null>(null);
  const { lines, itemCount, add } = useCart();
  // Like @ViewChild: a handle to the <input> DOM node, filled in after commit
  const searchInput = useRef<HTMLInputElement>(null);

  const { result, isLoading } = useBookSearch(query, (searched, total) => {
    console.info(`[search] "${searched}" -> ${total} book(s), cart has ${itemCount} item(s)`);
  });
  useKeyboardShortcut('/', () => searchInput.current?.focus());
  // Always called; the condition lives inside the callback, not around the hook
  useKeyboardShortcut('Escape', () => setSelectedIsbn(null));

searchInput.current is null during the first render; React writes the input into it after it has created and committed the DOM. That is why reading or writing a ref during render is wrong: react.dev only allows touching a ref in render for its initial value. Refs are read in event handlers and effects. useKeyboardShortcut is a custom hook we will look at shortly; it calls the function we pass when / is pressed.

The second use is values that have nothing to do with rendering. We added a "Removed Dune. Undo" message to the cart panel; it disappears after five seconds. The timer's id changes nothing on screen, but we need to reach it to cancel the timer when "Undo" is clicked or a second item is removed:

export function CartPanel() {
  const { lines, total, setQuantity, remove, restore, clear } = useCart();
  const [removed, setRemoved] = useState<CartLine | null>(null);
  // The timer id is not shown on screen: a ref keeps it without causing renders
  const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  function stopUndoTimer() {
    if (undoTimer.current !== null) {
      clearTimeout(undoTimer.current);
      undoTimer.current = null;
    }
  }

  function handleRemove(line: CartLine) {
    remove(line.isbn);
    setRemoved(line);
    stopUndoTimer();
    undoTimer.current = setTimeout(() => setRemoved(null), undoMs);
  }

  function handleUndo(line: CartLine) {
    stopUndoTimer();
    restore(line);
    setRemoved(null);
  }

  // Do not leave a timer behind when the panel unmounts
  useEffect(() => stopUndoTimer, []);

Had we kept the timer id in useState, every removal would cause one more needless render, and the handler from the render in which we set the id would see the old value. A plain let variable, on the other hand, would be reset on every render. The rule is simple: if the value is visible on screen it is state; if it is not, but has to live between renders, it is a ref. The effect on the last line makes sure the timer is not left behind if the panel unmounts.

On the dev server we opened the page, pressed /, typed "Dune", added the book to the cart and removed it, clicked "Undo", then signed in and played with the theme:

   91 ms request:  ?search=&pageSize=50
   92 ms request:  ?search=&pageSize=50
   93 ms failed:   ?search=&pageSize=50 net::ERR_ABORTED
  117 ms console: [search] "" -> 20 book(s), cart has 0 item(s)
  133 ms focus:    BODY
  142 ms after /:  Search books
  662 ms request:  ?search=Dune&pageSize=50
  674 ms console: [search] "Dune" -> 1 book(s), cart has 0 item(s)
  720 ms header:   Cart: 1 item(s) · ₺489.00
  751 ms cart:     Cart | Removed Dune. Undo | Your cart is empty.
  780 ms cart:     Cart | Dune  ₺489.00 Remove | Total: ₺489.00 | Clear cart
  812 ms user:     Bookstore | Cart: 1 item(s) · ₺489.00 | Dark theme | Ada Lovelace Sign out
  813 ms theme:    light
  916 ms OS dark:  dark
  928 ms clicked:  light
 1030 ms OS light: light (user choice wins)

Look at the first lines: in the previous post the first load sent a single request; now StrictMode sends two and the first one is cancelled. The reason is that we moved the debounce into its own hook: useDebouncedValue returns the value on the first render without waiting, so the first request starts inside the effect without a timer, and the request of the first effect that StrictMode sets up and tears down is cut with abort(). In production a single request goes out.

In React 19 ref Is a Prop, Ref Callbacks and Cleanup

The search box is our own component; in React 18 passing a ref through your own component needed forwardRef. In React 19, ref is an ordinary prop for function components:

import type { Ref } from 'react';

type SearchBoxProps = {
  value: string;
  onChange: (value: string) => void;
  // React 19: ref is an ordinary prop, no forwardRef needed
  ref?: Ref<HTMLInputElement>;
};

// Controlled input: the parent owns the text, the input only shows it and reports changes
export function SearchBox({ value, onChange, ref }: SearchBoxProps) {
  return (
    <input ref={ref} type="search" placeholder="Search by title or author (press /)" aria-label="Search books"
      value={value} onChange={(e) => onChange(e.target.value)} />
  );
}

You can also pass a function instead of an object to the ref prop; this is called a ref callback. React calls it with the node when the node is attached to the DOM; in React 19 the function can return a cleanup, which runs when the node is detached. When the details panel opens we want to scroll it into view and move focus to it, and when it closes we want to give focus back to the button that opened it; the new panel, together with useId:

import { useId, useState } from 'react';
import type { BookSummary } from '@/features/catalog/types';
import { formatPrice } from '@/shared/format';

type BookDetailsPanelProps = {
  book: BookSummary;
  onClose: () => void;
};

const maxNote = 120;

// Defined outside the component: the same function on every render, so React calls it only on attach
function revealPanel(node: HTMLElement) {
  const opener = document.activeElement;
  node.scrollIntoView({ block: 'nearest' });
  node.focus();
  // Cleanup (React 19): when the panel goes away with focus inside it, give focus back to the opener
  return () => {
    if (node.contains(document.activeElement) && opener instanceof HTMLElement) {
      opener.focus();
    }
  };
}

export function BookDetailsPanel({ book, onClose }: BookDetailsPanelProps) {
  // Belongs to one book; the parent resets it with key={book.isbn}
  const [giftNote, setGiftNote] = useState('');
  // Unique per component instance, stable across renders: safe for id/htmlFor/aria-*
  const noteId = useId();

  return (
    <section ref={revealPanel} tabIndex={-1} className="details" aria-label="Book details">
      <h2>{book.title}</h2>
      <p>{book.author}, {book.publishedYear} · {book.category} · {formatPrice(book.price)}</p>
      <label htmlFor={noteId}>Gift note</label>{' '}
      <input id={noteId} aria-describedby={`${noteId}-hint`} value={giftNote}
        onChange={(e) => setGiftNote(e.target.value)} maxLength={maxNote} />
      <p id={`${noteId}-hint`} className="muted">{maxNote - giftNote.length} characters left</p>
      <button type="button" onClick={onClose}>Close</button>
    </section>
  );
}

Defining revealPanel outside the component is deliberate. We first wrote the same logic as an inline arrow function and logged the attach and cleanup moments. We opened Dune, typed "Hi!" into the gift note field, then switched to Emma:

-- click "Dune"
[ref] attach Dune
[ref] cleanup Dune
[ref] attach Dune
-- click the gift note and type "Hi!"
[ref] cleanup Dune
[ref] attach Dune
gift note value: "H"
-- click "Emma"
[ref] cleanup Dune
[ref] attach Emma
[ref] cleanup Emma
[ref] attach Emma

Because a new function is created on every render, React ran the old ref's cleanup and attached the new one on every render. Our callback also calls focus(), so after the first letter the focus jumped from the input to the panel and only "H" made it into the field. The same logs with the module-level revealPanel:

-- click "Dune"
[ref] attach Dune
[ref] cleanup Dune
[ref] attach Dune
-- click the gift note and type "Hi!"
gift note value: "Hi!"
-- click "Emma"
[ref] cleanup Dune
[ref] attach Emma
[ref] cleanup Emma
[ref] attach Emma

No ref calls happened while typing, and the whole note was written. The "attach, cleanup, attach" triple at mount in both runs comes from StrictMode: in development React 19 detaches and re-attaches ref callbacks once, just like effects. If a ref callback needs props or state, you stabilize it with useCallback; that hook is the subject of the twelfth post.

The cleanup has one more job. In our first version revealPanel returned nothing, and clicking Dune never opened the panel:

pageerror: Cannot read properties of null (reading 'scrollIntoView')
pageerror: Cannot read properties of null (reading 'scrollIntoView')
after click "Dune":  focus = BODY | panels: 0

For backwards compatibility, React calls a ref callback that returns no cleanup again with null when the node is detached; StrictMode's detach and re-attach at mount triggered this on the first click, and null.scrollIntoView() took the whole tree down. TypeScript did not catch it, because React's types deliberately declare the ref callback parameter as bivariant, so (node: HTMLElement) => void is accepted. react.dev says this null call will be removed in the future; the correct form is returning a cleanup. Ours also does something useful:

after click "Dune":  focus = Book details | panels: 1
after "Close":       focus = Dune | panels: 0
after Emma + Escape: focus = Emma | panels: 0

On close, focus went back to the Dune button. When we pressed Escape on Emma, focus was in the panel and again returned to the button. If the user switches the panel by clicking another book, focus is already on that button, so the cleanup leaves it alone.

Context: Carrying a Value Through the Tree Instead of DI

React has no DI container like Angular's. The way to get a value deep into the tree without passing props is Context. For the cart:

import { createContext, use } from 'react';
import type { CartLine } from '@/features/cart/types';
import type { BookSummary } from '@/features/catalog/types';

export type CartContextValue = {
  lines: CartLine[];
  itemCount: number;
  total: number;
  add: (book: BookSummary, amount: number) => void;
  setQuantity: (isbn: string, quantity: number) => void;
  remove: (isbn: string) => void;
  restore: (line: CartLine) => void;
  clear: () => void;
};

// null default: reading the cart outside <CartProvider> is a bug, not a state
export const CartContext = createContext<CartContextValue | null>(null);

export function useCart(): CartContextValue {
  const cart = use(CartContext);
  if (cart === null) {
    throw new Error('useCart must be used inside <CartProvider>');
  }
  return cart;
}
import { useReducer, type ReactNode } from 'react';
import { CartContext, type CartContextValue } from '@/features/cart/CartContext';
import { cartItemCount, cartReducer, cartTotal } from '@/features/cart/cartReducer';

// Owns the cart state; every component below can read it with useCart()
export function CartProvider({ children }: { children: ReactNode }) {
  const [lines, dispatch] = useReducer(cartReducer, []);

  const value: CartContextValue = {
    lines,
    itemCount: cartItemCount(lines),
    total: cartTotal(lines),
    add: (book, amount) => dispatch({ type: 'added', book, amount }),
    setQuantity: (isbn, quantity) => dispatch({ type: 'quantityChanged', isbn, quantity }),
    remove: (isbn) => dispatch({ type: 'removed', isbn }),
    restore: (line) => dispatch({ type: 'restored', line }),
    clear: () => dispatch({ type: 'cleared' }),
  };

  return <CartContext value={value}>{children}</CartContext>;
}

createContext creates a key; it resembles an InjectionToken in Angular. Since React 19 you render <CartContext value={...}> directly as the provider; the old <CartContext.Provider> form still works, but react.dev lists it as legacy. To read it you use useContext(CartContext) or React 19's use(CartContext). Both return the same value; the difference is that use can also be called inside an if or a loop.

The default value is a design decision. For the cart we chose null, and useCart throws when there is no provider, because a catalog without a cart is a bug and we do not want to hide it behind an empty cart. For the theme there is a meaningful default (light), so components still work without a provider. We put the providers at the top of the tree in main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from '@/features/auth/AuthProvider'
import { CartProvider } from '@/features/cart/CartProvider'
import { ThemeProvider } from '@/shared/theme/ThemeProvider'

// Providers wrap the tree like root-level injectors: the nearest one above a component wins
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ThemeProvider>
      <AuthProvider>
        <CartProvider>
          <App />
        </CartProvider>
      </AuthProvider>
    </ThemeProvider>
  </StrictMode>,
)

The similarities and differences with Angular's hierarchical injector:

TopicAngular DIReact Context
Keya class or an InjectionTokenthe object created by createContext()
RegistrationprovidedIn: 'root', providers: []rendering <Ctx value={...}> in the tree
Resolutionnearest injector, then the parent injectornearest provider, then the default value
What is carrieda service instance (usually a singleton)any value; it can change on every render
On changetemplates that read the signal updateevery component that reads the Context re-renders

The last row matters most. In Angular a service is created once and the signals inside it change; in React a Context's value is derived from state, and when the value changes every component that reads that Context re-renders. Our cart Context creates a new value object on every render, but since CartProvider only renders when its own state changes, that is not a problem. Let us measure why.

Who Re-renders When a Context Changes?

We ran a variant in a production build that adds a console.log('[render] ...') line at the top of every component. After the page loaded we changed the theme, signed in and added Dune to the cart:

page load:             ThemeProvider, AuthProvider, CartProvider, App, Layout, CartSummary, ThemeToggle, UserMenu, CatalogPage×3, SearchBox×2, CartPanel, BookList, BookCard×20
toggle theme:          ThemeProvider, ThemeToggle
sign in:               AuthProvider, UserMenu
add Dune to cart:      CartProvider, CartSummary, CatalogPage, SearchBox, BookList, BookCard×20, CartPanel

When the theme changed, only ThemeProvider and ThemeToggle, which reads the theme, rendered; on sign-in only AuthProvider and UserMenu. The reason is that the providers take children: when ThemeProvider's state changes React calls it again, but children are the same elements as in the previous render, so React does not walk into that subtree; it only finds the components that read the Context. With the cart, CatalogPage reads the cart and the cards depend on it through props, so 20 cards rendered.

With a version that keeps the same theme as a useState in App and renders the button there:

page load:             ThemeProvider, AuthProvider, CartProvider, App, Layout, CartSummary, UserMenu, CatalogPage×3, SearchBox×2, CartPanel, BookList, BookCard×20
toggle theme:          App, Layout, CartSummary, UserMenu, CatalogPage, SearchBox, BookList, BookCard×20, CartPanel
sign in:               AuthProvider, UserMenu
add Dune to cart:      CartProvider, CartSummary, CatalogPage, SearchBox, BookList, BookCard×20, CartPanel

Twenty-eight components rendered for one button label. The screen is the same in both; the difference is the work React does each time. With 20 books this is not even a millisecond, but as the number of components reading the cart grows, Context's "render everyone who reads it" behavior becomes noticeable. Context has no selective subscription: a component that only reads the cart's itemCount still renders on every cart change. We will address this limit with Zustand's selectors in the eleventh post and measure the cost of needless renders in the twelfth.

Custom Hooks: Share Logic, Not State

In the sixth post the search effect sat directly inside CatalogPage. Now it is split into two hooks:

import { useEffect, useState } from 'react';

// Returns value, but only after it has stopped changing for delayMs
export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}
import { useEffect, useEffectEvent, useState } from 'react';
import type { BookSummary, PagedResult } from '@/features/catalog/types';
import { useDebouncedValue } from '@/shared/useDebouncedValue';

export type SearchResult =
  | { status: 'success'; query: string; books: BookSummary[]; total: number }
  | { status: 'error'; query: string; message: string };

// While loading, result may be missing or belong to an older query
export type BookSearch =
  | { isLoading: true; result: SearchResult | null }
  | { isLoading: false; result: SearchResult };

const debounceMs = 300;

// The search effect from post 06, moved out of the page into a reusable hook
export function useBookSearch(query: string, onFinished: (query: string, total: number) => void): BookSearch {
  const debouncedQuery = useDebouncedValue(query, debounceMs);
  const [result, setResult] = useState<SearchResult | null>(null);
  const handleFinished = useEffectEvent(onFinished);

A custom hook is an ordinary function whose name starts with use and which calls other hooks. It differs from an Angular service in an important way: a hook shares logic, not state. If you call useDebouncedValue in two components you get two separate useStates and two separate timers. If you need a shared value, you put it in a Context (or later a store) and use a hook to read it; that is exactly what useCart does.

The keyboard shortcut hook reuses useEffectEvent from the previous post:

import { useEffect, useEffectEvent } from 'react';

// Calls onPress when the key is pressed anywhere on the page, except while typing in a field
export function useKeyboardShortcut(key: string, onPress: () => void) {
  const handlePress = useEffectEvent(onPress);

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      const target = e.target as HTMLElement;
      if (e.key !== key || target.closest('input, textarea, select')) {
        return;
      }
      e.preventDefault();
      handlePress();
    }
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [key]);
}

onPress is a new function on every render. Had we put it into the dependency array, the listener would be removed and re-added on every render; with useEffectEvent the listener is only replaced when key changes, yet it always calls the latest onPress. The line in the output above where pressing / moves focus to the search box is this hook's work.

When should you write a custom hook? When you notice you have copied the same effect into two places, or when the answer to "what do these lines in the component do" fits in a single name (useBookSearch). Wrapping every useState in a hook gains nothing except making the reader open one more file.

The Rules of Hooks and Why They Exist

There are two rules: call hooks only in components and custom hooks; and always call them at the top level, not in conditions, loops, nested functions or after an early return. Our first attempt at an Escape shortcut that closes the details panel while it is open looked like this:

  const selectedBook = books.find((b) => b.isbn === selectedIsbn) ?? null;
  // WRONG: a hook inside a condition
  if (selectedBook !== null) {
    useKeyboardShortcut('Escape', () => setSelectedIsbn(null));
  }
> bookstore-react@0.0.0 lint
> oxlint

src/features/catalog/CatalogPage.tsx:31:5: error react-hooks(rules-of-hooks): React Hook "useKeyboardShortcut" is called conditionally. React Hooks must be called in the exact same order in every component render. help: Move the Hook call before the condition, or call it unconditionally and branch inside the Hook/effect instead.
exit code: 1

We ignored the lint and clicked Dune on the page:

pageerror: Rendered more hooks than during the previous render.
console.error: React has detected a change in the order of Hooks called by CatalogPage. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks

   Previous render            Next render
   ------------------------------------------------------
1. useState                   useState
2. useState                   useState
3. useState                   useState
4. useRef                     useRef
5. useState                   useState
6. useEffect                  useEffect
7. useState                   useState
8. useEffectEvent             useEffectEvent
9. useEffect                  useEffect
10. useEffectEvent            useEffectEvent
11. useEffect                 useEffect
12. undefined                 useEffectEvent
root children: 0

The error explains the reason for the rule by itself. React recognizes hooks by order, not by name: it keeps a list per component in call order and on the next render assumes that the first useState is the first entry. It is also interesting that useCart does not appear in the list at all: the use inside it does not count as a hook, which is why it can be called in a condition. When a book was selected a twelfth hook was added to the list and React could not finish the render. With no error boundary the whole tree unmounted; #root has zero children. The correct version always calls the hook and moves the condition into the callback; that is how the Escape shortcut looks in the snapshot code, and pressing Escape while the details panel is closed changes nothing anyway.

useId and useSyncExternalStore

We used useId in the details panel. Linking a label to an input with htmlFor/id and a hint with aria-describedby needs an id that is unique on the page; if we hard-code "gift-note", the ids collide when the same component renders twice. useId produces a stable, unique value per component instance. It is not for list keys; keys come from the data.

The default theme follows the operating system's light/dark preference. That preference lives outside React; it is read with matchMedia and fires an event when it changes. The right way to subscribe to such "external stores" is useSyncExternalStore:

import { useSyncExternalStore } from 'react';

const query = '(prefers-color-scheme: dark)';

// The OS/browser color scheme is an external store: React subscribes and reads a snapshot
function subscribe(onChange: () => void) {
  const media = window.matchMedia(query);
  media.addEventListener('change', onChange);
  return () => media.removeEventListener('change', onChange);
}

function getSnapshot() {
  return window.matchMedia(query).matches;
}

export function usePrefersDark(): boolean {
  return useSyncExternalStore(subscribe, getSnapshot);
}
import { useEffect, useState, type ReactNode } from 'react';
import { ThemeContext, type Theme } from '@/shared/theme/ThemeContext';
import { usePrefersDark } from '@/shared/usePrefersDark';

export function ThemeProvider({ children }: { children: ReactNode }) {
  const prefersDark = usePrefersDark();
  // null = follow the system setting until the user picks a theme
  const [choice, setChoice] = useState<Theme | null>(null);
  const theme: Theme = choice ?? (prefersDark ? 'dark' : 'light');

  // <html data-theme> lives outside React's tree: an effect keeps it in sync
  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  const value = { theme, toggleTheme: () => setChoice(theme === 'dark' ? 'light' : 'dark') };
  return <ThemeContext value={value}>{children}</ThemeContext>;
}

React calls subscribe once and listens for changes, reads getSnapshot on every change and renders if the value differs. Defining subscribe outside the component matters; if you define it inside, it is a new function on every render and React resubscribes on every render. In the output above, Playwright switching the system theme to dark with emulateMedia made the page dark; after the user picked the light theme with the button, the user's choice won even when the system theme changed. We will see this hook again in the eleventh post: store libraries like Zustand connect to React through this API.

In Production

Context is not a state management tool but a transport tool. It fits values that change rarely and are read in many places (theme, language, the signed-in user, feature flags). If you put a value that changes often and whose different parts many components read (cart lines, form fields, lists from the server) into a single Context, everyone who reads it renders on every change. The first measure is splitting Contexts: that is why we put theme, user and cart into three separate Contexts.

AuthProvider is only a demo for now: the user is kept in memory, disappears on reload, and the server knows nothing about it. For real authentication we will set up the BFF approach in the fourteenth post instead of storing a token in the browser.

When you touch the DOM through a ref, do not change what React manages. Focus, scrolling, measuring and handing a node to a third-party library are a ref's job; removing a node's children or changing its text by hand is either undone by React's next commit or leads to errors.

Common Pitfalls

Reading a ref during render: an expression like {inputRef.current?.value} is empty on the first render and the previous commit's value afterwards. A value that appears on screen is state.

Inline ref callbacks: as we saw above, they run again on every render. If they only assign, that is harmless; if they focus, measure or subscribe, move the function out or stabilize it with useCallback.

Putting a provider in the wrong place: a component that calls useCart outside the provider gets an exception; accidentally putting a provider inside a modal creates a separate, empty cart whenever that modal opens. It is like a new instance being created when you put a service into a component's providers in Angular.

Thinking a custom hook shares state: useDebouncedValue called in two components produces two separate values. Shared state lives in a common parent component, in a Context or in a store.

Putting a hook in a condition or calling it after an early return: always keep the lint on; React's error often does not show up until the user takes a particular path.

A Small Experiment

★☆☆ Add a countdown showing the five seconds to the "Undo" message in the cart panel ("Undo (4)"). Should the countdown value be state or a ref? Where should the interval's id live?

★★☆ Change ThemeProvider so that it renders <App /> itself instead of taking children, and compare it with the render logs. Show with your own numbers why it matters that the provider takes children.

★★★ Split the cart Context in two: one carries lines, itemCount and total (state), the other only the functions (add, remove…). How does the function Context's value stay stable from render to render? Measure whether a component that only has "Add" buttons still renders when the cart changes.

We have seen distributing values with Context, gathering logic in custom hooks and touching the DOM with refs. The search is still an effect we wrote by hand, and the results are not cached anywhere. In the next post we move this effect to TanStack Query, connect the cart from the browser to the server-side cart in the Bookstore API (/api/carts), and look at where Suspense and use() fit in data fetching.