04-02 and 04-04 already explained why hooks exist: before 2019, reusing stateful logic between components meant wrapping them in HOCs or render props, with all the wrapper hell that produced. The five hooks you've studied in this module solve the problem from the inside, but you haven't yet used the piece that closes the circle: the ability to write your own hooks. The debounced search box from 05-02, the subscription to the connection-status event, the availability timer, the theme synced with localStorage from 05-04… these are fragments that repeat across different CicloUrbano components and that today you'd have to copy and paste. In this lesson you'll learn to extract them into reusable functions, you'll build a complete collection of hooks for the project, and — most importantly — you'll understand the property that gets misunderstood the most: a custom hook shares logic, not state.

Contents

  1. What a custom hook is
  2. Shares logic, not state: the demonstration
  3. When to extract a hook
  4. How to extract one, step by step
  5. useToggle: the simplest one
  6. useLocalStorage: state synced with the browser
  7. useDebounce: delaying the search term
  8. useFetchBikes: data fetching with cancellation
  9. useKeyEvent and useWindowWidth
  10. Designing a hook's API
  11. The rules still apply
  12. useId and other supporting hooks

  1. What a custom hook is

A custom hook is a JavaScript function whose name starts with use and that calls other hooks.

That's all there is to it. No special API, no registry, no configuration. If you write a function called useSomething that uses useState or useEffect inside, you've created a hook.

// src/hooks/useRenderCount.js
import { useRef, useEffect } from 'react';

export function useRenderCount(name) {
  const renders = useRef(0);

  useEffect(() => {
    renders.current += 1;
    console.log(`${name}: render #${renders.current}`);
  });

  return renders.current;
}

Two things make the use prefix non-optional:

  • It's what tells React and the linter that a function follows the rules of hooks (04-04). Without the prefix, eslint-plugin-react-hooks can't check that you're not calling it inside an if or a loop, and you'd lose that safety net.
  • It's what tells whoever reads your code that this function isn't an ordinary utility: it holds state, participates in the render cycle, and can't be called from just anywhere.

The reverse holds too: if a function doesn't call any hook, don't name it useSomething. validateBooking and cx are plain pure functions, which is why they live in src/utils/ and not in src/hooks/.

  1. Shares logic, not state: the demonstration

This is the property that causes the most confusion, so let's demonstrate it.

// src/hooks/useCounter.js
import { useState } from 'react';

export function useCounter(initial = 0) {
  const [value, setValue] = useState(initial);
  const increment = () => setValue((previous) => previous + 1);
  const reset = () => setValue(initial);
  return { value, increment, reset };
}

Now two components that use it, on the same screen:

function LeftPanel() {
  const { value, increment } = useCounter();
  return <button type="button" onClick={increment}>Left: {value}</button>;
}

function RightPanel() {
  const { value, increment } = useCounter();
  return <button type="button" onClick={increment}>Right: {value}</button>;
}

Click the left button five times. It'll read "Left: 5" and "Right: 0". The two components share the recipe, not the jar.

flowchart TD
    H["useCounter()<br/><i>the logic: a single definition</i>"]
    H -.->|"runs inside"| PI["LeftPanel<br/><b>its own useState → 5</b>"]
    H -.->|"runs inside"| PD["RightPanel<br/><b>its own useState → 0</b>"]
    style H fill:#e0f2fe
    style PI fill:#dcfce7
    style PD fill:#fde68a

The reason is the same mechanism from 04-04: each component has its own ordered list of hook cells. When LeftPanel calls useCounter, the useState inside occupies a cell belonging to LeftPanel; when RightPanel calls it, it occupies a cell belonging to RightPanel. They're separate stores.

A custom hook is a template for behavior, not a shared store. Every call creates independent state.

