AI & ML

Build Reasoning UIs with DeepSeek R1: Visualize Chain-of-Thought (2026)

· 5 min read
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

For most of the LLM era, model outputs arrived as finished artifacts: a paragraph of text, a code block, a summary. DeepSeek R1 changed that calculus by exposing reasoning tokens as a first-class output, separate from the final answer, letting developers build reasoning UIs that render a model's chain-of-thought in real time.

How to Build a Real-Time Chain-of-Thought Visualizer with DeepSeek R1

  1. Scaffold a Next.js App Router project with TypeScript, Tailwind CSS, and Socket.IO dependencies.
  2. Configure the DeepSeek R1 API client using the OpenAI-compatible SDK with streaming enabled.
  3. Create a custom Node.js server that attaches Socket.IO alongside Next.js for WebSocket support.
  4. Parse incoming reasoning tokens using heuristic regex patterns to detect step boundaries and self-corrections.
  5. Emit structured messages over WebSockets using a discriminated-union protocol for steps, corrections, and final answers.
  6. Build React visualization components—a vertical timeline with animated step cards, correction badges, and a final answer panel.
  7. Manage streaming state via a custom hook that accumulates steps, marks abandoned branches, and exposes start/cancel controls.
  8. Harden for production with rate limiting, CORS restrictions, ARIA live regions, virtualized rendering, and cost monitoring.

This article walks through building a real-time chain-of-thought visualizer using DeepSeek R1, React, Next.js (App Router), and WebSockets.

Table of Contents

Why Reasoning UIs Matter Now

For most of the LLM era, model outputs arrived as finished artifacts: a paragraph of text, a code block, a summary. The internal process that produced them stayed hidden. DeepSeek R1 changed that calculus. Its API exposes reasoning tokens as a first-class output, separate from the final answer, letting developers build reasoning UIs that render a model's chain-of-thought in real time. This is not a prompt engineering trick or a post-hoc explanation. R1 emits sequential, heuristically parseable reasoning content as it thinks, step by step, before it commits to a response.

This article walks through building a real-time chain-of-thought visualizer using DeepSeek R1, React, Next.js (App Router), and WebSockets. The end result is an interactive interface where users submit a prompt, watch R1's reasoning steps appear as an animated vertical flow, see self-corrections rendered visually, and receive the final answer in a dedicated panel below the reasoning timeline, visually distinct from the chain-of-thought steps.

Prerequisites:

  • Working knowledge of React, Next.js App Router conventions, and TypeScript
  • Basic understanding of how LLM streaming APIs behave
  • Node.js ≥ 18.17 (required by Next.js 14+)
  • A DeepSeek API key with R1 access enabled

Understanding DeepSeek R1's Chain-of-Thought Output Format

How R1 Structures Reasoning Tokens

R1's chat completion response returns two distinct content fields: reasoning_content and content. The reasoning_content field carries the model's intermediate thinking, including logical steps, sub-steps, self-corrections, and intermediate conclusions. The content field carries the final answer. During streaming, reasoning tokens arrive first. The model works through its chain-of-thought in reasoning_content chunks, and only after reasoning completes does content begin populating.

Within the reasoning stream, the model's output follows recognizable structural patterns: numbered steps, sub-step elaborations, explicit self-correction markers (phrases like "Wait, let me reconsider..." or "Actually, that's incorrect because..."), and conclusion statements. These patterns are heuristic and will vary across model versions or prompt types. The API does not formally delimit them with special tokens, but they have been consistent enough to parse programmatically in testing.

R1 emits sequential, heuristically parseable reasoning content as it thinks, step by step, before it commits to a response.

Parsing the Reasoning Stream

You identify reasoning boundaries by parsing heuristic patterns in the token stream. Step transitions typically align with numbered markers or logical pivots. Self-corrections appear as backtracking phrases followed by revised reasoning. The data model should represent this as a tree structure rather than a flat list, since corrections create branching points where a parent step gets abandoned in favor of a corrected child.

Here is a sample streaming response payload from the R1 API, followed by TypeScript types that model the parsed reasoning structure:

