Integrating DataStoryBot into a React Application
Build a React component that uploads CSVs, displays AI-generated story angles, and renders narratives with charts using the DataStoryBot API.
Integrating DataStoryBot into a React Application
You have a React app. Your users have CSVs. They want insights without leaving your product. This article walks through building a complete DataStoryBot integration in React — from file upload to rendered narrative with charts — handling loading states, errors, and the 20-minute container lifecycle along the way.
The end result is a component that does what DataStoryBot's own playground does: upload, pick a story, get the report. You can drop it into any React project.
Architecture Overview
The integration follows DataStoryBot's three-step pipeline, and each step maps to a UI state:
Upload (file picker) → Analyze (story cards) → Refine (narrative + charts)
We will call the DataStoryBot API directly from the browser. No backend proxy is needed — the API accepts CORS requests during the open beta. No API key is required. All requests go to https://datastory.bot.
For the complete API reference, see the getting started guide.
The API Client
Start with a thin wrapper around the three endpoints. This keeps API details out of your components:
// lib/datastorybot.ts
const BASE_URL = "https://datastory.bot";
export interface UploadResult {
containerId: string;
fileId: string;
metadata: { fileName: string; rowCount: number; columnCount: number; columns: string[] };
}
export interface Story {
id: string; title: string; summary: string; chartFileId: string;
}
export interface RefineResult {
narrative: string;
charts: { fileId: string; caption: string }[];
resultDataset: { fileId: string; caption: string };
}
async function post(path: string, body: FormData | object): Promise<Response> {
const isForm = body instanceof FormData;
const res = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: isForm ? undefined : { "Content-Type": "application/json" },
body: isForm ? body : JSON.stringify(body),
});
if (!res.ok) throw new Error(`${path} failed: ${res.status}`);
return res;
}
export async function uploadFile(file: File): Promise<UploadResult> {
const fd = new FormData();
fd.append("file", file);
return (await post("/api/upload", fd)).json();
}
export async function analyzeData(containerId: string, steeringPrompt?: string): Promise<Story[]> {
const body: Record<string, string> = { containerId };
if (steeringPrompt) body.steeringPrompt = steeringPrompt;
return (await post("/api/analyze", body)).json();
}
export async function refineStory(
containerId: string, selectedStoryTitle: string, refinementPrompt?: string
): Promise<RefineResult> {
const body: Record<string, string> = { containerId, selectedStoryTitle };
if (refinementPrompt) body.refinementPrompt = refinementPrompt;
return (await post("/api/refine", body)).json();
}
export function getFileUrl(containerId: string, fileId: string): string {
return `${BASE_URL}/api/files/${containerId}/${fileId}`;
}
Every function throws on failure. The components will catch these errors and display them to users.
The Upload Component
The first step is getting the file from the user into DataStoryBot's container:
// components/FileUpload.tsx
import { useState } from "react";
import { uploadFile, type UploadResult } from "../lib/datastorybot";
interface Props { onUploadComplete: (result: UploadResult) => void }
export function FileUpload({ onUploadComplete }: Props) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith(".csv")) { setError("Please select a CSV file."); return; }
if (file.size > 50 * 1024 * 1024) { setError("File must be under 50 MB."); return; }
setError(null);
setUploading(true);
try {
onUploadComplete(await uploadFile(file));
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
}
return (
<div>
<label>{uploading ? "Uploading..." : "Choose a CSV file"}</label>
<input type="file" accept=".csv" onChange={handleChange} disabled={uploading} />
{error && <p role="alert">{error}</p>}
</div>
);
}
This validates the file client-side before sending it. The onUploadComplete callback passes the container ID and metadata up to the parent.
The Story Selection Component
After upload, the analyze endpoint returns three story angles. Display them as cards and let the user pick one:
// components/StorySelector.tsx
import { useState, useEffect } from "react";
import { analyzeData, getFileUrl, type Story } from "../lib/datastorybot";
interface Props {
containerId: string;
steeringPrompt?: string;
onStorySelect: (story: Story) => void;
}
export function StorySelector({ containerId, steeringPrompt, onStorySelect }: Props) {
const [stories, setStories] = useState<Story[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
analyzeData(containerId, steeringPrompt)
.then((result) => { if (!cancelled) setStories(result); })
.catch((err) => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [containerId, steeringPrompt]);
if (loading) return <p>Analyzing your data — this usually takes 15-30 seconds...</p>;
if (error) return <p role="alert">Error: {error}</p>;
return (
<div>
<h2>Story Angles</h2>
{stories.map((story) => (
<button key={story.id} onClick={() => onStorySelect(story)}>
<h3>{story.title}</h3>
<p>{story.summary}</p>
<img src={getFileUrl(containerId, story.chartFileId)} alt={story.title} loading="lazy" />
</button>
))}
</div>
);
}
The useEffect calls analyze immediately on mount. The cancellation flag prevents state updates if the user navigates away. Preview chart images load directly from DataStoryBot's file endpoint — dark-themed PNGs rendered by matplotlib inside the container.
The Results Component
Once the user picks a story, call refine and render the output:
// components/AnalysisResult.tsx
import { useState, useEffect } from "react";
import { refineStory, getFileUrl, type RefineResult } from "../lib/datastorybot";
import ReactMarkdown from "react-markdown";
interface Props {
containerId: string;
selectedStoryTitle: string;
refinementPrompt?: string;
}
export function AnalysisResult({ containerId, selectedStoryTitle, refinementPrompt }: Props) {
const [result, setResult] = useState<RefineResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
refineStory(containerId, selectedStoryTitle, refinementPrompt)
.then((data) => { if (!cancelled) setResult(data); })
.catch((err) => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [containerId, selectedStoryTitle, refinementPrompt]);
if (loading) return <p>Generating your data story — this takes 20-40 seconds...</p>;
if (error) return <p role="alert">Error: {error}</p>;
if (!result) return null;
return (
<article>
<section><ReactMarkdown>{result.narrative}</ReactMarkdown></section>
<section>
<h2>Charts</h2>
{result.charts.map((chart) => (
<figure key={chart.fileId}>
<img src={getFileUrl(containerId, chart.fileId)} alt={chart.caption} />
<figcaption>{chart.caption}</figcaption>
</figure>
))}
</section>
<section>
<a href={getFileUrl(containerId, result.resultDataset.fileId)} download>
Download filtered dataset
</a>
</section>
</article>
);
}
The narrative is Markdown — react-markdown handles rendering. Charts are dark-themed PNG images loaded directly from the container. The filtered dataset is a downloadable CSV link.
Wiring It Together
The parent component manages the flow with a step state that moves through "upload", "stories", and "result":
// components/DataAnalyzer.tsx
import { useState } from "react";
import { FileUpload } from "./FileUpload";
import { StorySelector } from "./StorySelector";
import { AnalysisResult } from "./AnalysisResult";
import type { UploadResult, Story } from "../lib/datastorybot";
type Step = "upload" | "stories" | "result";
export function DataAnalyzer() {
const [step, setStep] = useState<Step>("upload");
const [upload, setUpload] = useState<UploadResult | null>(null);
const [story, setStory] = useState<Story | null>(null);
return (
<div>
{step !== "upload" && (
<button onClick={() => { setStep("upload"); setUpload(null); setStory(null); }}>
Start over
</button>
)}
{step === "upload" && (
<FileUpload onUploadComplete={(r) => { setUpload(r); setStep("stories"); }} />
)}
{step === "stories" && upload && (
<StorySelector
containerId={upload.containerId}
onStorySelect={(s) => { setStory(s); setStep("result"); }}
/>
)}
{step === "result" && upload && story && (
<AnalysisResult containerId={upload.containerId} selectedStoryTitle={story.title} />
)}
</div>
);
}
Three states, three components, clean transitions. In a production app, you would add transitions, progress indicators, and persist the container ID in session storage so a page refresh does not lose the session.
Handling the Container Lifecycle
The ephemeral container lives for 20 minutes. After that, chart URLs break, the refine endpoint returns 404, and image tags silently fail. A simple hook tracks this:
import { useState, useEffect } from "react";
export function useContainerTimer(containerId: string | null) {
const [expired, setExpired] = useState(false);
useEffect(() => {
if (!containerId) return;
setExpired(false);
const timer = setTimeout(() => setExpired(true), 20 * 60 * 1000);
return () => clearTimeout(timer);
}, [containerId]);
return expired;
}
Use this to show a warning when time is running low, or to prompt re-upload after expiry. For production, consider downloading and caching chart images immediately after refinement so your display does not depend on the container staying alive.
Performance Notes
The analyze step takes 15-30 seconds. Refine adds another 20-40 seconds. Users will stare at loading states for up to a minute. Make this tolerable: show progress text that updates at each step, display file metadata immediately after upload, and show story cards as soon as analyze returns (before the user clicks refine). Do not try to poll or stream — these are synchronous HTTP calls. The latency is inherent to running code in a container, not a network issue.
What to Read Next
For the full API endpoint reference — request schemas, response formats, and edge cases — see the API reference guide.
To understand what DataStoryBot actually does inside the container during analysis, read the getting started guide.
Or try the full flow yourself in the DataStoryBot playground — it uses the same API your React app will call.
Ready to find your data story?
Upload a CSV and DataStoryBot will uncover the narrative in seconds.
Try DataStoryBot →