And from that follows a practical consequence: if what you want is for several components to see the same data, a custom hook isn't enough. You need to lift state up (04-01) or use a context (05-04). The usual approach is to combine them: the shared state lives in a provider, and the access hook — useUser, useTheme, useBookings — is a custom hook that reads it. In fact, you've already written three custom hooks without calling them that.

  1. When to extract a hook

Signal Example in CicloUrbano
The same useState + useEffect pair shows up in two or more components The resize subscription in BikeList and in SummaryPanel
A component has so much plumbing it's hard to find the JSX ActivityPanel with loading, error, cancellation, and an ignore flag
You want to test the logic without mounting the interface The search box's debounced validation
A name would clearly describe what that block does useLocalStorage, useDebounce, useKeyEvent
The same bug has to be fixed in several places Forgetting removeEventListener in three different components

And when not to extract:

  • When it's used just once and the block is short. A three-line useEffect inside the component reads better where it already is.
  • When the "hook" just wraps another one without adding anything. function useName() { return useState(''); } is pure indirection: whoever reads it has to open another file to discover it does nothing.
  • When the logic doesn't use any hook. That's a plain function; moving it to src/utils/ is the right call.

  1. How to extract one, step by step

Let's start from 05-02's ConnectionNotice and turn it into a hook. The process is always the same:

Step 1: identify the full block. State, effect, and everything that depends on them.

