featured image

Building a Command Router Pattern for Terminal Interfaces

Learn how to architect a command router in TypeScript and React to handle terminal-style user inputs with clean separation of concerns.

Published

Sun Jun 15 2025

Technologies Used

Typescript React
Beginner 11 minutes

Purpose

The Giant Switch Statement Nightmare

You’re building a terminal-style portfolio interface. Users can type commands like help, about, skills, cd projects, etc. Your first instinct might be to handle this in the component:

function Terminal() {
  const handleCommand = (input: string) => {
    if (input === "help") {
      return <HelpComponent />;
    } else if (input === "about") {
      return <AboutComponent />;
    } else if (input === "skills") {
      return <SkillsComponent />;
    } else if (input.startsWith("cd ")) {
      const page = input.split(" ")[1];
      // ... more logic
    } else {
      return <NotFoundComponent />;
    }
  };
}

This works for 3 commands. But what about 10? 20? The component becomes a 1000-line monolith where:

  • Adding a new command requires editing the core component
  • Testing individual commands is impossible without mounting the entire terminal
  • Command logic is tightly coupled to UI rendering
  • You can’t reuse command parsing logic elsewhere

The Core Problem: Mixing routing logic (which command to execute) with business logic (what the command does) and presentation logic (how to display results) violates the Single Responsibility Principle.

A Command Router with Component Mapping

The code we’re analyzing (src/lib/renderCmd.tsx) implements a Command Router Pattern that:

  1. Separates command parsing from execution
  2. Maps command strings to React components
  3. Handles arguments and validation
  4. Provides a single entry point for command execution
  5. Returns a standardized result object

This is the same pattern used by:

  • Express.js (route handlers)
  • React Router (URL to component mapping)
  • CLI frameworks (argparse, commander.js)

Understanding Separation of Concerns

This tutorial demonstrates three architectural principles:

  • Command Pattern: Encapsulating requests as objects
  • Strategy Pattern: Selecting algorithms (components) at runtime
  • Single Responsibility: Each command component does one thing

🔵 Deep Dive: The Command Pattern is one of the Gang of Four design patterns. It’s used in undo/redo systems, job queues, and macro recording—anywhere you need to decouple “what to do” from “when to do it.”

Prerequisites & Tooling

Knowledge Base

Required:

  • JavaScript/TypeScript basics (functions, objects, arrays)
  • React fundamentals (components, JSX)
  • String manipulation (split, includes)

Helpful:

  • Understanding of design patterns
  • Familiarity with routing concepts
  • Experience with switch statements

Environment

From the project structure:

// File structure
src/
  lib/
    renderCmd.tsx          // Command router
  components/
    commands/
      Help.tsx             // Individual command components
      About.tsx
      Skills.tsx
      Contact.tsx
      Bio.tsx
      Ls.tsx
      NotFound.tsx

Key Concepts:

  • Command: A string input from the user (e.g., “help”, “cd projects”)
  • Component: A React component that renders the command’s output
  • Router: The function that maps commands to components

High-Level Architecture

Command Flow Diagram

graph LR
    A[User Input: 'cd projects'] --> B[renderCmd Function]
    B --> C{Parse Command}
    C --> D[Extract: command='cd', arg='projects']
    D --> E{Switch Statement}
    E --> F[Match 'cd' Case]
    F --> G{Validate Argument}
    G --> H[Check if 'projects' in pages array]
    H --> I{Valid?}
    I -->|Yes| J[Navigate to /projects]
    I -->|No| K[Return Error Component]
    J --> L[Return CmdHistory Object]
    K --> L
    L --> M[Terminal Displays Result]
    
    style B fill:#a855f7
    style E fill:#10b981
    style G fill:#f59e0b

The Restaurant Menu

Think of the command router as a restaurant ordering system:

RestaurantCommand Router
Menu itemsAvailable commands
Order (e.g., “Burger with fries”)User input (e.g., “cd projects”)
Kitchen stationsCommand components
WaiterrenderCmd function
Prepared dishCmdHistory object

When you order “Burger with fries”:

  1. Waiter (renderCmd) takes your order
  2. Parses it: main item = “Burger”, side = “fries”
  3. Routes to the grill station (component)
  4. Returns the prepared dish (JSX)

The waiter doesn’t cook—they just route orders to the right kitchen station.

The Three-Layer Architecture

Layer 1: User Input (Unstructured)
  └─ "cd projects" (string)

Layer 2: Command Router (Parsing & Routing)
  └─ renderCmd.tsx
     └─ Parses, validates, maps to component

Layer 3: Command Components (Execution)
  └─ Individual components render output

The Implementation

Defining the Command History Type

