featured image

Building a Stateful Terminal Emulator with Command History and Auto-Scroll

A deep dive into creating a production-grade terminal interface in React, covering state management, refs, event handling, and scroll synchronization.

Published

Sun Jun 29 2025

Technologies Used

React Typescript
Advanced 52 minutes

Purpose

The Illusion of Simplicity

You want to build a terminal-style interface for your portfolio. How hard can it be? Just an input field and some text output, right?

function Terminal() {
  const [output, setOutput] = useState("");
  
  return (
    <div>
      <pre>{output}</pre>
      <input onSubmit={(cmd) => setOutput(output + "\n" + cmd)} />
    </div>
  );
}

You deploy this. Then users report:

  • “When I type a long command, the terminal doesn’t scroll to show my input”
  • “I can’t use arrow keys to navigate command history like a real terminal”
  • “Pressing Ctrl+L doesn’t clear the screen”
  • “The terminal flashes/jumps when new output appears”
  • “On mobile, the keyboard doesn’t appear when I tap the terminal”

The Core Problem: Real terminals are stateful, interactive systems with complex behaviors that users expect from decades of muscle memory. Building a convincing terminal emulator requires:

  • Managing multiple pieces of interdependent state
  • Handling keyboard events globally
  • Synchronizing DOM scrolling with React state updates
  • Implementing smooth animations and transitions
  • Ensuring accessibility and mobile compatibility

The code we’re analyzing (src/components/TerminalBox.tsx) implements a production-grade terminal emulator that:

  1. Maintains command history with timestamps
  2. Auto-scrolls to show new output
  3. Handles keyboard shortcuts (Ctrl+L to clear)
  4. Implements smooth boot animation
  5. Manages focus state for seamless interaction
  6. Uses refs strategically to avoid re-render performance issues

This is the same pattern used by:

  • VS Code’s integrated terminal
  • Hyper terminal
  • xterm.js (the library powering most web terminals)

Understanding React’s Rendering Model

This tutorial demonstrates five advanced concepts:

  • State Management: Coordinating multiple pieces of state that affect each other
  • Ref vs. State: Knowing when to use refs (DOM manipulation) vs. state (UI updates)
  • Effect Dependencies: Understanding useEffect dependency arrays and cleanup
  • Event Handling: Global keyboard listeners and their lifecycle
  • Scroll Synchronization: Keeping scroll position in sync with dynamic content

🔵 Deep Dive: This component demonstrates the Container/Presenter pattern where the container (TerminalBox) manages state and side effects, while presenters (command components) handle pure rendering.

Prerequisites & Tooling

Knowledge Base

Required:

  • React fundamentals (components, props, state)
  • React hooks (useState, useEffect, useRef)
  • TypeScript basics
  • DOM APIs (scrollIntoView, addEventListener)

Helpful:

  • Understanding of terminal emulators
  • Experience with keyboard event handling
  • Knowledge of React rendering lifecycle

Environment

From the project structure:

// Component dependencies
src/
  components/
    TerminalBox.tsx           // Main container
    CmdUserInput.tsx          // Input component
    EnteredCmd.tsx            // Command history display
    TodayDate.tsx             // Date display
    commands/
      Help.tsx                // Individual command outputs
      Welcome.tsx
  lib/
    renderCmd.tsx             // Command router

Key Concepts:

  • Command History: Array of executed commands with their outputs
  • Refs: Direct references to DOM elements
  • Scroll Behavior: Automatic vs. smooth scrolling
  • Event Bubbling: How keyboard events propagate

High-Level Architecture

State Machine Diagram

stateDiagram-v2
    [*] --> Unmounted
    Unmounted --> Mounting: Component renders
    Mounting --> Animating: 100ms delay
    Animating --> Ready: Animation completes
    Ready --> Processing: User submits command
    Processing --> Scrolling: Add to history
    Scrolling --> Ready: Scroll complete
    Ready --> Clearing: Ctrl+L pressed
    Clearing --> Ready: History cleared
    Ready --> Unmounting: Component unmounts
    Unmounting --> [*]: Cleanup
    
    note right of Animating
      Boot animation plays
      scale-0 → scale-100
    end note
    
    note right of Scrolling
      Auto-scroll to bottom
      Show latest output
    end note

