What Color is Your Hook?

11 min read Original article ↗

The Scenario

Picture this: you're building a workflow editor where users can create nodes and connect them. Each node can reference variables from connected upstream nodes. Your first requirement seems simple enough:

Requirement 1: Show users which variables they can reference in the current node, updating in real-time as connections change.

Easy! You create a hook that calculates available variables based on the current graph structure:

function useNodeVariables(nodeId: string) {
  const graph = useWorkflowGraph();

  return useMemo(() => {
    const upstreamNodes = findUpstreamNodes(graph, nodeId);
    return extractVariables(upstreamNodes);
  }, [graph, nodeId]);
}

Perfect! Now your variable picker updates automatically when users connect or disconnect nodes:

function VariablePicker({ nodeId }: { nodeId: string }) {
  const variables = useNodeVariables(nodeId); // Updates automatically!

  return (
    <div>
      {variables.map(v => (
        <VariableOption key={v.id} variable={v} />
      ))}
    </div>
  );
}

Life is good. Your hook works beautifully for UI components that need real-time updates.

Then Comes a New Requirement

A few days later, your product manager comes with a new request:

Requirement 2: When users save the workflow, validate that all variable references are still valid and show errors for broken ones.

No problem! You already have useNodeVariables, so you use it in your save handler:

function SaveButton() {
  const [errors, setErrors] = useState([]);

  const handleSave = async () => {
    const allNodes = getAllNodes();
    const validationErrors = [];

    for (const node of allNodes) {
      const variables = useNodeVariables(node.id); // 🚨 This won't work!
      const brokenRefs = findBrokenReferences(node, variables);
      validationErrors.push(...brokenRefs);
    }

    setErrors(validationErrors);
  };

  return <button onClick={handleSave}>Save</button>;
}

Wait... you can't call hooks inside loops or event handlers! React yells at you with the dreaded Rules of Hooks error.

The First Solution: Move Hook to Component Level

Okay, let's move the hook to the component level:

function SaveButton() {
  const [currentNodeId, setCurrentNodeId] = useState(null);
  const variables = useNodeVariables(currentNodeId); // 😵 But which node?

  const handleSave = async () => {
    // How do I get variables for ALL nodes?
    // I can only get variables for one node at a time...
  };

  return <button onClick={handleSave}>Save</button>;
}

This doesn't work either. You need variables for ALL nodes during validation, but your reactive hook only works for one node at a time, and only within the component render cycle.

The Real Problem Emerges

You realize you have two fundamentally different use cases:

  1. UI Display: Show variables for a specific node, update automatically when graph changes
  2. Event Handling: Get variables for any node on-demand during save validation

Your reactive hook works great for #1 but fails completely for #2. You need a different approach for event handlers—something that can be called imperatively, outside of React's render cycle.

The Solution: Two Different Hooks

You decide to create a second hook specifically for imperative use:

// For UI components - reactive, auto-updating
function useNodeVariables(nodeId: string) {
  const graph = useWorkflowGraph();
  return useMemo(() => {
    const upstreamNodes = findUpstreamNodes(graph, nodeId);
    return extractVariables(upstreamNodes);
  }, [graph, nodeId]);
}

// For event handlers - imperative, call on-demand
function useGetNodeVariables() {
  const getGraph = useGetWorkflowGraph(); // Also imperative

  return useCallback(
    (nodeId: string) => {
      const graph = getGraph();
      const upstreamNodes = findUpstreamNodes(graph, nodeId);
      return extractVariables(upstreamNodes);
    },
    [getGraph],
  );
}

Now your save validation works perfectly:

function SaveButton() {
  const getNodeVariables = useGetNodeVariables();

  const handleSave = async () => {
    const allNodes = getAllNodes();
    const validationErrors = [];

    for (const node of allNodes) {
      const variables = getNodeVariables(node.id); // ✅ Works in event handlers!
      const brokenRefs = findBrokenReferences(node, variables);
      validationErrors.push(...brokenRefs);
    }

    if (validationErrors.length === 0) {
      await saveWorkflow();
    } else {
      showErrors(validationErrors);
    }
  };

  return <button onClick={handleSave}>Save</button>;
}

