Skip to content
academia.sh

Lesson 16 / 25

Custom Hooks and Composable Functions

Units that package state and side effects without producing markup; the call-order rule for position-based hooks, scope cleanup for functions that run once at setup, and a comparison of the two models.

Contents

The previous lesson solved the reuse of structure. But structure is not the only thing that repeats across components. On the North Slope Measurement Station page, the filter panel’s search field triggers on a delay after input, the measurement table holds a sort preference, and the measurement badge refreshes at an interval. All three have state, all three have a side effect and that effect’s cleanup. All three were written separately.

This logic cannot be moved as long as it stays inside a component’s body. To be movable, it has to be separated from markup, and to be separable, state has to be attachable to a call instead of a component. This lesson’s subject is how that call is set up in two different framework families, and which rule each one imposes.

What Gets Separated

A component does two jobs: it manages state and produces a tree from that state. The second job is markup, and it is specific to the component. The first job is, most of the time, not specific.

Separation takes this shape: a function that holds state, derived values, and side effects; that does not produce markup; and that returns a handle made of values to read and functions to call. In the framework family that keeps a virtual tree, this function is called a custom hook; in the family that tracks dependencies at runtime, it is called a composable. Their purpose is the same, their runtime contracts differ.

The test for the separation is a simple question: can two components that call this function look completely different? If the answer is no, what was separated is not logic but a hidden component.

Position-Bound Slots

In the virtual-tree family, a component is a function and it runs from the start on every render. Local variables created in one render do not survive into the next. State has to live outside the component, somewhere the framework holds it.

The cheapest implementation of this is position: a slot array belonging to the component is kept, and each hook call takes the next slot. A hook’s identity is not its name or its parameters but which call number it is.

// hooks.mjs — position-based hook runtime, hook composition, and the call-order rule
let slots = [];
let cursor = 0;

// A single render: reset the cursor, run the component function.
const render = (component, props = {}) => { cursor = 0; return component(props); };

// The one primitive: a position-based state slot.
function useState(initialValue) {
  const i = cursor++;
  if (!(i in slots)) slots[i] = initialValue;
  return [slots[i], (next) => { slots[i] = next; }];
}

// Custom hook: two slots and a validation rule; produces no markup.
function useThreshold(initial) {
  const [threshold, setThreshold] = useState(initial);
  const [touched, setTouched] = useState(false);
  const valid = Number.isFinite(threshold) && threshold >= -40 && threshold <= 40;
  return {
    threshold, valid,
    error: touched && !valid ? "threshold must be between -40 and 40" : null,
    write(next) { setThreshold(next); setTouched(true); },
  };
}

// Second custom hook: sort field and direction.
function useSort(initialField) {
  const [field, setField] = useState(initialField);
  const [ascending, setAscending] = useState(true);
  return {
    field, ascending,
    click(next) { if (next === field) setAscending(!ascending); else { setField(next); setAscending(true); } },
  };
}

// Third custom hook only combines the first two; it has no slot of its own.
const useFilter = () => ({ threshold: useThreshold(-5), sort: useSort("name") });

// Component: calls the hook, returns its view and the handle it hands outward.
function FilterPanel() {
  const f = useFilter();
  const summary =
    `threshold=${f.threshold.threshold} valid=${f.threshold.valid} error=${f.threshold.error ?? "-"} ` +
    `sort=${f.sort.field}/${f.sort.ascending ? "ascending" : "descending"}`;
  return { summary, f };
}

console.log("--- composing two custom hooks: four slots ---");
const c1 = render(FilterPanel);
console.log("render 1:", c1.summary, "| slots:", JSON.stringify(slots));

c1.f.threshold.write(88);
const c2 = render(FilterPanel);
console.log("render 2:", c2.summary, "| slots:", JSON.stringify(slots));

c2.f.sort.click("value");
const c3 = render(FilterPanel);
console.log("render 3:", c3.summary, "| slots:", JSON.stringify(slots));

c3.f.sort.click("value");
const c4 = render(FilterPanel);
console.log("render 4:", c4.summary, "| slots:", JSON.stringify(slots));

// --- Call-order rule ---
console.log("\n--- conditional call: slots shifting ---");
slots = [];
function brokenPanel({ advanced }) {
  const [query] = useState("north");
  if (advanced) useState(-5);                // called only on some renders
  const [sort] = useState("name");
  return { query, sort };
}
console.log("advanced on :", JSON.stringify(render(brokenPanel, { advanced: true })),
  "| slots:", JSON.stringify(slots));
console.log("advanced off:", JSON.stringify(render(brokenPanel, { advanced: false })),
  "| slots:", JSON.stringify(slots));