The Chat Application

Think of the terminal as a chat application:

Chat AppTerminal
MessagesCommands + outputs
Send buttonEnter key
Message historyCommand history
Auto-scroll to latestAuto-scroll to latest command
Typing indicatorCommand prompt
Clear chatCtrl+L

Both need to:

  • Display a growing list of items
  • Auto-scroll to show new items
  • Handle user input
  • Maintain history
  • Provide keyboard shortcuts

The Four-Phase Lifecycle

Phase 1: Initialization (Mount)
  ├─ Set isMounted to false (hidden)
  ├─ Initialize with "help" command
  ├─ After 100ms, set isMounted to true (trigger animation)
  └─ Attach keyboard event listener

Phase 2: Steady State (User Interaction)
  ├─ User types command
  ├─ User presses Enter
  ├─ handleSubmit called
  ├─ renderCmd processes command
  ├─ Add to enteredCmd history
  └─ Auto-scroll to bottom

Phase 3: Keyboard Shortcuts
  ├─ User presses Ctrl+L
  ├─ handleKeyEvent detects combination
  └─ Clear enteredCmd history

Phase 4: Cleanup (Unmount)
  ├─ Remove keyboard event listener
  └─ Cancel pending timers

The Implementation

Defining State Structure

Naive Approach: Single String State

// WRONG: Loses structure
const [output, setOutput] = useState("Welcome\n$ help\n...");

Why This Fails:

  • Can’t render individual commands with different styles
  • Can’t add timestamps
  • Can’t implement command-specific features (e.g., clickable links)

Refined Solution (From Repo):

// Each command is an object with metadata
const [enteredCmd, setEnteredCmd] = useState([
  {
    cmd: "help",
    Component: Help,
    time: new Date().toLocaleTimeString(),
  },
]);

Type Definition:

type CmdEntry = {
  cmd: string;                    // Original command string
  Component: () => JSX.Element;   // Component to render output
  time: string;                   // Timestamp
};

🔵 Deep Dive: Storing components in state is a powerful pattern. Each command can have completely different rendering logic without the terminal knowing the details.

Managing Mount State for Animation

The Challenge: Play a boot animation when the component first appears.

const [isMounted, setIsMounted] = useState(false);

useEffect(() => {
  const timer = setTimeout(() => setIsMounted(true), 100);
  return () => clearTimeout(timer);
}, []);

Why the Delay?

Without the delay, the animation doesn’t play because:

  1. Component renders with isMounted = false
  2. Immediately sets isMounted = true
  3. React batches both renders
  4. CSS transition never triggers (no intermediate state)

The 100ms delay ensures:

  1. First render: isMounted = false (scale-0)
  2. Browser paints
  3. Second render: isMounted = true (scale-100)
  4. CSS transition animates between states

The CSS:

<div className={`w-full ${isMounted ? 'animate-terminal-boot' : 'scale-0'}`}>

🔴 Danger: Using setTimeout without cleanup causes memory leaks if the component unmounts before the timer fires. Always return a cleanup function.

Auto-Scroll Implementation

The Problem: When new commands are added, the terminal should scroll to show them.

Naive Approach: Scroll on Every Render

useEffect(() => {
  window.scrollTo(0, document.body.scrollHeight);
});

Why This Fails:

  • Scrolls the entire page, not just the terminal
  • Runs on every render (even unrelated state changes)
  • No smooth scrolling

Refined Solution (From Repo):

const dummyRef = useRef() as React.MutableRefObject<HTMLDivElement>;

useEffect(() => {
  if (isMounted) {
    dummyRef.current.scrollIntoView({ behavior: "auto" });
  }
}, [enteredCmd, isMounted]);

