What Color Is Your Hook?

3 months ago 3

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.

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.

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);

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

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]); }

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?

Read Entire Article