Great! You now have:

  • useNodeVariables for UI components that need automatic updates
  • useGetNodeVariables for event handlers that need on-demand access

But Wait, There's a Problem: Code Duplication

Looking at your two hooks, you notice they share the same core logic:

// Reactive version
function useNodeVariables(nodeId: string) {
  const graph = useWorkflowGraph();
  return useMemo(() => {
    const upstreamNodes = findUpstreamNodes(graph, nodeId); // 🔄 Duplicated
    return extractVariables(upstreamNodes); // 🔄 Duplicated
  }, [graph, nodeId]);
}

// Imperative version
function useGetNodeVariables() {
  const getGraph = useGetWorkflowGraph();

  return useCallback(
    (nodeId: string) => {
      const graph = getGraph();
      const upstreamNodes = findUpstreamNodes(graph, nodeId); // 🔄 Duplicated
      return extractVariables(upstreamNodes); // 🔄 Duplicated
    },
    [getGraph],
  );
}

This duplication is dangerous. What happens when the business logic changes? You'll need to update it in two places, and it's easy to forget one. Plus, if the logic has bugs, you'll have to fix them twice.

Extract Pure Functions

The key insight is to extract the core business logic into pure functions:

// Pure function - no React, no hooks, just logic
function calculateNodeVariables(graph: WorkflowGraph, nodeId: string) {
  const upstreamNodes = findUpstreamNodes(graph, nodeId);
  return extractVariables(upstreamNodes);
}

// Reactive hook - uses pure function
function useNodeVariables(nodeId: string) {
  const graph = useWorkflowGraph();
  return useMemo(() => calculateNodeVariables(graph, nodeId), [graph, nodeId]);
}

// Imperative hook - uses same pure function
function useGetNodeVariables() {
  const getGraph = useGetWorkflowGraph();

  return useCallback(
    (nodeId: string) => {
      const graph = getGraph();
      return calculateNodeVariables(graph, nodeId);
    },
    [getGraph],
  );
}

Now your business logic lives in one place! But the benefits go beyond just avoiding duplication:

Benefits of Pure Functions

  1. Easy to test: No React context needed, just pass inputs and check outputs
  2. Cacheable: Can be memoized for performance since output only depends on input
  3. Reusable: Can be used in other contexts (server-side, workers, etc.)
  4. Debuggable: Easy to step through without React complexity
// Easy unit testing
describe('calculateNodeVariables', () => {
  it('should return variables from upstream nodes', () => {
    const graph = createMockGraph();
    const variables = calculateNodeVariables(graph, 'node-1');
    expect(variables).toEqual([...]);
  });
});

// Can be cached for performance
const memoizedCalculateNodeVariables = memoize(calculateNodeVariables);

Discovering Hook Colors

At this point, you've discovered something important. Inspired by the classic article "What Color is Your Function?", you realize that React hooks also have "colors"—not about sync/async, but about reactivity and imperative access.

🔵 Reactive Hooks (Blue Hooks)

  • Subscribe to state changes and auto-update
  • Trigger re-renders when dependencies change
  • Perfect for UI that needs real-time updates

🔴 Imperative Hooks (Red Hooks)

  • Return functions that fetch data on-demand
  • Don't trigger re-renders or subscribe to changes
  • Perfect for event handlers and business logic

Colors Don't Mix Well

Let's revisit our example: useNodeVariables uses useWorkflowGraph (blue), while useGetNodeVariables uses useGetWorkflowGraph (red). What if we swapped them—using red hooks inside blue hooks, or vice versa?

Experiment 1: Using Red Hook in Blue Hook

// 🔵 Blue hook trying to use red hook ❌
function useNodeVariables(nodeId: string) {
  const getGraph = useGetWorkflowGraph(); // 🔴 Red hook inside blue hook

  return useMemo(() => {
    const graph = getGraph();
    const upstreamNodes = findUpstreamNodes(graph, nodeId);
    return extractVariables(upstreamNodes);
  }, [getGraph, nodeId]);
}