Goal: Create a standardized return type for all commands.

type CmdHistory = {
  cmd: string;              // Original command string
  Component: () => JSX.Element;  // React component to render
  time: string;             // Timestamp (populated by caller)
};

Why This Structure?

  • cmd: Allows displaying what the user typed (echo)
  • Component: Enables lazy rendering (component only renders when needed)
  • time: Supports command history with timestamps

🔵 Deep Dive: Using Component: () => JSX.Element instead of Component: JSX.Element is crucial. The former is a function that returns JSX (can be called multiple times), while the latter is a value (rendered once and cached).

Naive Approach: Hardcode URLs Everywhere

// WRONG: Scattered throughout the code
case "cd":
  if (arg === "projects") window.location.href = "/projects";
  if (arg === "github") window.open("https://github.com/...");
  // ... repeated for every page

Why This Fails: Adding a new page requires editing multiple places. Changing a URL breaks everything.

Refined Solution (From Repo):

// Centralized configuration
const pages = [
  "projects",
  "tutorials",
  "categories",
  "tags",
  "contact",
  "about",
  "github",
  "linkedin",
];

const externalLinks: { [key: string]: string } = {
  "github": "https://github.com/jason-n-tran",
  "linkedin": "https://www.linkedin.com/in/jason-n-tran",
};

Benefits:

  • Single source of truth for available pages
  • Easy to add new pages (just add to array)
  • External links separated from internal routes
  • Can be imported and reused elsewhere (e.g., autocomplete)

The Core Router Function

The Function Signature:

function renderCmd(cmd: string): CmdHistory {
  // Parse the command
  const [command, arg] = cmd.split(" ");
  
  // Route to appropriate handler
  switch (command) {
    // ... cases
  }
}

🔴 Danger: The split(" ") approach only handles single arguments. For commands like cd my folder (with spaces), you’d need more sophisticated parsing. Production systems use libraries like yargs or commander.

Implementing Simple Commands

Pattern: Commands without arguments map directly to components.

