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...
}
}
}
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;
}
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?
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 ✗
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?
}
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!
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;
}
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))
}
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
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!
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
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...
}
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
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
}
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: [] }
]
};
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?
}
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
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;
}
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)