Used in a component:

function VariablePicker({ nodeId }: { nodeId: string }) {
  const variables = useNodeVariables(nodeId);
  // ...
}

What goes wrong? The component shows stale data. getGraph() returns a snapshot from when the hook was created, so the UI doesn't update as the graph changes.

Experiment 2: Using Blue Hook in Red Hook

// 🔴 Red hook trying to use blue hook ❌
function useGetNodeVariables() {
  const graph = useWorkflowGraph(); // 🔵 Blue hook inside red hook

  return useCallback(
    (nodeId: string) => {
      const upstreamNodes = findUpstreamNodes(graph, nodeId);
      return extractVariables(upstreamNodes);
    },
    [graph],
  );
}

Used in an event handler:

function SaveButton() {
  const getNodeVariables = useGetNodeVariables();
  // ...
}

What goes wrong? Performance suffers. Even if you try to use useMemo to maintain a stable callback reference, since getNodeVariables depends on graph, the callback gets recreated every time the graph updates, causing unnecessary re-renders and resource waste, even though you only need fresh data at the moment the user clicks the save button.

The Color Contamination Problem

Hook "colors" are contagious. Mixing them causes issues:

  • Red hooks using blue hooks become reactive, losing their on-demand nature
  • Blue hooks using red hooks become static, losing automatic updates

Key insight: Build red hooks from red hooks, and blue hooks from blue hooks.

graph TD
    subgraph Pure Functions
        PF1[PF 1]:::pure
        PF2[PF 2]:::pure
        PF3[PF 3]:::pure
        PF4[PF 4]:::pure
    end
    subgraph Reactive Hooks
        RH1[RH 1]:::reactive
        RH2[RH 2]:::reactive
        RH3[RH 3]:::reactive
        RH4[RH 4]:::reactive
        RH5[RH 5]:::reactive
    end
    subgraph Imperative Hooks
        IH1[IH 1]:::imperative
        IH2[IH 2]:::imperative
        IH3[IH 3]:::imperative
        IH4[IH 4]:::imperative
        IH5[IH 5]:::imperative
    end

    PF1 --> RH1
    PF1 --> IH1
    PF2 --> RH2
    PF2 --> IH2
    PF3 --> RH3
    PF4 --> IH3
    RH1 --> RH4
    RH2 --> RH4
    IH1 --> IH4
    IH2 --> IH4
    RH3 --> RH5
    RH4 --> RH5
    PF3 --> RH5
    IH3 --> IH5
    IH4 --> IH5
    PF4 --> IH5

    classDef pure fill:#f5f5f5,stroke:#666666,stroke-width:2px
    classDef reactive fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    classDef imperative fill:#ffebee,stroke:#d32f2f,stroke-width:2px
Loading
Build blue hooks from blue hooks, and red hooks from red hooks. Pure functions are colorless and can be safely used by both.

Not Every Hook Needs Both Colors

Another point I want to emphasize is: not every hook needs both colors.

Going back to our useNodeVariables example, you might have noticed a performance issue. Suppose your graph contains node position information, so every time users drag nodes around the canvas, the graph updates, which triggers findUpstreamNodes to recalculate—even though node positions don't affect variable availability.

function useNodeVariables(nodeId: string) {
  const graph = useWorkflowGraph(); // Updates on every node drag!

  return useMemo(() => {
    const upstreamNodes = findUpstreamNodes(graph, nodeId); // Expensive recalculation
    return extractVariables(upstreamNodes);
  }, [graph, nodeId]);
}

To solve this problem, you create an optimized hook useAbstractWorkflowGraph specifically for useWorkflowGraph, filtering out position changes:

function useAbstractWorkflowGraph() {
  const fullGraph = useWorkflowGraph();

  // This hook returns a graph that ignores position changes
  // Only updates when node IDs, connections, or node data change
  return useCustomCompareMemo(
    () => fullGraph,
    [fullGraph],
    (prevGraph, nextGraph) => {
      // Custom comparison: only care about structural changes, not positions
      return isGraphStructurallyEqual(prevGraph, nextGraph);
    },
  );
}