switch (command) {
  case "help":
    return {
      cmd,
      Component: Help,
      time: "",
    };
  
  case "whois":
    return {
      cmd,
      Component: Bio,
      time: "",
    };
  
  case "skills":
    return {
      cmd,
      Component: Skills,
      time: "",
    };
  
  case "contact":
    return {
      cmd,
      Component: Contact,
      time: "",
    };
  
  case "about":
    return {
      cmd,
      Component: About,
      time: "",
    };
  
  case "ls":
    return {
      cmd,
      Component: Ls,
      time: "",
    };

Key Insight: Notice we’re returning the component function itself, not calling it. The terminal component will call it later:

// In the terminal component
<enteredCmd.Component />  // Calls the function

Handling Commands with Arguments

The Challenge: The cd command needs to:

  1. Validate the argument exists
  2. Distinguish between internal and external links
  3. Perform navigation
  4. Return appropriate feedback

Implementation:

case "cd": {
  // Check if argument was provided and is valid
  if (arg && pages.includes(arg)) {
    
    // Check if it's an external link
    if (externalLinks[arg]) {
      window.open(externalLinks[arg], "_blank");
      
      // Return a component that shows feedback
      const Redirecting = () => <p>Opening {arg} in a new tab...</p>;
      return {
        cmd,
        Component: Redirecting,
        time: "",
      };
    } else {
      // Internal navigation
      window.location.href = `/${arg}`;
      
      const Redirecting = () => <p>Redirecting to /{arg}...</p>;
      return {
        cmd,
        Component: Redirecting,
        time: "",
      };
    }
  }
  
  // Invalid argument - return error
  return {
    cmd,
    Component: () => (
      <p>Invalid directory. Type 'ls' to see available directories.</p>
    ),
    time: "",
  };
}

🔵 Deep Dive: Creating inline components (const Redirecting = () => <p>...</p>) is a powerful pattern for dynamic content. Each invocation creates a new component with closure over the arg variable.

The Default Case (Command Not Found)

default:
  return {
    cmd,
    Component: NotFound,
    time: "",
  };

Why a Separate Component?

The NotFound component can:

  • Display helpful error messages
  • Suggest similar commands (fuzzy matching)
  • Show usage examples
  • Log analytics about failed commands

Complete Implementation

Here’s the full router from the repository:

import About from "@/components/commands/About";
import Bio from "@/components/commands/Bio";
import Contact from "@/components/commands/Contact";
import Help from "@/components/commands/Help";
import Ls from "@/components/commands/Ls";
import NotFound from "@/components/commands/NotFound";
import Skills from "@/components/commands/Skills";

type CmdHistory = {
  cmd: string;
  Component: () => JSX.Element;
  time: string;
};

const pages = [
  "projects",
  "tutorials",
  "categories",
  "tags",
  "contact",
  "about",
  "github",
  "linkedin",
];

const externalLinks: { [key: string]: string } = {
  "github": "https://github.com/jason-n-tran",
  "linkedin": "https://www.linkedin.com/in/jason-n-tran",
};

function renderCmd(cmd: string): CmdHistory {
  const [command, arg] = cmd.split(" ");
  
  switch (command) {
    case "help":
      return { cmd, Component: Help, time: "" };
    
    case "whois":
      return { cmd, Component: Bio, time: "" };
    
    case "skills":
      return { cmd, Component: Skills, time: "" };
    
    case "contact":
      return { cmd, Component: Contact, time: "" };
    
    case "about":
      return { cmd, Component: About, time: "" };
    
    case "ls":
      return { cmd, Component: Ls, time: "" };
    
    case "cd": {
      if (arg && pages.includes(arg)) {
        if (externalLinks[arg]) {
          window.open(externalLinks[arg], "_blank");
          const Redirecting = () => <p>Opening {arg} in a new tab...</p>;
          return { cmd, Component: Redirecting, time: "" };
        } else {
          window.location.href = `/${arg}`;
          const Redirecting = () => <p>Redirecting to /{arg}...</p>;
          return { cmd, Component: Redirecting, time: "" };
        }
      }
      return {
        cmd,
        Component: () => (
          <p>Invalid directory. Type 'ls' to see available directories.</p>
        ),
        time: "",
      };
    }
    
    default:
      return { cmd, Component: NotFound, time: "" };
  }
}

export default renderCmd;

Under the Hood

Memory & Performance

When Does This Run?

The router executes synchronously when the user presses Enter:

User types "help" → Press Enter

Terminal component calls renderCmd("help")
  ↓ (< 1ms)
Returns { cmd: "help", Component: Help, time: "" }

Terminal adds to history state

React re-renders with new history

<Help /> component renders

Performance Characteristics:

  • Time Complexity: O(1) for switch statement lookup
  • Space Complexity: O(1) - only creates one CmdHistory object
  • Render Cost: Depends on the command component (Help is ~50 lines of JSX)

Real-World Impact:

For a typical command:

  • String parsing: ~0.01ms
  • Switch lookup: ~0.001ms
  • Component creation: ~0.1ms
  • Total: ~0.111ms (imperceptible to users)

The Switch Statement vs. Object Lookup

Alternative Implementation: Object Map

const commandMap = {
  help: Help,
  whois: Bio,
  skills: Skills,
  contact: Contact,
  about: About,
  ls: Ls,
};

function renderCmd(cmd: string): CmdHistory {
  const [command, arg] = cmd.split(" ");
  const Component = commandMap[command] || NotFound;
  return { cmd, Component, time: "" };
}

Comparison:

ApproachProsCons
SwitchHandles complex logic (cd case)Verbose for simple cases
Object MapConcise, easy to extendCan’t handle arguments easily

Hybrid Approach (Best of Both):

const simpleCommands = {
  help: Help,
  whois: Bio,
  skills: Skills,
  contact: Contact,
  about: About,
  ls: Ls,
};

function renderCmd(cmd: string): CmdHistory {
  const [command, arg] = cmd.split(" ");
  
  // Check simple commands first
  if (simpleCommands[command]) {
    return { cmd, Component: simpleCommands[command], time: "" };
  }
  
  // Handle complex commands
  switch (command) {
    case "cd":
      // ... complex logic
    default:
      return { cmd, Component: NotFound, time: "" };
  }
}

Component Closure Behavior

The Inline Component Pattern:

const Redirecting = () => <p>Opening {arg} in a new tab...</p>;

What’s Happening:

  1. arg is captured in the closure
  2. Each call to renderCmd creates a new Redirecting function
  3. The function “remembers” the arg value from its creation context

Memory Implications:

// Call 1: renderCmd("cd github")
const Redirecting1 = () => <p>Opening github in a new tab...</p>;
// Closure captures: arg = "github"

// Call 2: renderCmd("cd linkedin")
const Redirecting2 = () => <p>Opening linkedin in a new tab...</p>;
// Closure captures: arg = "linkedin"

// These are DIFFERENT functions with DIFFERENT closures
Redirecting1 !== Redirecting2  // true

This is safe because each command execution creates its own closure scope.

Edge Cases & Pitfalls

Commands with Multiple Arguments

Problem: cd my folder splits into ["cd", "my"], losing “folder”.

Current Behavior: Treats “my” as the argument, ignores “folder”.

Solution: Use regex or limit split:

const [command, ...args] = cmd.split(" ");
const arg = args.join(" ");  // Rejoin remaining parts

// Or use regex
const match = cmd.match(/^(\w+)\s+(.+)$/);
if (match) {
  const [, command, arg] = match;
}

Case Sensitivity

Problem: User types “HELP” instead of “help”.

Current Behavior: Falls through to default case (NotFound).

Solution: Normalize input:

const [command, arg] = cmd.toLowerCase().split(" ");

🔴 Danger: This breaks case-sensitive arguments. Better approach:

const parts = cmd.split(" ");
const command = parts[0].toLowerCase();
const arg = parts.slice(1).join(" ");  // Preserve original case

Empty Input

Problem: User presses Enter without typing anything.

Current Behavior: command = "", falls to default case.

Better Approach: Handle explicitly:

function renderCmd(cmd: string): CmdHistory {
  const trimmed = cmd.trim();
  
  if (!trimmed) {
    return {
      cmd,
      Component: () => <></>,  // Empty component
      time: "",
    };
  }
  
  const [command, arg] = trimmed.split(" ");
  // ... rest of logic
}

Special Characters

Problem: User types cd projects; rm -rf / (command injection attempt).

Current Behavior: Treats entire string as one command, fails validation.

Security Note: Since we’re only routing to predefined components (not executing shell commands), this is safe. But if you ever add eval() or similar, you’d need sanitization.

Problem: The cd case calls window.location.href, which is a side effect in a pure function.

case "cd": {
  window.location.href = `/${arg}`;  // Side effect!
  return { cmd, Component: Redirecting, time: "" };
}

Why This Matters: Side effects make testing difficult. You can’t unit test this without mocking window.location.

Better Approach: Return navigation intent, let caller handle it:

type CmdHistory = {
  cmd: string;
  Component: () => JSX.Element;
  time: string;
  navigation?: { type: 'internal' | 'external', url: string };
};

case "cd": {
  if (arg && pages.includes(arg)) {
    if (externalLinks[arg]) {
      return {
        cmd,
        Component: () => <p>Opening {arg}...</p>,
        time: "",
        navigation: { type: 'external', url: externalLinks[arg] }
      };
    }
  }
}

// In terminal component
const result = renderCmd(cmd);
if (result.navigation) {
  if (result.navigation.type === 'external') {
    window.open(result.navigation.url, '_blank');
  } else {
    window.location.href = result.navigation.url;
  }
}

Component Import Overhead

Problem: Importing all command components upfront increases bundle size.

import About from "@/components/commands/About";
import Bio from "@/components/commands/Bio";
// ... 10 more imports

Solution: Dynamic imports (code splitting):

const commandMap = {
  help: () => import("@/components/commands/Help"),
  about: () => import("@/components/commands/About"),
  // ...
};

async function renderCmd(cmd: string): Promise<CmdHistory> {
  const [command] = cmd.split(" ");
  const loader = commandMap[command];
  
  if (loader) {
    const { default: Component } = await loader();
    return { cmd, Component, time: "" };
  }
  
  return { cmd, Component: NotFound, time: "" };
}

This loads each command component only when first used.

Conclusion

Skills Acquired

You’ve learned:

  1. Command Pattern: Encapsulating requests as objects with standardized interfaces
  2. Routing Logic: Mapping string inputs to executable code
  3. Separation of Concerns: Decoupling parsing, validation, and execution
  4. Component Composition: Using functions as first-class values in React
  5. Configuration Management: Centralizing route definitions

The Proficiency Marker: Most developers hardcode command handling in components. You now understand command routing as a separate architectural layer that can be tested, extended, and reused independently. This mental model transfers to:

  • API route handlers (Express, Fastify)
  • State machines (XState)
  • Event handlers (Redux actions)
  • Plugin systems (VS Code extensions)

Extending the Router

Adding a New Command:

  1. Create the component:
// src/components/commands/Projects.tsx
export default function Projects() {
  return <div>My projects...</div>;
}
  1. Import and add to router:
import Projects from "@/components/commands/Projects";

case "projects":
  return { cmd, Component: Projects, time: "" };
  1. Update the ls command to show it:
// In Ls.tsx
const pages = [..., "projects"];

Adding Command Aliases:

case "help":
case "?":
case "h":
  return { cmd, Component: Help, time: "" };

Adding Command Flags:

case "ls": {
  const showHidden = arg === "-a" || arg === "--all";
  const LsComponent = () => <Ls showHidden={showHidden} />;
  return { cmd, Component: LsComponent, time: "" };
}

Next Challenge: Implement command autocomplete using a Trie data structure to suggest commands as users type, similar to bash/zsh completion.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!