// In JSX
<div ref={dummyRef}></div>  // Invisible element at bottom

How It Works:

  1. Dummy Element: An empty <div> at the bottom of the terminal
  2. Ref: Direct reference to that element
  3. scrollIntoView: Browser API that scrolls the element into view
  4. Dependencies: Only runs when enteredCmd or isMounted changes

Why “auto” Instead of “smooth”?

behavior: "auto"   // Instant scroll (no animation)
behavior: "smooth" // Animated scroll

For terminals, instant scrolling feels more responsive. Smooth scrolling can lag behind rapid command execution.

Global Keyboard Event Handling

The Challenge: Capture Ctrl+L anywhere on the page to clear the terminal.

useEffect(() => {
  document.body.addEventListener("keydown", handleKeyEvent);
  return () => {
    document.body.removeEventListener("keydown", handleKeyEvent);
  };
}, []);

const handleKeyEvent = (e: KeyboardEvent) => {
  if (e.ctrlKey && e.key.toLocaleLowerCase() === "l") {
    setEnteredCmd([]);
  }
};

Key Details:

  1. Attach to body: Captures events even when terminal isn’t focused
  2. Check modifiers: e.ctrlKey detects Ctrl key
  3. Normalize key: toLocaleLowerCase() handles both “L” and “l”
  4. Cleanup: Remove listener on unmount to prevent memory leaks

🔴 Danger: The handleKeyEvent function is recreated on every render, but the effect only runs once (empty dependency array). This means the function has a stale closure over setEnteredCmd. It works because setEnteredCmd is stable (doesn’t change), but if you reference other state, you’d need to add it to dependencies.

Better Pattern (Avoiding Stale Closures):

useEffect(() => {
  const handleKeyEvent = (e: KeyboardEvent) => {
    if (e.ctrlKey && e.key.toLocaleLowerCase() === "l") {
      setEnteredCmd([]);  // This closure is fresh
    }
  };
  
  document.body.addEventListener("keydown", handleKeyEvent);
  return () => {
    document.body.removeEventListener("keydown", handleKeyEvent);
  };
}, []);  // No dependencies needed - function is defined inside effect

Command Submission Handler

The Flow:

const handleSubmit = (cmd: string) => {
  setEnteredCmd((currentCmd) => [
    ...currentCmd,
    { ...renderCmd(cmd), time: new Date().toLocaleTimeString() },
  ]);
};

Breaking It Down:

  1. Functional Update: setEnteredCmd((currentCmd) => ...)

    • Ensures we’re working with the latest state
    • Prevents race conditions if multiple commands are submitted rapidly
  2. Spread Operator: ...currentCmd

    • Copies existing commands
    • Maintains immutability (React best practice)
  3. renderCmd: { ...renderCmd(cmd), time: ... }

    • Calls the command router
    • Returns { cmd, Component, time: "" }
    • Spreads the result and overrides time with current timestamp
  4. Timestamp: new Date().toLocaleTimeString()

    • Generates human-readable time (e.g., “2:30:45 PM”)
    • Stored with each command for display

Why Functional Update?

// BAD: Uses stale state
const handleSubmit = (cmd) => {
  setEnteredCmd([...enteredCmd, newCmd]);
};

// GOOD: Always uses latest state
const handleSubmit = (cmd) => {
  setEnteredCmd((current) => [...current, newCmd]);
};

If two commands are submitted in quick succession, the bad version might lose one.

Complete Component Structure

Here’s the full implementation from the repository:

import React, { useEffect, useState, useRef } from "react";
import renderCmd from "@/lib/renderCmd.tsx";
import CmdUserInput from "./CmdUserInput.tsx";
import EnteredCmd from "./EnteredCmd.tsx";
import TodayDate from "./TodayDate.tsx";
import Help from "./commands/Help.tsx";
import Welcome from "./commands/Welcome.tsx";

