# Canvas starter scaffold A known-good baseline for a React + Quill data canvas. It already wires the pieces that are easy to get wrong — the date picker (self-sizing, no `compact`), theme-aware tokens, per-query loading state so every card fills in the moment its own data lands, reading a typed-node result correctly, and the "View query" verification dialog every ad-hoc data card must carry. Start from it on a first build: keep the wiring, replace the sample metrics and the layout with what the user asked for. Ideally swap the inline `ph.query` typed nodes for saved insights loaded with `ph.loadInsight(shortId, { dateRange })` (see the `querying-canvas-data` skill) — then replace the "View query" dialog with a "View in PostHog" button calling `ph.openExternal(insightUrl)`, the URL minted at authoring time by the `generate-app-url` MCP tool (`/insights/{id}`), so viewers verify the numbers on the real insight with their own permissions applied. The load-bearing pattern is `useCanvasQuery`: one instance per query, each owning its `{ loading, error, data }`. All queries start concurrently on mount and each card renders as soon as its own result arrives — a slow query only holds back its own card. Keep that shape as you add metrics; never collapse the queries behind one shared `loading` flag or a `Promise.all`. ```tsx import React, { useEffect, useState } from 'react' import { Button, Card, CardContent, CardHeader, CardTitle, DateTimePicker, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Heading, Popover, PopoverContent, PopoverTrigger, quickRanges, SkeletonText, } from '@posthog/quill' import { RefreshCw } from 'lucide-react' import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' // One builder per query feeds both the ph.query call and its "View query" // dialog, so the query a viewer inspects is exactly the one that ran. Typed // query nodes are computed by PostHog's own runner so the numbers match the // UI exactly. `event: null` = all events (works on any project). const totalEventsQuery = (dateRange) => ({ kind: 'TrendsQuery', series: [{ kind: 'EventsNode', event: null, name: 'All events', math: 'total' }], dateRange, }) // BoldNumber makes the runner compute uniques across the whole period; // summing the per-day values (`count`) would recount anyone active on // several days. const uniqueUsersQuery = (dateRange) => ({ kind: 'TrendsQuery', series: [{ kind: 'EventsNode', event: null, name: 'Unique users', math: 'dau' }], trendsFilter: { display: 'BoldNumber' }, dateRange, }) // One instance per query. Each section owns its own { loading, error, data } // and renders the moment ITS result lands — never share one loading flag // across queries or gate the canvas on Promise.all: that makes the fastest // card wait for the slowest query. function useCanvasQuery(runQuery, deps) { const [state, setState] = useState({ loading: true, error: null, data: null }) useEffect(() => { let cancelled = false setState({ loading: true, error: null, data: null }) runQuery() .then((data) => { if (cancelled) return setState({ loading: false, error: null, data }) }) .catch((err) => { if (cancelled) return // A failed query must LOOK failed — falling through to zeros or an // empty chart reads as "no data" and hides real breakage. setState({ loading: false, error: String(err?.message ?? err), data: null }) }) return () => { cancelled = true } }, deps) return state } function CardError({ message, onRetry }) { return (
Couldn't load: {message}