function ConnectionNotice() {
  const [isOnline, setIsOnline] = useState(() => navigator.onLine);   // ← block

  useEffect(() => {                                                    // ← block
    function handleOnline() { setIsOnline(true); }
    function handleOffline() { setIsOnline(false); }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  if (isOnline) return null;                                           // ← NOT this: it's UI
  return <Notice tone="warning">Offline…</Notice>;
}

Step 2: move the block into a function prefixed with use, in src/hooks/.

Step 3: decide what it returns. The minimum the component needs: here, a boolean.

// src/hooks/useOnlineStatus.js
import { useState, useEffect } from 'react';

/**
 * Reports whether the browser has a network connection.
 * Returns: boolean
 */
export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(() => navigator.onLine);

  useEffect(() => {
    function handleOnline() { setIsOnline(true); }
    function handleOffline() { setIsOnline(false); }

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

Step 4: the component is left with only the UI.

// src/components/ConnectionNotice.jsx
import { useOnlineStatus } from '../hooks/useOnlineStatus.js';
import Notice from './Notice.jsx';

function ConnectionNotice() {
  const isOnline = useOnlineStatus();

  if (isOnline) return null;
  return (
    <Notice tone="warning">
      You're offline. You can browse the catalogue, but you can't confirm bookings.
    </Notice>
  );
}

export default ConnectionNotice;

From twenty lines to six, and now BookingPanel's "Confirm booking" button can be disabled while offline with a single line: const isOnline = useOnlineStatus();.

  1. useToggle: the simplest one

Let's start the collection. A boolean with three named operations, showing up in Modal, Accordion, and AdvancedPanel.

// src/hooks/useToggle.js
import { useState, useCallback } from 'react';

/**
 * A boolean with named operations.
 * Parameters:
 *  - initial (boolean, optional, defaults to false)
 * Returns: [value, { activate, deactivate, toggle }]
 */
export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);

  const activate = useCallback(() => setValue(true), []);
  const deactivate = useCallback(() => setValue(false), []);
  const toggle = useCallback(() => setValue((previous) => !previous), []);

  return [value, { activate, deactivate, toggle }];
}

Line by line:

  • useState(initial) holds the boolean. Everything else wraps its updater.
  • activate and deactivate use a direct value because they don't depend on the previous one; toggle uses the functional form because it does (05-01).
  • useCallback with [] makes the three functions stable across renders. Without it, every render would return brand-new functions, and a component that put them in an effect's dependencies (05-02) would re-run it in a loop. This is the only concession to Module 8 in the whole lesson, and it's about correctness, not performance: custom hooks must return stable values (section 10).
  • It returns an array because the first slot is the main value and the second is a group of actions; that way whoever uses it picks the names.
// src/components/Accordion.jsx (excerpt)
import { useToggle } from '../hooks/useToggle.js';

function Accordion({ title, children }) {
  const [open, { toggle }] = useToggle(false);

  return (
    <section>
      <button type="button" onClick={toggle} aria-expanded={open}>
        {title}
      </button>
      {open && <div>{children}</div>}
    </section>
  );
}

  1. useLocalStorage: state synced with the browser

Combines useState and useEffect so a value survives a page reload. ThemeProvider (05-04) uses it, and the booking draft will too.

// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

/**
 * State synced with localStorage.
 * Parameters:
 *  - key     (string, required): the localStorage key
 *  - initial (any, optional): the value when nothing is stored yet
 * Returns: [value, setValue]
 */
export function useLocalStorage(key, initial = null) {
  const [value, setValue] = useState(() => {
    // Lazy initialization: the browser is read only once (05-01)
    try {
      const saved = localStorage.getItem(key);
      return saved === null ? initial : JSON.parse(saved);
    } catch {
      // Corrupt JSON or localStorage blocked (private mode, quota)
      return initial;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Out of space or no permission: the app must keep working
    }
  }, [key, value]);

  return [value, setValue];
}

The details that separate a toy hook from a usable one:

  • The read happens in the lazy initializer, not in an effect. If it were in an effect, the first render would show the initial value and the second one the saved value: a visible flicker.
  • The two try/catch blocks aren't paranoia. localStorage throws in private mode on some browsers, when the quota is exceeded, or when the stored content isn't valid JSON because an earlier version of the app saved something else. A failure here shouldn't bring down the app.
  • key is in the effect's dependencies because it's a reactive value: if the component switches keys, it has to save under the new one.
  • It returns a positional pair, just like useState, so the swap is immediate: you change useState('light') for useLocalStorage('ciclourbano:tema', 'light') and you're done.
// ThemeProvider (05-04) simplifies to this
const [theme, setTheme] = useLocalStorage('ciclourbano:tema', 'light');

  1. useDebounce: delaying the search term

BikeSearch shouldn't filter on every keystroke: it has to wait until the user stops typing. In 05-02 you solved this with an effect inside the component; now it becomes a reusable hook.

// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';

/**
 * Returns a delayed copy of a value.
 * Parameters:
 *  - value   (any, required)
 *  - delayMs (number, optional, defaults to 400)
 * Returns: the value after `delayMs` without changes
 */
export function useDebounce(value, delayMs = 400) {
  const [delayedValue, setDelayedValue] = useState(value);

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

  return delayedValue;
}

The mechanism is the one you analyzed in 05-02: the cleanup cancels the pending timer. As long as the user keeps typing, every keystroke kills the previous timer and schedules another one; only once 400 ms pass without changes does one survive and update delayedValue.

// src/components/BikeSearch.jsx
import { useState, useEffect } from 'react';
import { useDebounce } from '../hooks/useDebounce.js';

/**
 * Props:
 *  - onSearch (function, required): receives the delayed term
 */
function BikeSearch({ onSearch }) {
  const [query, setQuery] = useState('');
  const delayedQuery = useDebounce(query, 400);

  useEffect(() => {
    onSearch(delayedQuery);
  }, [delayedQuery, onSearch]);

  return (
    <input
      type="search"
      value={query}
      onChange={(event) => setQuery(event.target.value)}
      aria-label="Search bikes by model"
    />
  );
}

export default BikeSearch;

Notice the split: the field stays controlled and responds instantly (03-04), because nobody wants typing to feel sluggish; only the notification to the parent gets delayed.

  1. useFetchBikes: data fetching with cancellation

The longest case, and the one that cleans up the component the most. It reuses everything from 05-02: AbortController, the ignore flag, checking response.ok, and a single phase variable.

// src/hooks/useFetchBikes.js
import { useState, useEffect } from 'react';

/**
 * Fetches the bikes at a station from the API.
 * Parameters:
 *  - stationId (string, required)
 * Returns: { bikes, loading, error }
 */
export function useFetchBikes(stationId) {
  const [bikes, setBikes] = useState([]);
  const [phase, setPhase] = useState('idle');   // 'idle' | 'loading' | 'success' | 'error'
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!stationId) {
      setBikes([]);
      setPhase('idle');
      return;
    }

    const controller = new AbortController();
    let ignore = false;

    async function load() {
      setPhase('loading');
      setError(null);
      try {
        const response = await fetch(
          `/api/estaciones/${stationId}/bicicletas`,
          { signal: controller.signal }
        );
        if (!response.ok) throw new Error(`The server responded ${response.status}`);
        const data = await response.json();
        if (!ignore) {
          setBikes(data);
          setPhase('success');
        }
      } catch (failure) {
        if (failure.name === 'AbortError') return;
        if (!ignore) {
          setError(failure.message);
          setPhase('error');
        }
      }
    }

    load();

    return () => {
      ignore = true;
      controller.abort();
    };
  }, [stationId]);

  return { bikes, loading: phase === 'loading', error };
}