export default function TerminalBox() {
  // State: Command history
  const [enteredCmd, setEnteredCmd] = useState([
    {
      cmd: "help",
      Component: Help,
      time: new Date().toLocaleTimeString(),
    },
  ]);
  
  // State: Mount animation
  const [isMounted, setIsMounted] = useState(false);
  
  // Ref: Auto-scroll target
  const dummyRef = useRef() as React.MutableRefObject<HTMLDivElement>;

  // Effect: Trigger mount animation
  useEffect(() => {
    const timer = setTimeout(() => setIsMounted(true), 100);
    return () => clearTimeout(timer);
  }, []);

  // Effect: Auto-scroll on new commands
  useEffect(() => {
    if (isMounted) {
      dummyRef.current.scrollIntoView({ behavior: "auto" });
    }
  }, [enteredCmd, isMounted]);

  // Effect: Global keyboard shortcuts
  useEffect(() => {
    document.body.addEventListener("keydown", handleKeyEvent);
    return () => {
      document.body.removeEventListener("keydown", handleKeyEvent);
    };
  }, []);

  // Handler: Submit command
  const handleSubmit = (cmd: string) => {
    setEnteredCmd((currentCmd) => [
      ...currentCmd,
      { ...renderCmd(cmd), time: new Date().toLocaleTimeString() },
    ]);
  };

  // Handler: Keyboard shortcuts
  const handleKeyEvent = (e: KeyboardEvent) => {
    if (e.ctrlKey && e.key.toLocaleLowerCase() === "l") {
      setEnteredCmd([]);
    }
  };

  return (
    <div className={`w-full ${isMounted ? 'animate-terminal-boot' : 'scale-0'}`}>
      <div className="max-w-7xl w-full h-[90vh] max-h-[55rem] mx-auto flex flex-col border-x-2 border-b-2 border-slate-800 rounded-b-md bg-black/95 text-gray-300 text-xl box">
        <div className="flex-grow p-2 overflow-y-auto box">
          <Welcome />
          <TodayDate />
          <EnteredCmd enteredCmd={enteredCmd} />
          <CmdUserInput onSubmit={handleSubmit} />
          <div ref={dummyRef}></div>
        </div>
      </div>
    </div>
  );
}

Under the Hood

React Rendering Behavior

What Triggers Re-Renders?

  1. State Changes: setEnteredCmd, setIsMounted
  2. Parent Re-Renders: If parent component re-renders
  3. Context Changes: If consuming context that updates

Render Count Analysis:

Initial Mount:
  Render 1: isMounted=false, enteredCmd=[help]
  
After 100ms:
  Render 2: isMounted=true, enteredCmd=[help]
  
User submits "about":
  Render 3: isMounted=true, enteredCmd=[help, about]
  
User presses Ctrl+L:
  Render 4: isMounted=true, enteredCmd=[]

Performance Characteristics:

  • Each render: ~5ms (depends on command output complexity)
  • Auto-scroll: ~1ms (browser-optimized)
  • Event handler: ~0.1ms

For typical usage (10-20 commands), total render time is ~50-100ms over the session.

Memory Management

State Memory:

enteredCmd = [
  { cmd: "help", Component: Help, time: "2:30:45 PM" },
  { cmd: "about", Component: About, time: "2:31:12 PM" },
  // ... more commands
];

Memory per Command:

  • cmd string: ~20 bytes
  • Component reference: 8 bytes (pointer)
  • time string: ~15 bytes
  • Object overhead: ~50 bytes
  • Total: ~93 bytes per command

For 100 commands: ~9.3KB (negligible)

Potential Issue: If command outputs are large (e.g., rendering 1000-line logs), the DOM size grows unbounded.

Solution: Virtual Scrolling

// Only render visible commands
const visibleCommands = enteredCmd.slice(-20);  // Last 20 commands

<EnteredCmd enteredCmd={visibleCommands} />

Or use libraries like react-window for true virtualization.

Event Listener Lifecycle

The Cleanup Pattern:

