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

A terminal-style interface sounds simple until you try to build one. It’s just an input and some text output, right? You deploy that, and then users start telling you the terminal doesn’t scroll when they type a long command, arrow keys don’t navigate history, Ctrl+L doesn’t clear the screen, and on mobile the keyboard never appears. Real terminals carry decades of muscle memory, and users expect all of it.

The TerminalBox.tsx component in this portfolio handles all of that: command history with timestamps, auto-scroll to new output, Ctrl+L to clear, a boot animation, and focus management that keeps interaction seamless. This tutorial walks through every piece of how it works — specifically the state design, ref usage, effect lifecycle, and scroll synchronization that make it feel like a real terminal instead of a styled <pre> tag.

What You Need Before This Makes Sense

You need React fundamentals (components, props, state), all three core hooks (useState, useEffect, useRef), TypeScript basics, and DOM APIs like scrollIntoView and addEventListener. Understanding the React rendering lifecycle — specifically when effects run and what triggers re-renders — is important here because several decisions in this component are specifically about avoiding unnecessary renders.

The component dependencies:

src/
  components/
    TerminalBox.tsx
    CmdUserInput.tsx
    EnteredCmd.tsx
    TodayDate.tsx
    commands/
      Help.tsx
      Welcome.tsx
  lib/
    renderCmd.tsx

Why Command History Is an Array of Objects, Not a String

The naive approach is one big string:

const [output, setOutput] = useState("Welcome\n$ help\n...");

That loses all structure. You can’t render individual commands with different styles, you can’t add timestamps, and you can’t implement command-specific features like clickable links. The actual implementation stores each command as an object:

const [enteredCmd, setEnteredCmd] = useState([
  {
    cmd: "help",
    Component: Help,
    time: new Date().toLocaleTimeString(),
  },
]);

Each entry has the original command string (for display), a React component that handles rendering the output (so each command can look completely different without the terminal knowing the details), and a timestamp. The terminal pre-loads with the help command on mount so there’s always something to show.

The Boot Animation: Why the 100ms Delay Exists

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

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

Without the delay, the animation doesn’t play. Here’s why: if you set isMounted to true synchronously on mount, React batches the initial render (false) and the immediate update (true) together, and the browser never paints the intermediate state. The CSS transition has no starting point to animate from.

The 100ms delay forces two distinct paints: first render with scale-0, then after the delay, scale-100 with the CSS transition in between. The cleanup function matters too — if the component unmounts before the 100ms fires, the timer still runs and tries to update state on an unmounted component. Always cancel timers in cleanup.

Auto-Scroll Without Scrolling the Whole Page

The naive auto-scroll sets window.scrollTo(0, document.body.scrollHeight). That scrolls the entire page, not just the terminal container, and runs on every render regardless of whether new commands were added.

The actual solution uses a dummy element:

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

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

// In JSX:
<div ref={dummyRef}></div>  // Empty div at the bottom of the command list

The empty <div> lives at the very bottom of the terminal’s scrollable area. When scrollIntoView is called on it, the browser scrolls that element into view — which means the terminal scrolls to the bottom. The effect only runs when enteredCmd or isMounted changes, so unrelated state updates don’t trigger unnecessary scrolls.

I used behavior: "auto" (instant) rather than behavior: "smooth" because smooth scrolling lags behind rapid command execution. Terminals should feel immediate.

Global Keyboard Shortcuts and the Stale Closure Trap

useEffect(() => {
  const handleKeyEvent = (e: KeyboardEvent) => {
    if (e.ctrlKey && e.key.toLocaleLowerCase() === "l") {
      setEnteredCmd([]);
    }
  };
  
  document.body.addEventListener("keydown", handleKeyEvent);
  return () => {
    document.body.removeEventListener("keydown", handleKeyEvent);
  };
}, []);

The listener attaches to document.body so Ctrl+L works even when the terminal isn’t focused. The cleanup is non-negotiable. Without it, every time the component mounts (route changes, hot reloads in dev), you accumulate another listener. The first keypress after three mounts fires the handler three times.

One subtle thing: I defined handleKeyEvent inside the effect rather than outside it. If you define the function outside and reference it in the cleanup, you might have a stale closure — the function captured in removeEventListener might be a different function object than the one attached, so the removal fails silently. Defining it inside the effect guarantees you’re removing exactly what you added.

Submitting Commands: Why the Functional Update Matters

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

setEnteredCmd takes a function here rather than a value. The functional update form setEnteredCmd((current) => [...current, newCmd]) always uses the latest state, even if multiple updates are batched. The direct form setEnteredCmd([...enteredCmd, newCmd]) closes over the state value from the render that created the handler — if two commands are submitted in quick succession, the second one might overwrite the first rather than append after it.

The spread of renderCmd(cmd) followed by overriding time with new Date().toLocaleTimeString() is clean: renderCmd returns a CmdHistory object with time: "", and the submission handler fills in the actual timestamp.

The Full Component

export default function TerminalBox() {
  const [enteredCmd, setEnteredCmd] = useState([
    {
      cmd: "help",
      Component: Help,
      time: new Date().toLocaleTimeString(),
    },
  ]);
  
  const [isMounted, setIsMounted] = useState(false);
  const dummyRef = useRef() as React.MutableRefObject<HTMLDivElement>;

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

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

  useEffect(() => {
    const handleKeyEvent = (e: KeyboardEvent) => {
      if (e.ctrlKey && e.key.toLocaleLowerCase() === "l") {
        setEnteredCmd([]);
      }
    };
    document.body.addEventListener("keydown", handleKeyEvent);
    return () => {
      document.body.removeEventListener("keydown", handleKeyEvent);
    };
  }, []);

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

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

Refs vs. State: The Rule That Prevents 60fps Re-Renders

Three.js and animation-heavy projects run into this constantly, but it applies here too. If something changes every frame or on every event, use a ref. If it needs to trigger a re-render (affects what the user sees), use state. Mixing them up is the most common cause of performance issues in complex React components.

The dummyRef is a ref because we’re manipulating the DOM directly — scrollIntoView is an imperative DOM operation that doesn’t need React to re-render anything. The enteredCmd array is state because adding a command should cause the terminal to re-render and display the new output.

Edge Cases Worth Knowing About

Rapid command submission. React 18 automatically batches state updates, so submitting ten commands quickly results in one re-render with all ten commands, not ten re-renders. Before React 18, you’d need unstable_batchedUpdates for this. If you’re on an older version and notice dropped commands under load, that’s why.

Ctrl+L while typing. The current implementation clears enteredCmd but doesn’t clear the input field — that state lives in CmdUserInput. To clear both, you’d need to expose a clear method from CmdUserInput via useImperativeHandle and call it from the keyboard handler. Whether that’s worth the added complexity depends on how important the UX detail is.

Mobile keyboards. Tapping the terminal on mobile won’t bring up the keyboard because the terminal itself isn’t a focusable input. The fix is clicking anywhere in the terminal container to focus the hidden input inside CmdUserInput:

const inputRef = useRef<HTMLInputElement>(null);

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

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

Event listener accumulation. React Strict Mode mounts components twice in development to help catch missing cleanup. Without the cleanup functions on the keyboard and timer effects, you’d see doubled behavior in dev. The cleanup functions in this component handle it correctly — Strict Mode is how I noticed the stale closure issue with the keyboard listener in the first place.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!