Design points:

  • The initial guard (if (!stationId)) lets you use the hook before a station has been chosen. A hook can't be called conditionally, but it can bail out early on the inside: the condition lives inside the effect, not around the hook.
  • phase is internal and never exposed. Only loading and error come out, which is what the UI needs. Hiding the detail is part of API design.
  • It returns an object, not an array, because these are three values with no natural order (section 10).

The component ends up remarkably clean:

// src/components/ActivityPanel.jsx
import { useFetchBikes } from '../hooks/useFetchBikes.js';
import BikeList from './BikeList.jsx';
import Notice from './Notice.jsx';

function ActivityPanel({ stationId }) {
  const { bikes, loading, error } = useFetchBikes(stationId);

  if (loading) return <p aria-live="polite">Loading bikes…</p>;
  if (error) return <Notice tone="error">Couldn't load the station: {error}</Notice>;

  return <BikeList bikes={bikes} />;
}

export default ActivityPanel;

This hook is also the best demonstration of why server-state libraries exist: what it's missing — a cache shared across screens, deduplication of identical requests, retries, revalidation on tab focus — doesn't fit in twenty lines, and it's exactly what TanStack Query or SWR solve in 07-06.

  1. useKeyEvent and useWindowWidth

Two short hooks that round out the collection.

// src/hooks/useKeyEvent.js
import { useEffect, useRef } from 'react';

/**
 * Runs an action when a specific key is pressed.
 * Parameters:
 *  - key    (string, required): event.key's value, e.g. 'Escape'
 *  - action (function, required)
 *  - active (boolean, optional, defaults to true)
 */