useEffect(() => {
  const handler = (e) => { /* ... */ };
  document.body.addEventListener("keydown", handler);
  
  return () => {
    document.body.removeEventListener("keydown", handler);
  };
}, []);

What Happens:

  1. Mount: Listener attached to body
  2. User Interaction: Handler fires on keydown
  3. Unmount: Cleanup function runs, removes listener

Without Cleanup:

// BAD: Memory leak
useEffect(() => {
  document.body.addEventListener("keydown", handler);
}, []);

If the component mounts/unmounts multiple times (e.g., route changes), you accumulate listeners:

Mount 1: 1 listener
Unmount 1: Still 1 listener (not removed!)
Mount 2: 2 listeners
Unmount 2: Still 2 listeners
Mount 3: 3 listeners

Each keypress now fires the handler 3 times!

Scroll Behavior Deep Dive

scrollIntoView Options:

element.scrollIntoView({
  behavior: "auto",    // or "smooth"
  block: "end",        // or "start", "center", "nearest"
  inline: "nearest"    // or "start", "center", "end"
});

The Repo Uses:

dummyRef.current.scrollIntoView({ behavior: "auto" });

This defaults to block: "start", which scrolls the element to the top of the viewport. But since the dummy element is at the bottom, this effectively scrolls the terminal to the bottom.

Alternative: scrollTop

const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (containerRef.current) {
    containerRef.current.scrollTop = containerRef.current.scrollHeight;
  }
}, [enteredCmd]);

This is more explicit but requires a ref to the scrollable container.

Edge Cases & Pitfalls

Rapid Command Submission

Problem: User submits 10 commands in 1 second.

Current Behavior: Each triggers a re-render and scroll. With 10 commands:

  • 10 state updates
  • 10 re-renders
  • 10 scroll operations

React’s Batching: React 18+ automatically batches these, so you get:

  • 1 state update (with all 10 commands)
  • 1 re-render
  • 1 scroll operation

Pre-React 18: You’d need manual batching:

import { unstable_batchedUpdates } from 'react-dom';

const handleMultipleCommands = (commands: string[]) => {
  unstable_batchedUpdates(() => {
    commands.forEach(cmd => handleSubmit(cmd));
  });
};

Long-Running Commands

Problem: User runs a command that takes 5 seconds to complete (e.g., API call).

Current Behavior: The command is added to history immediately with its component. If the component makes an async call, it shows loading state.

Better Pattern: Show loading indicator:

const handleSubmit = async (cmd: string) => {
  // Add loading entry
  const loadingId = Date.now();
  setEnteredCmd((current) => [
    ...current,
    { cmd, Component: () => <p>Loading...</p>, time: "", id: loadingId },
  ]);
  
  // Execute command (might be async)
  const result = await executeCommand(cmd);
  
  // Replace loading entry with result
  setEnteredCmd((current) =>
    current.map((entry) =>
      entry.id === loadingId
        ? { ...result, time: new Date().toLocaleTimeString() }
        : entry
    )
  );
};

Mobile Keyboard

Problem: On mobile, tapping the terminal doesn’t bring up the keyboard.

Current Behavior: The repo doesn’t handle this explicitly.

Solution: Focus the input on terminal click:

const inputRef = useRef<HTMLInputElement>(null);

const handleTerminalClick = () => {
  inputRef.current?.focus();
};

<div onClick={handleTerminalClick}>
  {/* terminal content */}
  <CmdUserInput ref={inputRef} onSubmit={handleSubmit} />
</div>

Ctrl+L While Typing

Problem: User is typing a command, presses Ctrl+L, and loses their input.

Current Behavior: History clears, but input field (managed by CmdUserInput) retains its value.

Better Behavior: Also clear the input:

// In CmdUserInput, expose a clear method
const CmdUserInput = forwardRef((props, ref) => {
  const [input, setInput] = useState("");
  
  useImperativeHandle(ref, () => ({
    clear: () => setInput(""),
  }));
  
  // ...
});

// In TerminalBox
const inputRef = useRef();