// Sample R1 streaming chunk (simplified representation)
// Each chunk arrives via the streaming API as a server-sent event
const sampleChunk = {
  id: "chatcmpl-abc123",
  object: "chat.completion.chunk",
  choices: [
    {
      index: 0,
      delta: {
        reasoning_content: "Step 1: The user is asking about the time complexity of merge sort. Let me break this down...",
        content: null, // null while reasoning is in progress
      },
      finish_reason: null,
    },
  ],
};

// After reasoning completes, content chunks begin:
const finalChunk = {
  id: "chatcmpl-abc123",
  object: "chat.completion.chunk",
  choices: [
    {
      index: 0,
      delta: {
        reasoning_content: null, // null once reasoning is done
        content: "The time complexity of merge sort is O(n log n)...",
      },
      finish_reason: null,
    },
  ],
};

// --- Parsed reasoning data model ---

interface ReasoningStep {
  id: string;
  index: number;
  content: string;
  type: "step" | "correction" | "conclusion";
  parentId: string | null; // null for root steps; points to corrected step's UUID for corrections
  status: "thinking" | "complete" | "abandoned";
  timestamp: number;
}

interface ReasoningChain {
  id: string;
  steps: ReasoningStep[];
  finalAnswer: string | null;
  status: "idle" | "reasoning" | "complete" | "error";
}

The parentId field enables tree representation: when a correction step references an earlier step, the UI can render the branching relationship. The abandoned status marks steps that the model subsequently walked back.

Setting Up the Project

Scaffolding the Next.js App

Initialize a Next.js project with the App Router and install the required dependencies:

npx create-next-app@latest reasoning-ui --typescript --app --tailwind --eslint
cd reasoning-ui
npm install openai socket.io socket.io-client uuid
npm install -D @types/uuid ts-node tsconfig-paths

The openai package works with DeepSeek's API since R1 exposes an OpenAI-compatible endpoint. Socket.IO handles the WebSocket layer with built-in reconnection and room support.

Since the project uses a custom server.ts (explained below), update your package.json scripts to use ts-node:

{
  "scripts": {
    "dev": "ts-node -r tsconfig-paths/register server.ts",
    "build": "next build",
    "start": "NODE_ENV=production ts-node -r tsconfig-paths/register server.ts"
  }
}

Note: tsx is a faster alternative to ts-node if you prefer. The -r tsconfig-paths/register flag ensures that @/ path aliases defined in tsconfig.json resolve correctly outside of the Next.js bundler context.

Configuring the DeepSeek R1 API Connection

Create a .env.local file at the project root:

DEEPSEEK_API_KEY=your_api_key_here
ALLOWED_ORIGIN=http://localhost:3000
NEXT_PUBLIC_SOCKET_URL=

Create a server-side utility that initializes the DeepSeek client with streaming enabled:

// lib/deepseek.ts
import OpenAI from "openai";

if (!process.env.DEEPSEEK_API_KEY) {
  throw new Error("DEEPSEEK_API_KEY is not set. Add it to .env.local before starting the server.");
}

const deepseek = new OpenAI({
  baseURL: "https://api.deepseek.com",
  apiKey: process.env.DEEPSEEK_API_KEY,
});

export async function* streamReasoning(
  prompt: string,
  options?: { signal?: AbortSignal }
) {
  const stream = await deepseek.chat.completions.create(
    {
      model: "deepseek-reasoner",
      messages: [{ role: "user", content: prompt }],
      stream: true,
    },
    { signal: options?.signal }
  );

  for await (const chunk of stream) {
    const choice = chunk.choices[0];
    if (!choice) continue;

    const delta = choice.delta;
    if (!delta) continue;

    const reasoning =
      typeof (delta as Record<string, unknown>).reasoning_content === "string"
        ? ((delta as Record<string, unknown>).reasoning_content as string)
        : null;

    yield {
      reasoning_content: reasoning,
      content: typeof delta.content === "string" ? delta.content : null,
      finish_reason: choice.finish_reason ?? null,
    };
  }
}

The deepseek-reasoner model identifier activates R1's reasoning mode. Verify the current identifier at https://platform.deepseek.com/api-docs before deploying, as model names change across API versions. The generator function yields each chunk as it arrives, preserving the separation between reasoning_content and content. The optional signal parameter allows the caller to cancel the underlying HTTP stream, stopping both token processing and API billing.