export function useKeyEvent(key, action, active = true) {
  const savedAction = useRef(action);

  // Keeps the action up to date without resubscribing the listener
  useEffect(() => {
    savedAction.current = action;
  }, [action]);

  useEffect(() => {
    if (!active) return;

    function handleKey(event) {
      if (event.key === key) savedAction.current(event);
    }

    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [key, active]);
}

The savedAction trick deserves an explanation, because it solves a real problem. If action sat directly in the second effect's dependencies, any component passing an inline function (() => setOpen(false)) would trigger an unsubscribe and a resubscribe on every render. By storing the function in a ref (05-03) and updating it in a separate effect, the listener registers only once and still always calls the latest version. It's a common pattern, informally known as the "effect event."

// src/components/Modal.jsx (excerpt) — close with Escape
import { useKeyEvent } from '../hooks/useKeyEvent.js';

function Modal({ title, children, onClose }) {
  useKeyEvent('Escape', onClose);
  …
}
// src/hooks/useWindowWidth.js
import { useState, useEffect } from 'react';

/**
 * The window's current width in pixels.
 * Returns: number
 */
export function useWindowWidth() {
  const [width, setWidth] = useState(() => window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener('resize', handleResize);
    handleResize();   // in case it changed between render and subscription

    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

This is the example 04-04 opened the module with: logic you'd have had to copy into three components or wrap in four stacked HOCs now fits in fifteen lines and gets used with one. The full collection looks like this:

Hook File Returns Used in
useToggle src/hooks/useToggle.js [value, { activate, deactivate, toggle }] Modal, Accordion, AdvancedPanel
useLocalStorage src/hooks/useLocalStorage.js [value, setValue] ThemeProvider, booking draft
useDebounce src/hooks/useDebounce.js The delayed value BikeSearch
useFetchBikes src/hooks/useFetchBikes.js { bikes, loading, error } ActivityPanel
useKeyEvent src/hooks/useKeyEvent.js Nothing Modal, BookingDialog
useWindowWidth src/hooks/useWindowWidth.js number BikeList, Layout
useOnlineStatus src/hooks/useOnlineStatus.js boolean ConnectionNotice, BookingPanel

  1. Designing a hook's API

A hook is a public interface: someone will use it without reading its insides. These four decisions make the difference.

What to return: array or object

Returns When Example
A single value There's only one result useWindowWidth()1280
An array Two values with a natural order, and whoever uses it will want to rename them useLocalStorage()[value, setValue]
An object Three or more values, or you want to be able to add more later useFetchBikes(){ bikes, loading, error }

The underlying criterion: an array forces you to remember the order but allows renaming (that's why useState uses it: you declare several per component); an object documents each value by its name and lets you add fields without breaking anyone, which would be impossible with a five-slot array.

Parameters with default values

// ✅ The common case needs no configuration
export function useDebounce(value, delayMs = 400) { … }
export function useToggle(initial = false) { … }
export function useKeyEvent(key, action, active = true) { … }

If your hook needs five parameters, group them into an options object with defaults: useFetchBikes(stationId, { retries = 0, intervalMs = 0 } = {}).

Returning stable values

This is the most common design mistake and the hardest one to diagnose:

// ❌ Returns a NEW object on every render
export function useBooking(bike, hours) {
  return { bike, hours, total: hours * bike.pricePerHour };
}

// Whoever uses it like this hits an infinite loop:
const booking = useBooking(bike, 2);
useEffect(() => { record(booking); }, [booking]);   // 💥 booking ALWAYS changes

Functions you return must be stable (useCallback with correct dependencies, as in useToggle), and objects too, or else you must clearly document that they aren't. useState's updaters and useReducer's dispatch are already stable by default, so returning them directly is always safe.

Keeping the hook focused

// ❌ Too many responsibilities: impossible to name well and to reuse
export function useWholePanel(stationId) {
  // fetches data + manages the form + controls the modal + listens for keys
}

// ✅ Four small hooks combined wherever needed
const { bikes, loading, error } = useFetchBikes(stationId);
const [modalOpen, { activate, deactivate }] = useToggle();
useKeyEvent('Escape', deactivate, modalOpen);

A hook should be describable in one sentence. If writing its comment needs an "and," and then another "and," that's two hooks.

  1. The rules still apply

Custom hooks are hooks, so they inherit 04-04's two rules with no exceptions.

Rule 1: only at the top level. Not inside conditionals, not inside loops, not inside nested functions.

// ❌ Breaks rule 1: the list of cells would change between renders
function StationPanel({ stationId }) {
  if (stationId) {
    const { bikes } = useFetchBikes(stationId);   // 💥
  }
}

// ✅ The hook is always called; the condition goes INSIDE the hook
function StationPanel({ stationId }) {
  const { bikes, loading } = useFetchBikes(stationId);   // handles null internally
}

Rule 2: only from React components or from other hooks. A custom hook can call other custom hooks without limit; what it can't do is get called from an event handler, from a plain function, or from a class body.

// ✅ Composing hooks: perfectly legitimate
export function useFilteredCatalogue(stationId, term) {
  const { bikes, loading, error } = useFetchBikes(stationId);
  const delayedTerm = useDebounce(term, 400);

  const visible = bikes.filter((bike) =>
    bike.model.toLowerCase().includes(delayedTerm.toLowerCase())
  );

  return { visible, loading, error };
}

This example shows off the most useful property of all: hooks compose. useFilteredCatalogue doesn't reimplement anything; it chains two existing hooks and adds a derived filter. Just like components are built out of other components (04-02), hooks are built out of other hooks.

  1. useId and other supporting hooks

One last hook from the standard library that fits right in here and rounds out the accessibility work from 03-06: useId generates a unique, stable identifier for pairing labels with fields.

// src/components/HoursField.jsx
import { useId } from 'react';

function HoursField({ value, onHoursChange }) {
  const fieldId = useId();
  const helpId = `${fieldId}-help`;

  return (
    <p>
      <label htmlFor={fieldId}>Booking hours</label>
      <input
        id={fieldId}
        type="number"
        min="1"
        max="24"
        value={value}
        onChange={(event) => onHoursChange(Number(event.target.value))}
        aria-describedby={helpId}
      />
      <span id={helpId}>Between 1 and 24 hours.</span>
    </p>
  );
}

Why writing id="hours" by hand isn't enough: if HoursField shows up twice on the same page — the booking form and the edit form — there'd be two elements with the same id, and one's <label> would activate the other's field. useId guarantees uniqueness per instance. And why Math.random() won't do: the identifier has to be the same on the server and on the client if you ever render on the server (Module 10), and it also has to stay stable across renders. useId satisfies both.

Rule of use: useId is for accessibility identifiers, not for list keys. keys come from the data (03-03), never from a generator.

Common Mistakes and Tips

  • Expecting a hook to share state between components. It doesn't: every call gets its own state. To share, use context (05-04) or lift state up (04-01).
  • Not starting the name with use. The linter stops watching the function and rule violations go unnoticed until they fail in production.
  • Putting use on a function that calls no hook. That's a utility function: move it to src/utils/.
  • Creating a hook that just wraps another one. useName() { return useState(''); } adds a file and contributes nothing.
  • Piling on too many responsibilities. If the name needs an "and," that's two hooks.
  • Returning unstable objects or functions. They break the effect dependencies of whoever uses them. useCallback for functions; for objects, either stabilize them or document that they aren't.
  • Calling a custom hook inside an if. Rule 1 doesn't relax just because you wrote it. The condition goes inside the hook.
  • Calling it from an event handler. onClick={() => useToggle()} isn't valid: hooks get called during render.
  • Tip: write the hook the second time you copy the same block, not the first. Extracting too early produces abstractions that don't fit the second case.
  • Tip: document every hook with a comment stating what parameters it takes and what it returns. It's the only documentation whoever uses it will read.
  • Tip: if a hook is hard to name, it's probably doing too much. The name is a good design detector.

Exercises

Exercise 1. These two CicloUrbano components repeat the same logic. Extract it into a useAvailabilityTimer hook in src/hooks/, decide what it should return, and rewrite both components to use it.

function StationAvailability({ station }) {
  const [freeDocks, setFreeDocks] = useState(station.docks);
  const [lastReading, setLastReading] = useState(null);

  useEffect(() => {
    const id = setInterval(() => {
      setFreeDocks(checkFreeDocks(station.id));
      setLastReading(new Date());
    }, 10000);
    return () => clearInterval(id);
  }, [station.id]);

  return <p>{station.name}: {freeDocks} free docks</p>;
}

function FleetSummary({ station }) {
  const [free, setFree] = useState(station.docks);
  const [timestamp, setTimestamp] = useState(null);

  useEffect(() => {
    const id = setInterval(() => {
      setFree(checkFreeDocks(station.id));
      setTimestamp(new Date());
    }, 30000);
    return () => clearInterval(id);
  }, [station.id]);

  return <span>Occupancy: {station.docks - free}/{station.docks}</span>;
}

Exercise 2. A colleague writes this hook and complains that "the counter is shared between the two cards." Explain why that's impossible, what they're actually observing, and how they'd truly get a shared counter.

export function useViews() {
  const [views, setViews] = useState(0);
  const record = () => setViews((previous) => previous + 1);
  return { views, record };
}

Exercise 3. Write usePrevious(value), a hook that returns the value its argument held on the previous render (and undefined on the first one). Then use it in DockCounter to show whether the free docks have gone up or down since the previous reading. Justify why the hook uses useRef and not useState.

Solutions

Solution 1.

// src/hooks/useAvailabilityTimer.js
import { useState, useEffect } from 'react';
import { checkFreeDocks } from '../utils/availability.js';

/**
 * Periodically checks a station's free docks.
 * Parameters:
 *  - station    (Station object, required)
 *  - intervalMs (number, optional, defaults to 10000)
 * Returns: { freeDocks, occupied, lastReading }
 */
export function useAvailabilityTimer(station, intervalMs = 10000) {
  const [freeDocks, setFreeDocks] = useState(station.docks);
  const [lastReading, setLastReading] = useState(null);

  useEffect(() => {
    const id = setInterval(() => {
      setFreeDocks(checkFreeDocks(station.id));
      setLastReading(new Date());
    }, intervalMs);

    return () => clearInterval(id);
  }, [station.id, intervalMs]);

  return {
    freeDocks,
    occupied: station.docks - freeDocks,   // derived: the hook computes it, not the component
    lastReading
  };
}
function StationAvailability({ station }) {
  const { freeDocks } = useAvailabilityTimer(station);
  return <p>{station.name}: {freeDocks} free docks</p>;
}

function FleetSummary({ station }) {
  const { occupied } = useAvailabilityTimer(station, 30000);
  return <span>Occupancy: {occupied}/{station.docks}</span>;
}

Three design decisions worth justifying: it returns an object because these are three values with no natural order; the interval is a parameter with a default value, because the two components needed a different one and that was their only real difference; and occupied gets computed inside the hook because it's a derived value both consumers would want, so nobody gets the subtraction wrong. And an important consequence of section 2's property: the two components have independent timers, with different frequencies and separate state. Sharing the hook doesn't mean sharing the timer.

Solution 2.

It's impossible for the counter to be shared: every call to useViews() runs its own useState, which occupies a cell in the calling component's own hook list. Two cards are two components, with two lists and two states. The useViews function is a template; React creates the state per instance.

What the colleague is actually observing is one of these three things:

  1. There aren't two instances, just one. If the two "cards" are really the same component with different props, they share state because they're the same instance. Fixed with the key prop (05-01).
  2. The state isn't in the hook, it's further up. If the value comes from a context (05-04), it's shared by design and the hook only reads it.
  3. They declared the state outside the hook, at module scope, which genuinely is shared and, on top of that, doesn't trigger renders:
// ❌ This does share, and it works badly: it re-renders nobody
let views = 0;
export function useViews() {
  const record = () => { views += 1; };
  return { views, record };
}

To get a counter that's genuinely shared and reactive, the answer is context:

// src/contexts/ViewsContext.jsx
import { createContext, useContext, useState } from 'react';

const ViewsContext = createContext(null);

export function ViewsProvider({ children }) {
  const [views, setViews] = useState(0);
  const record = () => setViews((previous) => previous + 1);

  return <ViewsContext value={{ views, record }}>{children}</ViewsContext>;
}

export function useViews() {
  const context = useContext(ViewsContext);
  if (context === null) throw new Error('useViews must be used inside <ViewsProvider>');
  return context;
}

Now every card wrapped by <ViewsProvider> reads and updates the same counter. Notice that useViews is still a custom hook: what changed isn't the hook, but where the state lives.

Solution 3.

// src/hooks/usePrevious.js
import { useRef, useEffect } from 'react';

/**
 * Returns the value the argument held on the previous render.
 * Parameters:
 *  - value (any)
 * Returns: the previous value, or undefined on the first render
 */
export function usePrevious(value) {
  const ref = useRef(undefined);

  useEffect(() => {
    ref.current = value;   // written AFTER the render (05-03)
  }, [value]);

  return ref.current;      // during render, it still holds the previous value
}
// src/components/DockCounter.jsx
import { usePrevious } from '../hooks/usePrevious.js';
import styles from './DockCounter.module.css';
import { cx } from '../utils/classNames.js';

function DockCounter({ station, freeDocks }) {
  const previousDocks = usePrevious(freeDocks);

  const trend =
    previousDocks === undefined || previousDocks === freeDocks
      ? 'steady'
      : freeDocks > previousDocks
        ? 'rising'
        : 'falling';

  const SYMBOLS = { rising: '▲', falling: '▼', steady: '=' };

  return (
    <p className={cx(styles.counter, styles[trend])}>
      {station.name}: {freeDocks} docks
      <span aria-hidden="true"> {SYMBOLS[trend]}</span>
      <span className={styles.srOnly}>
        {trend === 'rising' ? 'rising' : trend === 'falling' ? 'falling' : 'unchanged'}
      </span>
    </p>
  );
}

Why useRef and not useState: writing the previous value must not trigger a render. With useState, every update to the value would fire an extra render just to store the copy, that render would trigger another comparison, and at best you'd double the re-renders; at worst, you'd enter a loop. And there's a deeper reason: the previous value isn't new data the app produces, it's memory of what was already painted. It fits exactly 05-03's definition of useRef: something remembered across renders without being part of any of them.

The aria-hidden on the symbol and the hidden alt text come from 03-06 and from StatusBadge's convention: a character like means nothing to a screen reader.

Conclusion

A custom hook is simply a function that starts with use and calls other hooks, and with that the circle 04-04 opened closes: reusing stateful logic without a single wrapper in the component tree. The property you always have to keep in mind is that it shares logic, not state: every component that calls useCounter, useToggle, or useAvailabilityTimer gets its own independent copy, and genuinely sharing a piece of data is still the job of lifting state up or of context. You've learned to extract a hook step by step from code that already exists, and you've built CicloUrbano's collection in src/hooks/: useToggle, useLocalStorage with safe serialization, useDebounce for the search box, useFetchBikes with AbortController and cancellation, useKeyEvent for closing the Modal with Escape, useWindowWidth, and useOnlineStatus. And along with them, the design criteria that make a hook usable by someone else: an array for positional pairs and an object for three or more values, parameters with default values, stable return values, and a single responsibility per hook. 04-04's two rules remain fully intact, and in exchange hooks compose with each other just like components do.

With this, Module 5 ends, and with it, the core of React. Across six lessons you've gone from basic useState to full control of state: the render snapshot and queued updates (05-01), synchronizing with external systems and race conditions (05-02), the memory that doesn't re-render and DOM access (05-03), the end of prop drilling (05-04), complex state governed by actions and pure reducers (05-05), and your own logic turned into reusable hooks (05-06). CicloUrbano is now an app with a catalogue, filters, validated bookings, notices, a visual theme, a user, and data fetching.

One obvious thing is still missing: it's a single screen. The header has carried a "Catalogue · Stations · My bookings" menu since Module 2 whose links go nowhere, because until now there's been no way for the browser's URL and the UI to correspond. That means shareable addresses, working back and forward buttons, and sections that only load when they're needed. Module 6: Routing in React solves this with React Router: what it is and why it isn't bundled with React, how to set it up, nested routes so Layout wraps every screen, programmatic navigation after confirming a booking, and protected routes visible only to operator usr-02. The next lesson is Introducing React Router.

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