// Now our variable hook doesn't recalculate on position changes
function useNodeVariables(nodeId: string) {
  const graph = useAbstractWorkflowGraph(); // Only updates on structural changes

  return useMemo(() => {
    const upstreamNodes = findUpstreamNodes(graph, nodeId);
    return extractVariables(upstreamNodes);
  }, [graph, nodeId]);
}

After careful consideration, you'll find: useAbstractWorkflowGraph doesn't need an imperative version. Why?

  • It's a reactive optimization: This hook exists specifically to reduce unnecessary reactive updates in UI components
  • Imperative scenarios don't have this problem: When you call getWorkflowGraph() imperatively in an event handler, you're not subscribing to updates, so frequent changes don't matter—you only get data when you explicitly request it
  • Imperative scenarios can use the base hook: useGetWorkflowGraph() is sufficient for on-demand access since event handlers don't need to care about filtering out position changes

This reveals an important principle: not every hook needs both colors. Some hooks are naturally suited to only one color based on their purpose.

useAbstractWorkflowGraph is naturally blue—it exists specifically to optimize reactive updates in UI components. An imperative version would be meaningless because red scenarios don't have the "frequent update" problem this hook solves. They can just use useGetWorkflowGraph() directly when they need data.

Naming Conventions: Making Intent Clear

To make hook colors obvious at a glance, we should follow consistent naming conventions:

Blue Hooks (Reactive): use[Thing]

const variables = useNodeVariables(nodeId);
const status = useValidationStatus();
const graph = useWorkflowGraph();

Features: Returns actual data, auto-updates, suitable for UI components.

Red Hooks (Imperative): useGet[Thing] or use[Action]

const getVariables = useGetNodeVariables();
const validateWorkflow = useValidateWorkflow();
const exportData = useExportWorkflow();

Features: Returns a function, call when you need fresh data, suitable for event handlers.

Pure Functions: Verb-based

calculateNodeVariables(graph, nodeId);
validateWorkflowData(data);
exportWorkflowToJson(workflow);

Features: No "use" prefix, names describe what they do, testable and reusable.

Quick Reference: When to Use What

Use Blue Hooks (🔵 Reactive) When

  • Displaying data in UI that should update automatically
  • Computing derived state that depends on other reactive data
  • Building other reactive hooks that need to stay in sync
// ✅ UI that updates automatically
function NodeEditor({ nodeId }: { nodeId: string }) {
  const variables = useNodeVariables(nodeId);
  return <VariableSelector options={variables} />;
}

// ✅ Derived reactive state
function useHasErrors() {
  const status = useValidationStatus();
  return useMemo(() => status.errors.length > 0, [status]);
}

Use Red Hooks (🔴 Imperative) When

  • Handling user events (clicks, form submissions)
  • Running side effects (timers, API calls)
  • One-time operations that don't need continuous updates
// ✅ Event handling
function SaveButton() {
  const validateAndSave = useValidateAndSave();
  return <button onClick={() => validateAndSave()}>Save</button>;
}

// ✅ Side effects
function useAutoSave() {
  const getWorkflowData = useGetWorkflowData();

  useEffect(() => {
    const timer = setInterval(() => {
      const data = getWorkflowData();
      if (data.isDirty) saveToServer(data);
    }, 30000);
    return () => clearInterval(timer);
  }, [getWorkflowData]);
}

Conclusion

What started as a simple feature request—showing available variables to users—led us to discover a fundamental pattern in React architecture. Just like the original "What Color is Your Function?" article revealed the hidden complexity of async/sync functions, React hooks also have "colors".

The key insight: hooks inherit their color. Blue hooks compose with other blue hooks, and red hooks compose with other red hooks. Mix them incorrectly, and you get performance issues, stale data, and subtle bugs. Every hook you write is making an implicit choice about reactivity that will constrain every hook that depends on it.

So next time you're designing hooks, maybe ask yourself:

What color is your hook?