Building the WebSocket Streaming Layer

Why WebSockets Over Server-Sent Events

SSE supports client-initiated cancellation via AbortController, but WebSockets are chosen here for bidirectional use cases such as mid-stream follow-up clarifications without re-establishing a connection. Socket.IO's built-in reconnection logic with exponential backoff further reduces the client-side plumbing needed for a reliable streaming experience with automatic reconnection and transparent fallback to long-polling.

Implementing the Next.js API Route with WebSocket

Next.js App Router does not natively support WebSocket upgrades in Route Handlers. The standard approach is a custom server file that attaches Socket.IO alongside Next.js. Create a server.ts at the project root:

// server.ts
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server as SocketIOServer } from "socket.io";
import { streamReasoning } from "./lib/deepseek";
import { v4 as uuidv4 } from "uuid";
import type { ReasoningStepMessage } from "./types/reasoning";

const dev = process.env.NODE_ENV !== "production";

if (!dev && !process.env.ALLOWED_ORIGIN) {
  throw new Error(
    "ALLOWED_ORIGIN must be set in production. Refusing to start with default localhost origin."
  );
}

const app = next({ dev });
const handle = app.getRequestHandler();

const MAX_PROMPT_LENGTH = 4000;
const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
const RATE_WINDOW_MS = 60_000;
const MAX_REQUESTS_PER_WINDOW = 10;