// Version that applies the rule: the hook is called unconditionally, the condition lives in the returned value.
console.log("\n--- unconditional call, conditional value ---");
slots = [];
function fixedPanel({ advanced }) {
  const [query] = useState("north");
  const [threshold] = useState(-5);
  const [sort] = useState("name");
  return { query, threshold: advanced ? threshold : null, sort };
}
console.log("advanced on :", JSON.stringify(render(fixedPanel, { advanced: true })),
  "| slots:", JSON.stringify(slots));
console.log("advanced off:", JSON.stringify(render(fixedPanel, { advanced: false })),
  "| slots:", JSON.stringify(slots));
--- composing two custom hooks: four slots ---
render 1: threshold=-5 valid=true error=- sort=name/ascending | slots: [-5,false,"name",true]
render 2: threshold=88 valid=false error=threshold must be between -40 and 40 sort=name/ascending | slots: [88,true,"name",true]
render 3: threshold=88 valid=false error=threshold must be between -40 and 40 sort=value/ascending | slots: [88,true,"value",true]
render 4: threshold=88 valid=false error=threshold must be between -40 and 40 sort=value/descending | slots: [88,true,"value",false]

--- conditional call: slots shifting ---
advanced on : {"query":"north","sort":"name"} | slots: ["north",-5,"name"]
advanced off: {"query":"north","sort":-5} | slots: ["north",-5,"name"]

--- unconditional call, conditional value ---
advanced on : {"query":"north","threshold":-5,"sort":"name"} | slots: ["north",-5,"name"]
advanced off: {"query":"north","threshold":null,"sort":"name"} | slots: ["north",-5,"name"]

The first section shows composition working. useFilter is a hook with no slot of its own; it only calls two hooks side by side, and through the fourth render, the four slots carry the correct values in order. The slot array is a flat list: the hierarchy of nested hook calls does not appear in it, only the call order does.

That hooks can call each other is this model’s strength. A hook does not announce how many slots it uses internally, and the caller does not need to know either, as long as the order stays fixed.

The Call-Order Rule

The second section shows what happens when that constancy breaks. With advanced true, three hooks are called and the slots become ["north", -5, "name"]. When the same component renders with advanced false, the middle call is skipped; the sort hook is now in second position and reads index 1, which is the first hook’s slot. The result is that the sort field becomes the number -5.

The failure is silent. No exception is thrown, no warning is printed; only the wrong value is read. In the measurement table, this means the sort silently breaks.

From this comes the one rule of the position-based runtime: hooks must be called the same number of times and in the same order on every render. They cannot be placed inside a condition, a loop, or an early return. The third section shows how the rule is applied: the hook is called unconditionally, and the condition is moved into the returned value. The slot array stays the same across both renders.

The rule has a cost: unused state is still held. With advanced off, the threshold slot stays in memory and keeps its value. This is usually the desired behavior — the threshold stays put when the user closes and reopens the advanced panel — but it is not a choice; it is a forced consequence of the model.

Functions That Run Once at Setup

In the family that tracks dependencies at runtime, the component function does not re-run on every update; it runs once, sets up reactive values, and binds the view to those values. State’s identity is not a slot order but the object produced at setup itself.

// composable.mjs — functions that run once at setup time, and cleanup scoping
let activeScope = null;