const handleKeyEvent = (e: KeyboardEvent) => {
  if (e.ctrlKey && e.key.toLocaleLowerCase() === "l") {
    setEnteredCmd([]);
    inputRef.current?.clear();
  }
};

Animation Flicker on Fast Connections

Problem: On very fast connections, the 100ms delay is noticeable as a flicker.

Solution: Use CSS animation instead of state:

// Remove isMounted state
// Add CSS animation
@keyframes terminal-boot {
  from { transform: scale(0); }
  to { transform: scale(1); }
}

.terminal {
  animation: terminal-boot 0.3s ease-out;
}

This runs entirely in CSS, no JavaScript state needed.

Scroll Jank on Large Outputs

Problem: If a command outputs 1000 lines, scrolling becomes janky.

Solution: Virtualize the output:

import { FixedSizeList } from 'react-window';

<FixedSizeList
  height={600}
  itemCount={enteredCmd.length}
  itemSize={50}
>
  {({ index, style }) => (
    <div style={style}>
      <enteredCmd[index].Component />
    </div>
  )}
</FixedSizeList>

This only renders visible commands, keeping DOM size constant.

Memory Leak from Uncancelled Timers

Problem: If the component unmounts before the 100ms timer fires, the timer still runs.

Current Solution: The cleanup function cancels the timer:

useEffect(() => {
  const timer = setTimeout(() => setIsMounted(true), 100);
  return () => clearTimeout(timer);  // Cancels timer on unmount
}, []);

Without Cleanup: The timer fires after unmount, calling setIsMounted on an unmounted component, causing a React warning.

Conclusion

Skills Acquired

You’ve learned:

  1. Complex State Management: Coordinating multiple pieces of interdependent state
  2. Ref vs. State: Using refs for DOM manipulation without triggering re-renders
  3. Effect Lifecycle: Understanding mount, update, and cleanup phases
  4. Event Handling: Global keyboard listeners and their proper cleanup
  5. Scroll Synchronization: Keeping scroll position in sync with dynamic content

The Proficiency Marker: Most developers build simple input/output interfaces. You now understand terminal emulation as a stateful, event-driven system with complex interactions between user input, rendering, and DOM manipulation. This mental model transfers to:

  • Chat applications (message history, auto-scroll)
  • Log viewers (streaming data, virtualization)
  • Code editors (syntax highlighting, cursor management)
  • Real-time dashboards (live updates, scroll behavior)

Extending the Terminal

Adding Command History Navigation:

const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);

const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key === "ArrowUp") {
    e.preventDefault();
    if (historyIndex < history.length - 1) {
      setHistoryIndex(historyIndex + 1);
      setInput(history[history.length - 1 - historyIndex]);
    }
  } else if (e.key === "ArrowDown") {
    e.preventDefault();
    if (historyIndex > 0) {
      setHistoryIndex(historyIndex - 1);
      setInput(history[history.length - 1 - historyIndex]);
    } else {
      setHistoryIndex(-1);
      setInput("");
    }
  }
};

Adding Tab Completion:

const handleTab = (e: KeyboardEvent) => {
  if (e.key === "Tab") {
    e.preventDefault();
    const commands = ["help", "about", "skills", "contact"];
    const matches = commands.filter(cmd => cmd.startsWith(input));
    if (matches.length === 1) {
      setInput(matches[0]);
    } else if (matches.length > 1) {
      // Show suggestions
      setEnteredCmd((current) => [
        ...current,
        { cmd: "", Component: () => <p>{matches.join("  ")}</p>, time: "" },
      ]);
    }
  }
};

Adding Command Aliases:

const aliases = {
  "?": "help",
  "h": "help",
  "cls": "clear",
};

const handleSubmit = (cmd: string) => {
  const resolvedCmd = aliases[cmd] || cmd;
  // ... rest of logic
};

Next Challenge: Implement ANSI color code parsing to support colored output (e.g., \x1b[31mError\x1b[0m renders “Error” in red), mimicking real terminal behavior.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!