app.prepare().then(() => {
  const httpServer = createServer((req, res) => {
    handle(req, res, parse(req.url!, true));
  });

  const io = new SocketIOServer(httpServer, {
    cors: {
      origin: process.env.ALLOWED_ORIGIN ?? "http://localhost:3000",
      credentials: true,
    },
    path: "/api/reasoning-socket",
  });

  io.on("connection", (socket) => {
    console.log("Client connected:", socket.id);

    let abortController: AbortController | null = null;
    let isStreaming = false;

    // Per-socket rate limiting
    let requestCount = 0;
    let windowStart = Date.now();

    function checkRateLimit(): boolean {
      const now = Date.now();
      if (now - windowStart > RATE_WINDOW_MS) {
        requestCount = 0;
        windowStart = now;
      }
      requestCount++;
      return requestCount <= MAX_REQUESTS_PER_WINDOW;
    }

    socket.on("start_reasoning", async (data: { prompt: string }) => {
      // Rate limit check
      if (!checkRateLimit()) {
        socket.emit("reasoning_message", {
          type: "error",
          payload: {
            message: "Rate limit exceeded. Please wait before submitting again.",
            timestamp: Date.now(),
          },
        });
        return;
      }

      // Concurrency guard: reject if a stream is already in progress
      if (isStreaming) {
        socket.emit("reasoning_message", {
          type: "error",
          payload: {
            message:
              "A reasoning request is already in progress. Cancel it before starting a new one.",
            timestamp: Date.now(),
          },
        });
        return;
      }

      // Input validation
      if (
        !data?.prompt ||
        typeof data.prompt !== "string" ||
        data.prompt.trim().length === 0 ||
        data.prompt.length > MAX_PROMPT_LENGTH
      ) {
        socket.emit("reasoning_message", {
          type: "error",
          payload: {
            message:
              "Invalid prompt. Must be a non-empty string under 4000 characters.",
            timestamp: Date.now(),
          },
        });
        return;
      }

      // Sanitize control characters
      const sanitizedPrompt = data.prompt.replace(CONTROL_CHAR_RE, "").trim();
      if (sanitizedPrompt.length === 0) {
        socket.emit("reasoning_message", {
          type: "error",
          payload: {
            message: "Prompt contained only invalid characters.",
            timestamp: Date.now(),
          },
        });
        return;
      }

      const chainId = uuidv4();
      let stepIndex = 0;
      let reasoningBuffer = "";
      let lastStepId: string | null = null;
      abortController = new AbortController();
      isStreaming = true;

      socket.emit("reasoning_started", { chainId });

      try {
        let shouldComplete = false;

        for await (const chunk of streamReasoning(sanitizedPrompt, {
          signal: abortController.signal,
        })) {
          if (abortController.signal.aborted) break;

          if (chunk.reasoning_content) {
            reasoningBuffer += chunk.reasoning_content;

            // Heuristic: split on numbered step patterns or self-correction markers
            const stepPattern =
              /(?:^|
)(Step \d+[:.]|Wait,|Actually,|Let me reconsider)/;
            const stepMatch = reasoningBuffer.match(stepPattern);

            if (
              stepMatch &&
              stepMatch.index !== undefined &&
              reasoningBuffer.length > 80
            ) {
              // Boundary starts at the keyword itself, not the preceding newline
              const boundaryIndex =
                stepMatch.index === 0 ? 0 : stepMatch.index + 1;

              const isCorrection =
                /^(Wait,|Actually,|Let me reconsider)/.test(stepMatch[1]);

              const stepId = uuidv4();

              const step: ReasoningStepMessage = {
                type: isCorrection
                  ? "reasoning_correction"
                  : "reasoning_step",
                payload: {
                  id: stepId,
                  index: stepIndex++,
                  content: reasoningBuffer.slice(0, boundaryIndex).trim(),
                  stepType: isCorrection ? "correction" : "step",
                  parentId: isCorrection ? lastStepId : null,
                  timestamp: Date.now(),
                },
              };

              socket.emit("reasoning_message", step);

              // Keep only the content after the matched boundary
              reasoningBuffer = reasoningBuffer.slice(boundaryIndex);

              // Track the last non-correction step ID for parentId references
              if (!isCorrection) {
                lastStepId = stepId;
              }
            }
          }

          if (chunk.content) {
            socket.emit("reasoning_message", {
              type: "final_answer",
              payload: { content: chunk.content, timestamp: Date.now() },
            });
          }

          if (chunk.finish_reason === "stop") {
            shouldComplete = true;
          }
        }

        // Flush remaining reasoning buffer after the loop exits
        if (reasoningBuffer.trim()) {
          const finalStepId = uuidv4();
          socket.emit("reasoning_message", {
            type: "reasoning_step",
            payload: {
              id: finalStepId,
              index: stepIndex++,
              content: reasoningBuffer.trim(),
              stepType: "conclusion",
              parentId: null,
              timestamp: Date.now(),
            },
          });
        }

        if (shouldComplete) {
          socket.emit("reasoning_complete", { chainId });
        }
      } catch (error: any) {
        if (error.name === "AbortError") {
          // Expected when cancellation is triggered; no need to emit an error
          return;
        }
        console.error("Reasoning stream error:", {
          socketId: socket.id,
          chainId,
          message: error.message,
          stack: error.stack,
        });
        socket.emit("reasoning_message", {
          type: "error",
          payload: {
            message: "An internal error occurred during reasoning.",
            timestamp: Date.now(),
          },
        });
      } finally {
        isStreaming = false;
        abortController = null;
      }
    });

    socket.on("cancel_reasoning", () => {
      abortController?.abort();
      socket.emit("reasoning_cancelled", {});
    });

    socket.on("disconnect", () => {
      abortController?.abort();
    });
  });

  const port = parseInt(process.env.PORT ?? "3000", 10);
  httpServer.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`);
  });
});

Security note: The ALLOWED_ORIGIN environment variable restricts which origins can connect via Socket.IO. Set this to your production domain before deploying. The server will refuse to start in production if ALLOWED_ORIGIN is not set. Never use origin: "*" in production — it allows any website to connect and submit prompts billed to your API key.

The server bridges R1's streaming output to WebSocket messages. It detects step boundaries with heuristic regex patterns and forwards structured messages to the client. The AbortController signal passes through to the OpenAI SDK's HTTP request, so cancelling mid-stream stops both chunk processing and upstream API billing. A per-socket concurrency guard ensures only one stream runs at a time, and a sliding-window rate limiter caps requests to prevent API key exhaustion.

Designing the Message Protocol

// types/reasoning.ts

export interface ReasoningStep {
  id: string;
  index: number;
  content: string;
  type: "step" | "correction" | "conclusion";
  parentId: string | null;
  status: "thinking" | "complete" | "abandoned";
  timestamp: number;
}

export interface ReasoningChain {
  id: string;
  steps: ReasoningStep[];
  finalAnswer: string | null;
  status: "idle" | "reasoning" | "complete" | "error";
}

export type ReasoningStepMessage = {
  type: "reasoning_step" | "reasoning_correction";
  payload: {
    id: string;
    index: number;
    content: string;
    stepType: "step" | "correction" | "conclusion";
    parentId: string | null;
    timestamp: number;
  };
};

export type FinalAnswerMessage = {
  type: "final_answer";
  payload: { content: string; timestamp: number };
};

export type ErrorMessage = {
  type: "error";
  payload: { message: string; timestamp: number };
};

export type ReasoningSocketMessage =
  | ReasoningStepMessage
  | FinalAnswerMessage
  | ErrorMessage;

The discriminated union on type allows exhaustive pattern matching on the client side, keeping message handling type-safe.

Building the Reasoning Visualization Components

The ReasoningFlow Component

The primary visualization renders reasoning steps as an animated vertical timeline. Each step appears as an expandable card that transitions from a "thinking" state (pulsing indicator) to "complete" as the next step arrives.

Add the following animation to your app/globals.css:

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-fade-in-up {
  animation: fadeInUp 0.2s ease-out both;
}
// components/ReasoningFlow.tsx
"use client";
import { ReasoningStep } from "@/types/reasoning";
import { ReasoningStepCard } from "./ReasoningStepCard";

interface ReasoningFlowProps {
  steps: ReasoningStep[];
  className?: string;
}

export function ReasoningFlow({ steps, className }: ReasoningFlowProps) {
  return (
    <div className={`relative pl-8 ${className ?? ""}`}>
      {/* Vertical timeline line */}
      <div className="absolute left-3 top-0 bottom-0 w-0.5 bg-gray-200" />
      {steps.map((step, i) => (
        <div
          key={step.id}
          className="relative mb-4 animate-fade-in-up"
          style={{ animationDelay: `${i * 50}ms` }}
        >
          {/* Timeline dot */}
          <div
            className={`absolute -left-5 top-3 w-3 h-3 rounded-full border-2 ${
              step.status === "thinking"
                ? "border-blue-400 bg-blue-100 animate-pulse"
                : step.status === "abandoned"
                ? "border-red-300 bg-red-50"
                : "border-green-400 bg-green-100"
            }`}
          />
          <ReasoningStepCard step={step} />
        </div>
      ))}
    </div>
  );
}
// components/ReasoningStepCard.tsx
"use client";
import { useState, useEffect } from "react";
import { ReasoningStep } from "@/types/reasoning";

interface ReasoningStepCardProps {
  step: ReasoningStep;
}

export function ReasoningStepCard({ step }: ReasoningStepCardProps) {
  const [expanded, setExpanded] = useState(step.status === "thinking");

  useEffect(() => {
    // Auto-collapse when step transitions away from "thinking"
    if (step.status !== "thinking") {
      setExpanded(false);
    }
  }, [step.status]);

  return (
    <div
      className={`rounded-lg border p-4 cursor-pointer transition-all ${
        step.status === "abandoned"
          ? "border-red-200 bg-red-50 opacity-60"
          : step.type === "correction"
          ? "border-amber-200 bg-amber-50"
          : "border-gray-200 bg-white"
      }`}
      onClick={() => setExpanded(!expanded)}
    >
      <div className="flex items-center justify-between">
        <span className="text-sm font-medium text-gray-500">
          Step {step.index + 1}
          {step.type === "correction" && (
            <span className="ml-2 text-xs bg-amber-200 text-amber-800 px-2 py-0.5 rounded">
              ↩ Correction
            </span>
          )}
        </span>
        <span className="text-xs text-gray-400">
          {expanded ? "▾" : "▸"}
        </span>
      </div>
      {expanded && (
        <p
          className={`mt-2 text-sm text-gray-700 ${
            step.status === "abandoned" ? "line-through" : ""
          }`}
        >
          {step.content}
        </p>
      )}
    </div>
  );
}

Handling Self-Corrections Visually

When R1 backtracks, the UI needs to signal that a prior step was reconsidered. Correction steps receive amber styling and a "↩ Correction" badge. Abandoned steps (the ones being corrected) receive a strikethrough treatment, red-tinted border, and reduced opacity. The conditional logic lives inside ReasoningStepCard:

// Inside ReasoningStepCard, the key conditional rendering:
{step.status === "abandoned" && (
  <div className="mt-1 flex items-center text-xs text-red-500">
    <svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
      <path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" />
    </svg>
    Revised in a later step
  </div>
)}

{step.type === "correction" && step.parentId && (
  <div className="mt-1 text-xs text-amber-600">
    Corrects a previous step
  </div>
)}

This keeps the UI scannable: users can quickly identify which reasoning paths were explored and abandoned versus which survived to the final answer.

Users can quickly identify which reasoning paths were explored and abandoned versus which survived to the final answer.

The Final Answer Panel

The final answer renders in a visually distinct panel below the reasoning flow. It appears only after the last reasoning token. A "Show reasoning" toggle collapses the entire reasoning chain for users who want just the result:

// components/FinalAnswerPanel.tsx
"use client";
import { ReasoningChain } from "@/types/reasoning";

interface FinalAnswerPanelProps {
  chain: ReasoningChain;
  showReasoning: boolean;
  onToggleReasoning: () => void;
}

export function FinalAnswerPanel({
  chain,
  showReasoning,
  onToggleReasoning,
}: FinalAnswerPanelProps) {
  if (chain.status !== "complete" || !chain.finalAnswer) return null;

  return (
    <div className="mt-6 rounded-lg border-2 border-blue-200 bg-blue-50 p-6">
      <div className="flex items-center justify-between mb-3">
        <h3 className="text-sm font-semibold text-blue-900">Final Answer</h3>
        <button
          onClick={onToggleReasoning}
          className="text-xs text-blue-600 hover:text-blue-800"
        >
          {showReasoning ? "Hide reasoning" : "Show reasoning"}
        </button>
      </div>
      <p className="text-gray-800 whitespace-pre-wrap">{chain.finalAnswer}</p>
    </div>
  );
}

Real-Time State Management and Streaming UX

Managing Streaming State with React Hooks

The useReasoningStream hook encapsulates all WebSocket and state logic:

// hooks/useReasoningStream.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import {
  ReasoningStep,
  ReasoningChain,
  ReasoningSocketMessage,
} from "@/types/reasoning";

type StreamStatus = "idle" | "connecting" | "reasoning" | "complete" | "error";

export function useReasoningStream() {
  const [chain, setChain] = useState<ReasoningChain>({
    id: "",
    steps: [],
    finalAnswer: null,
    status: "idle",
  });
  const [status, setStatus] = useState<StreamStatus>("idle");
  const socketRef = useRef<Socket | null>(null);

  useEffect(() => {
    const socket = io(process.env.NEXT_PUBLIC_SOCKET_URL ?? "", {
      path: "/api/reasoning-socket",
    });
    socketRef.current = socket;

    socket.on("connect", () => {
      setStatus((prev) => (prev === "error" ? "idle" : prev));
    });

    socket.on("disconnect", (reason) => {
      if (reason !== "io client disconnect") {
        setStatus("error");
      }
    });

    socket.on("connect_error", () => {
      setStatus("error");
    });

    socket.on("reasoning_started", ({ chainId }) => {
      setChain({
        id: chainId,
        steps: [],
        finalAnswer: null,
        status: "reasoning",
      });
      setStatus("reasoning");
    });

    socket.on("reasoning_message", (msg: ReasoningSocketMessage) => {
      if (
        msg.type === "reasoning_step" ||
        msg.type === "reasoning_correction"
      ) {
        const newStep: ReasoningStep = {
          ...msg.payload,
          type: msg.payload.stepType,
          status: "complete",
        };

        setChain((prev) => {
          let updatedSteps = prev.steps;
          if (msg.type === "reasoning_correction" && msg.payload.parentId) {
            updatedSteps = updatedSteps.map((s) =>
              s.id === msg.payload.parentId
                ? { ...s, status: "abandoned" as const }
                : s
            );
          }
          return { ...prev, steps: [...updatedSteps, newStep] };
        });
      }

      if (msg.type === "final_answer") {
        setChain((prev) => ({
          ...prev,
          finalAnswer: (prev.finalAnswer ?? "") + msg.payload.content,
        }));
      }

      if (msg.type === "error") {
        setStatus("error");
      }
    });

    socket.on("reasoning_complete", () => {
      setChain((prev) => ({ ...prev, status: "complete" }));
      setStatus("complete");
    });

    return () => {
      socket.disconnect();
    };
  }, []);

  const start = useCallback((prompt: string) => {
    setStatus("connecting");
    setChain({
      id: "",
      steps: [],
      finalAnswer: null,
      status: "idle",
    });
    socketRef.current?.emit("start_reasoning", { prompt });
  }, []);

  const cancel = useCallback(() => {
    socketRef.current?.emit("cancel_reasoning");
    setChain((prev) => ({
      ...prev,
      status: "idle",
    }));
    setStatus("idle");
  }, []);

  return { chain, status, start, cancel };
}

The hook exposes start and cancel functions, the current chain state with all accumulated steps, and a top-level status for controlling UI transitions. All step accumulation uses React state updater functions to avoid race conditions under rapid message bursts. The hook also surfaces socket connection and disconnection states so the UI can indicate when reconnection is in progress.

UX Considerations for Streaming Reasoning

To auto-scroll to the latest step, add a useEffect that watches chain.steps.length and scrolls the container to the bottom. Between token arrivals, skeleton cards with a pulsing animation indicate that the model is still working. The cancel function on the hook maps to a visible "Stop reasoning" button, giving users control over long-running chains.

Interactive Demo: Real-Time Reasoning Flow

Deployment note: Vercel's serverless runtime does not support persistent WebSocket servers. Deploy this application to Railway, Render, Fly.io, or a VPS. For a Vercel-compatible alternative, replace Socket.IO with SSE using Next.js Route Handlers.

The complete application provides an interactive demo where users type a prompt, submit it, and watch reasoning steps materialize as animated cards. Self-corrections flash amber. The final answer slides in below.

For readers unable to access a live version: the flow begins with a text input and submit button, transitions to a pulsing "Thinking..." indicator, then renders sequential step cards down a vertical timeline, with correction branches visually offset, culminating in a highlighted answer panel.

Polish and Production Considerations

Accessibility

Streaming content requires ARIA live regions. The reasoning flow container should use aria-live="polite" so screen readers announce new steps without interrupting the current reading. Each step card should be keyboard-focusable with tabIndex={0} and respond to Enter for expand/collapse.

Performance

When reasoning chains exceed 100 steps, accumulated DOM nodes degrade scroll performance. Virtualized rendering via @tanstack/virtual (preferred; react-window is in maintenance mode) prevents this problem. Rapid token arrivals should be debounced with a 50-100ms window to batch state updates and reduce re-renders. Below 50ms, batching gains are negligible; above 100ms, users perceive visible lag between token arrival and rendering.

Error Handling

Socket.IO reconnects automatically with exponential backoff, and the hook surfaces reconnection state to the UI so users know when the connection drops. Malformed reasoning tokens (chunks that fail the heuristic parser) should be buffered and appended to the next valid step rather than silently dropped.

Cost Awareness

R1 reasoning tokens are billed separately from output tokens. Long chains generate thousands of reasoning tokens, and reasoning tokens can outnumber answer tokens 10-to-1 or more on complex prompts. Check DeepSeek's pricing page for current per-token rates. Monitor usage via the DeepSeek dashboard to avoid unexpected costs, and consider setting a maximum reasoning token budget in your server logic.

R1 reasoning tokens are billed separately from output tokens. Long chains generate thousands of reasoning tokens, and reasoning tokens can outnumber answer tokens 10-to-1 or more on complex prompts.

What Comes Next

This article covered building a real-time chain-of-thought visualizer that streams DeepSeek R1's reasoning tokens through WebSockets into an animated React UI with support for self-correction rendering and user cancellation. Natural extensions include reasoning chain export to JSON or PDF, and a multi-model comparison view that runs the same prompt through different reasoning models side by side. A reasoning analytics dashboard tracking step counts, correction rates, and reasoning time across queries would also be worth building. The DeepSeek R1 API documentation at https://platform.deepseek.com/api-docs covers max_tokens, temperature constraints, and reasoning token budget caps in detail.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.