DEV Community

Cover image for Why Visual Metaphors Might Beat Code-First Thinking
Rostislav B
Rostislav B

Posted on

Why Visual Metaphors Might Beat Code-First Thinking

Frontend developers could spend all day thinking visually —component trees, state flow diagrams, DevTools showing exactly where things break. Then an algorithm problem appears, and that visual instinct vanishes. The editor opens, typing starts, and somehow we expect the code to figure itself out.

On the other hand, every tutorial says "draw it out before coding." Which makes sense — but draw what, exactly? If you haven't seen the pattern before, a blank page stays blank. It seems to work the other way around: learn a few visual shapes, and the right one surfaces when you need it.


The Code-First Trap

Consider the typical approach — see a problem, open the editor, start typing:

// See problem → start typing immediately
function solve(arr) {
  // uhh... for loop?
  for (let i = 0; i < arr.length; i++) {
    // wait, nested loop?
    for (let j = i + 1; j < arr.length; j++) {
      // this feels wrong but I'll keep going...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two hours later, still broken. No idea why.

The problem becomes clearer with distance: JavaScript's flexibility lets you write anything. No compiler stops you from pursuing bad ideas. Without some visual mental model of what you're trying to do, debugging becomes guesswork — just changing things and hoping something sticks. And if you, like me, sometimes have trouble maintaining focus, the thread gets lost completely after 20 minutes of trial and error.


Pattern 1: Finding the Maximum

Find the largest number in an array. Picture a champion on stage — challengers come one by one, strongest stays.

function findMax(arr) {
  let champion = arr[0]; // First one on stage

  for (let challenger of arr) {
    if (challenger > champion) {
      champion = challenger;
    }
  }

  return champion;
}
Enter fullscreen mode Exit fullscreen mode

Simple, but it sets up the idea: a visual shape makes the code obvious.


Pattern 2: Stack (Matching Brackets)

Here's a classic problem: check if brackets are balanced. "(())" is valid, "(()" is not.

The code-first approach hits a wall fast:

function isValid(s) {
  // Count opening and closing?
  let count = 0;
  for (let char of s) {
    if (char === '(') count++;
    if (char === ')') count--;
  }
  return count === 0;
}
// Fails on ")(" - same count, wrong order!
// Now what? Add more conditions? Track positions?
Enter fullscreen mode Exit fullscreen mode

Consider a different model: think of it as a stack of plates.

Input: "(())"

Read (  →  Add plate     Stack: [(]
Read (  →  Add plate     Stack: [(, (]
Read )  →  Remove plate  Stack: [(]
Read )  →  Remove plate  Stack: []

Stack empty at end = Valid ✓

Input: "(()"

Read (  →  Add plate     Stack: [(]
Read (  →  Add plate     Stack: [(, (]
Read )  →  Remove plate  Stack: [(]
End: Stack not empty = Invalid ✗
Enter fullscreen mode Exit fullscreen mode

Opening bracket? Add a plate. Closing bracket? Remove a plate. At the end, if the stack is empty, the brackets match. If there are leftover plates or you try to remove from an empty stack, they don't match.

The physical metaphor makes it hard to mess up — you can't remove a plate from an empty pile, and you can't have a balanced stack with plates left over.

The code becomes more obvious:

function isValid(s) {
  const stack = [];

  for (let char of s) {
    if (char === '(') {
      stack.push('('); // Add plate
    } else if (char === ')') {
      if (stack.length === 0) return false; // No plate to remove
      stack.pop(); // Remove plate
    }
  }

  return stack.length === 0; // All plates removed?
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Two Pointers (Palindrome Check)

Check if "racecar" reads the same forwards and backwards. Picture two fingers moving from the edges:

Word: "racecar"

L               R
r a c e c a r   ✓ r === r, move both inward

  L         R
  a c e c a     ✓ a === a, move both inward

    L   R
      c e c     ✓ c === c, move both inward

      LR
        e       ✓ fingers meet - it's a palindrome!
Enter fullscreen mode Exit fullscreen mode

Left finger on the first letter, right finger on the last. Do they match? Move both inward. Keep going until they meet:

function isPalindrome(s) {
  let left = 0;
  let right = s.length - 1;

  while (left < right) {
    if (s[left] !== s[right]) {
      return false;
    }
    left++;
    right--;
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Some might immediately see this pattern in other places — finding pairs in sorted arrays, removing duplicates, merging sorted lists. And there's probably some truth to that, though each of those has enough differences that it's worth treating them separately.


Pattern 4: Sliding Window (Consecutive Sum)

Find the max sum of 3 consecutive numbers in [1, 3, 2, 6, 2, 8].

Code-first means recalculating everything repeatedly:

function maxSum(arr, k) {
  let max = 0;
  // For each position, sum next k elements
  for (let i = 0; i <= arr.length - k; i++) {
    let sum = 0;
    for (let j = i; j < i + k; j++) {
      sum += arr[j]; // Recalculating same numbers over and over
    }
    max = Math.max(max, sum);
  }
  return max;
  // Works but slow (O(n*k))
}
Enter fullscreen mode Exit fullscreen mode

Picture a frame sliding across the array instead:

Array: [1, 3, 2, 6, 2, 8], k=3

Frame 1: [1, 3, 2] → sum = 6

Frame 2:    [3, 2, 6] → sum = 11
              ↑     ↑
           remove  add

Frame 3:       [2, 6, 2] → sum = 10
                 ↑     ↑
              remove  add

Frame 4:          [6, 2, 8] → sum = 16 ← max
Enter fullscreen mode Exit fullscreen mode

Calculate the first frame's sum. Then slide the frame: remove the left edge, add the right edge. Don't recalculate the middle numbers — they stay in the frame. The optimization follows naturally:

function maxSum(arr, k) {
  // Calculate first window
  let windowSum = 0;
  for (let i = 0; i < k; i++) {
    windowSum += arr[i];
  }
  let maxSum = windowSum;

  // Slide the window
  for (let i = k; i < arr.length; i++) {
    windowSum = windowSum - arr[i - k] + arr[i];
    //              remove left   add right
    maxSum = Math.max(maxSum, windowSum);
  }

  return maxSum;
}
// O(n) instead of O(n*k) - much faster!
Enter fullscreen mode Exit fullscreen mode

The drawing shows what changes: only the edges. Middle stays the same.

This one took me longest to get. I'd seen the formula, could memorize it, but it felt... arbitrary? Subtract left, add right — why? Watching the frame slide, the visual makes it inevitable. Not a trick to remember, but a shape that's there whether you notice it or not.


Pattern 5: BFS (Shortest Path)

One more pattern, slightly harder. Find the shortest path from S to E in a maze:

S . . #
# . . #
# . # .
# . . E
Enter fullscreen mode Exit fullscreen mode

Without a visual, it's hard to even figure out where to start:

// Try every path? Recursion?
function findPath(maze, row, col) {
  // Go right? Down? Up? Left?
  // How do I know which way is shortest?
  // Recursion depth getting crazy...
  // Stack overflow on complex maze...
}
Enter fullscreen mode Exit fullscreen mode

Think of dropping a stone in water. Ripples spread in circles. That's BFS.

Step 0:  S . . #     Start: S is wave 0
         # . . #
         # . # .
         # . . E

Step 1:  0 1 . #     Wave 1 spreads from S
         # . . #
         # . # .
         # . . E

Step 2:  0 1 2 #     Wave 2 spreads from wave 1
         # 2 . #
         # . # .
         # . . E

Step 3:  0 1 2 #     Wave 3 spreads from wave 2
         # 2 3 #
         # 3 # .
         # . . E

Final:   0 1 2 #     Found E at distance 5!
         # 2 3 #
         # 3 # 5
         # 4 5 E
Enter fullscreen mode Exit fullscreen mode

Start position is wave 0. Each step spreads to neighbors, creating the next wave. First wave to reach the goal gives you the shortest path, because waves spread uniformly.

There's something satisfying about drawing this one — watching the numbers fill in like water. The visual sticks.

The algorithm makes more sense:

function shortestPath(maze, start, end) {
  const queue = [{ pos: start, distance: 0 }]; // Wave 0
  const visited = new Set();

  while (queue.length > 0) {
    const { pos, distance } = queue.shift(); // Process current wave

    if (pos === end) return distance; // Found it!

    if (visited.has(pos)) continue;
    visited.add(pos);

    // Spread to neighbors (next wave)
    for (const neighbor of getNeighbors(pos)) {
      if (!isWall(neighbor) && !visited.has(neighbor)) {
        queue.push({ pos: neighbor, distance: distance + 1 });
      }
    }
  }

  return -1; // No path found
}
Enter fullscreen mode Exit fullscreen mode

One caveat: this only works when all steps cost the same. If some paths are "harder" than others — like moving through mud versus pavement — the wave metaphor breaks. You'd need Dijkstra's algorithm instead, which is more like... actually, not sure what the right visual is there. Priority queues don't map to water as cleanly.


Frontend Example: Flattening Nested Comments

Consider a real-world frontend problem. You have nested comment threads and need to flatten them for display or search:

const comments = {
  id: 1,
  text: "Parent comment",
  replies: [
    {
      id: 2,
      text: "First reply",
      replies: [
        { id: 3, text: "Nested reply", replies: [] }
      ]
    },
    { id: 4, text: "Second reply", replies: [] }
  ]
};
Enter fullscreen mode Exit fullscreen mode

Code-first thinking stalls immediately:

function flatten(comment) {
  // Recursion... but wait
  // How deep can this go?
  // What if there are 1000 levels?
  // Stack overflow risk?
}
Enter fullscreen mode Exit fullscreen mode

Try drawing it as a tree:

        1 (Parent)
       / \
      2   4
     /
    3

Stack: [1]           → pop 1, result: [1], push 2,4
Stack: [2, 4]        → pop 2, result: [1,2], push 3
Stack: [3, 4]        → pop 3, result: [1,2,3]
Stack: [4]           → pop 4, result: [1,2,3,4]
Stack: []            → done
Enter fullscreen mode Exit fullscreen mode

Once you draw the tree, it clicks — this is a tree traversal problem, deep nesting means recursion might overflow the stack, and you probably need an iterative solution with an explicit stack.

Solution becomes clearer:

function flattenComments(root) {
  const result = [];
  const stack = [root]; // Explicit stack (no recursion limit)

  while (stack.length > 0) {
    const comment = stack.pop();
    result.push(comment);

    // Add children to stack (reverse order for correct traversal)
    for (let i = comment.replies.length - 1; i >= 0; i--) {
      stack.push(comment.replies[i]);
    }
  }

  return result;
}
Enter fullscreen mode Exit fullscreen mode

How to Recognize Patterns

Look for trigger words in the problem — they point to the shape:

Trigger Words Pattern Visual When It Might Help
"largest", "smallest" Simple loop Champion on stage One-pass comparison
"matching pairs", "nesting" Stack Plates pile Last-in-first-out
"palindrome", "from both ends" Two Pointers Fingers from edges Scan from both sides
"consecutive k", "window" Sliding Window Moving frame Fixed-size range
"shortest path", "minimum steps" BFS Water ripples Graph/grid traversal
"nested", "hierarchical", "tree" DFS/Stack Tree + stack trace Recursive structures

Does This Actually Help?

Memorizing code patterns fades quickly, but visual metaphors stick. Drawing helps, but recognition is the real skill — knowing which shape fits the problem in front of you.

Next time you're stuck, try asking "What does this look like?" instead of "How do I code this?" You might be surprised how often the answer is already there, waiting to be seen.

Top comments (0)