function openScope(setup) {
  const cleanups = [];
  const previous = activeScope;
  activeScope = { addCleanup: (f) => cleanups.push(f) };
  const result = setup();
  activeScope = previous;
  return {
    result,
    dispose() {                                   // last registered, first disposed
      for (let i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
      cleanups.length = 0;
    },
    pendingCleanups: () => cleanups.length,
  };
}

// Position-independent state: identity is the object itself, not a slot's position.
function state(initialValue) {
  let value = initialValue;
  const listeners = new Set();
  return {
    read: () => value,
    write(next) { value = next; for (const l of [...listeners]) l(next); },
    listen(l) {
      listeners.add(l);
      activeScope?.addCleanup(() => listeners.delete(l));
    },
    listenerCount: () => listeners.size,
  };
}

const log = [];

// Composable 1: threshold and its validation.
function useThreshold(initial) {
  const threshold = state(initial);
  const valid = () => Number.isFinite(threshold.read()) && threshold.read() >= -40 && threshold.read() <= 40;
  threshold.listen((v) => log.push(`threshold written: ${v} (valid=${valid()})`));
  return { threshold, valid };
}

// Composable 2: interval refresh with a fake clock.
const clock = { tasks: [], every: (name, job) => { clock.tasks.push({ name, job }); return () => { clock.tasks = clock.tasks.filter((t) => t.job !== job); }; }, tick: () => clock.tasks.forEach((t) => t.job()) };

function useRefresh(name) {
  const counter = state(0);
  const stop = clock.every(name, () => counter.write(counter.read() + 1));
  activeScope?.addCleanup(() => { stop(); log.push(`${name}: refresh stopped`); });
  return counter;
}

// Composable 3: only combines the other two.
function panelSetup({ advanced }) {
  const thresholdState = useThreshold(-5);
  // Conditional setup is safe: identity is not position-bound.
  const refresh = advanced ? useRefresh("advanced panel") : null;
  return { ...thresholdState, refresh };
}

console.log("--- advanced off ---");
const a = openScope(() => panelSetup({ advanced: false }));
a.result.threshold.write(12);
clock.tick();
console.log("refresh object:", a.result.refresh, "| tasks on the clock:", clock.tasks.length,
  "| pending cleanups:", a.pendingCleanups());

console.log("\n--- advanced on ---");
const b = openScope(() => panelSetup({ advanced: true }));
b.result.threshold.write(88);
clock.tick(); clock.tick();
console.log("refresh counter:", b.result.refresh.read(), "| tasks on the clock:", clock.tasks.length,
  "| pending cleanups:", b.pendingCleanups());
console.log("threshold listeners:", b.result.threshold.listenerCount());

console.log("\n--- after the scope is disposed ---");
b.dispose();
clock.tick();
console.log("tasks on the clock:", clock.tasks.length,
  "| threshold listeners:", b.result.threshold.listenerCount(),
  "| refresh counter:", b.result.refresh.read());

console.log("\nlog:");
for (const s of log) console.log(" ", s);
--- advanced off ---
refresh object: null | tasks on the clock: 0 | pending cleanups: 1

--- advanced on ---
refresh counter: 2 | tasks on the clock: 1 | pending cleanups: 2
threshold listeners: 1

--- after the scope is disposed ---
tasks on the clock: 0 | threshold listeners: 0 | refresh counter: 2

log:
  threshold written: 12 (valid=true)
  threshold written: 88 (valid=false)
  advanced panel: refresh stopped

Conditional setup is safe here. With advanced off, the refresh function is never called, no slot shifts, no value breaks — because identity is bound to the object, not to sequence. In the off state, the clock has no tasks; in the on state, it has one task, and after two ticks the counter is 2.

The second distinction is cleanup. Composables register their own cleanup with the active scope at setup time; the caller does not know about this and does nothing manually. When the scope is disposed, the registrations run in reverse order: the last one set up is the first one disposed. After disposal, no task remains on the clock and no listener remains on the threshold; the counter freezes at its last value.

The reverse order is not a detail. If the second function leans on what the first one set up, the first one must still be standing during disposal. The reverse of the setup order guarantees this condition every time.

The Trade-Off Between the Two Models

The two models meet the same purpose at different costs.

The position-based model keeps no ledger at runtime: a slot array and a cursor are enough. Its cost is that call order turns into a contract, and that contract cannot be checked by the language itself. The rule is left to human discipline and static analysis.

The setup-based model does not depend on call order at all; setup can be conditional, inside a loop, or delayed. Its cost is that every reactive value and every listener is held as an object: the ledger lives at runtime. The “setup runs once” rule also brings its own trap — a prop read at setup captures that prop’s initial value; tracking later values requires the read value itself to be reactive.

Three rules are shared by both models. The separated function returns no markup; if it did, it would be a component. What it takes in and returns must be named; a hook that returns a two-element array breaks every caller when a third value is added. And the separated function must be testable on its own: if it can be called and its values read without rendering a component, the separation has genuinely been made.

Summary

  • A custom hook and a composable are units that package state and side effects without producing markup; they are different contracts for the same purpose across the two framework families.
  • In a position-based runtime, a hook’s identity is its call order; the slot array is flat and does not carry the hierarchy of nested hook calls.
  • A conditional hook call shifts the slots and the failure is silent: no exception is thrown, the wrong value is read. The rule is to call the hook unconditionally and move the condition into the returned value.
  • In functions that run once at setup, identity is the object; conditional setup is safe, and its cost is the ledger kept at runtime.
  • Cleanup is registered with the active scope and disposal runs in the reverse of the setup order, so that whatever a function leans on is still standing when that function is disposed.
  • The separated function returns no markup, returns named values, and can be tested without rendering a component.

Next Step

This lesson took logic out of the component, but it did not address the relationship between components. The filter panel has a tabbed structure: tab headers, tab bodies, and the information about which tab is active. Giving this information to every tab as a prop brings back the prop drilling measured in the previous lesson; letting each tab keep its own copy allows two tabs to be active at the same time. What is needed is a family written as separate components that share a single piece of state. The next lesson builds how this family is set up, how the implicit contract between its members is checked, and how misuse is kept from staying silent.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close