ag-grid-patterns
AG-Grid Patterns for TMNL
Critical: AG-Grid v34 Module Registration
Without this, grid renders blank. No exceptions.
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
ModuleRegistry.registerModules([AllCommunityModule])
No CSS imports needed when using the theme prop.
Canonical Sources
Core Library (v2)
- Compound component:
src/lib/data-grid/components/UnifiedDataGrid.tsx - Context system:
src/lib/data-grid/components/DataGridContext.tsx - Theme composer:
src/lib/data-grid/composer/theme-composer.ts - Flash system:
src/lib/data-grid/flash/index.ts - Cell renderers:
src/lib/data-grid/renderers/ - Variants:
src/lib/data-grid/variants/ - Barrel export:
src/lib/data-grid/index.ts
tldraw Integration
- V2 shape (modern):
src/components/tldraw/shapes/data-grid-shape-v2.tsx - V1 shape (legacy):
src/components/tldraw/shapes/data-grid-shape.tsx - Hybrid drag:
src/components/tldraw/shapes/data-grid-shape.tsx:366-594
Architecture Documentation
- Deep dive:
assets/documents/AG_GRID_THEMING_ARCHITECTURE.md
Pattern 1: Tmnl.DataGrid — COMPOUND COMPONENT API
When: Building any data grid in TMNL.
The modern API uses compound components for declarative composition.
import { Tmnl } from '@/lib/data-grid'
import { tmnlDenseDark } from '@/lib/data-grid/variants/tmnl-dense-dark'
<Tmnl.DataGrid
id="emitters"
variant={tmnlDenseDark}
rowData={data}
columnDefs={columnDefs}
>
<Tmnl.DataGrid.Header>
<Tmnl.DataGrid.Title title="EMITTERS" badge={data.length} />
<Tmnl.DataGrid.SettingsButton onClick={openSettings} />
</Tmnl.DataGrid.Header>
<Tmnl.DataGrid.Body />
<Tmnl.DataGrid.StatusBar>
<span className="text-cyan-500">V2</span>
<span>{data.length} rows</span>
</Tmnl.DataGrid.StatusBar>
<Tmnl.DataGrid.CornerDecorations />
</Tmnl.DataGrid>
Child Components
| Component | Purpose | Props |
|---|---|---|
Header |
Container for title/controls | children |
Title |
Grid title with optional badge | title, badge? |
SettingsButton |
Settings action button | onClick |
Body |
The actual AG-Grid | (none - uses context) |
StatusBar |
Footer status area | children |
CornerDecorations |
Visual corner accents | variant? |
Per-Grid Runtime Isolation
Each <Tmnl.DataGrid> creates its own Effect runtime with isolated services:
// Inside Tmnl.DataGrid provider
const runtime = useMemo(() => createDataGridRuntime(), [gridId])
// Child components access via context
const ctx = useDataGridContext()
const variant = ctx.variant // Reactive variant
const gridApi = ctx.gridApi // Reactive grid API
Canonical source: src/lib/data-grid/components/UnifiedDataGrid.tsx
Pattern 2: GridVariant System — VARIANT-DRIVEN THEMING
When: Customizing grid appearance, density, or behavior.
Variants are not just themes. They encode:
- Density — Row height, font size, padding
- Colors — Background, text, signals, flash
- Behavior — Selection, sorting, drag, resize
- Typography — Font family, letter spacing, weight
Variant Structure
export const tmnlDenseDark: GridVariant = {
id: 'tmnl-dense-dark',
densityTier: 'dense',
density: DENSITY_PRESETS.dense,
colorScheme: 'dark',
colors: {
background: { base: '#000000', alternateRow: '#0a0a0a', header: '#0d0d0d' },
text: { primary: '#ffffff', secondary: '#a3a3a3', muted: '#525252' },
signal: { positive: '#22c55e', negative: '#ef4444', accent: '#00ffcc' },
border: { primary: '#262626', muted: '#1a1a1a' },
flash: {
up: 'rgba(34, 197, 94, 0.4)',
down: 'rgba(239, 68, 68, 0.4)',
durationMs: 1500,
},
},
behavior: BEHAVIOR_PRESETS.interactive,
typography: {
fontFamily: "'JetBrains Mono', monospace",
headerLetterSpacing: '0.05em',
},
intentOverrides: {
// Per-column styling rules
},
}
Density Presets
| Preset | Row Height | Font Size | Padding |
|---|---|---|---|
dense |
20px | 10px | 4px |
compact |
24px | 11px | 6px |
normal |
32px | 13px | 8px |
comfortable |
40px | 14px | 12px |
Behavior Presets
| Preset | Selection | Sorting | Drag | Resize |
|---|---|---|---|---|
readonly |
none | true | false | false |
interactive |
single | true | true | true |
editable |
multiple | true | true | true |
minimal |
none | false | false | false |
Variant → Theme Conversion
import { composeAgGridTheme } from '@/lib/data-grid/composer/theme-composer'
const agTheme = composeAgGridTheme(tmnlDenseDark)
// Returns AG-Grid themeQuartz.withParams({...})
Canonical source: src/lib/data-grid/variants/tmnl-dense-dark.ts
Pattern 3: Flash System — CELL CHANGE HIGHLIGHTING
When: Showing real-time data updates with visual feedback.
Severity Mapping
| Delta | Severity | Visual Effect |
|---|---|---|
| 0 | none | No flash |
| 1-5 | low | Subtle background |
| 6-10 | medium | Visible pulse |
| 11-15 | high | Strong glow |
| 16+ | critical | Full glow + pulse |
Flash State Structure
interface FlashState {
severity: 'none' | 'low' | 'medium' | 'high' | 'critical'
intensity: number // 0-1, logarithmic scale
direction: 'up' | 'down' | 'neutral'
delta: number
timestamp: number
isActive: boolean
}
useFlashTracker Hook
import { useFlashTracker } from '@/lib/data-grid/flash'
const { getFlashState, hasFlash, processUpdates, injectKeyframes } = useFlashTracker({
maxDelta: 20,
flashExpirationMs: 1500,
})
// Initialize (once)
useEffect(() => injectKeyframes(), [])
// Process row updates
useEffect(() => {
processUpdates(newData, oldData, 'value')
}, [newData])
// In cell renderer
const flash = getFlashState(rowId, field)
const styles = generateFlashStyles(flash, { colors: variant.colors.flash })
Flash in Cell Renderer
function ValueCellRenderer(params: ICellRendererParams) {
const ctx = useDataGridContext()
const { getFlashState } = ctx.flash
const flash = getFlashState(params.node.id, params.colDef.field)
return (
<div
className={flash.isActive ? `flash-${flash.severity}` : ''}
style={{
backgroundColor: flash.isActive
? flash.direction === 'up'
? ctx.variant.colors.flash.up
: ctx.variant.colors.flash.down
: undefined,
}}
>
{params.value}
</div>
)
}
Canonical source: src/lib/data-grid/flash/index.ts
Pattern 4: DataGridContext — PER-GRID SERVICES
When: Child components need access to grid state, variant, or API.
Context Shape
interface DataGridContextValue<TData = unknown> {
gridId: string
runtime: DataGridRuntime // Per-grid Effect services
variant: GridVariantType // Reactive variant
rowData: TData[]
columnDefs: ColDef<TData>[]
getRowId?: GetRowIdFunc<TData>
gridApi: GridApi | null // AG-Grid API reference
setGridApi: (api: GridApi | null) => void
flash: FlashTrackerAPI
}
Usage in Components
// Required context (throws if missing)
const ctx = useDataGridContext()
// Optional context (returns null if outside grid)
const ctx = useDataGridContextMaybe()
Color Extraction from Context
function StatusCellRenderer(params: ICellRendererParams) {
const ctx = useDataGridContextMaybe()
// Variant colors with fallback
const colors = ctx?.variant.colors ?? {
signal: { positive: '#22c55e', negative: '#ef4444' }
}
return (
<span style={{ color: colors.signal.positive }}>
{params.value}
</span>
)
}
Canonical source: src/lib/data-grid/components/DataGridContext.tsx
Pattern 5: Context-Aware Cell Renderers
When: Renderers need variant colors or grid services.
Pattern: Fallback for Standalone Usage
import { useDataGridContextMaybe } from '@/lib/data-grid'
import { COLORS } from '@/lib/tokens'
export function ValueCellRenderer(params: ValueCellRendererParams) {
const ctx = useDataGridContextMaybe()
// Colors from variant OR token fallback
const textColor = ctx?.variant.colors.text.primary ?? COLORS.textPrimary
const accentColor = ctx?.variant.colors.signal.accent ?? COLORS.accentCyan
return (
<div style={{ color: textColor }}>
<span>{params.value}</span>
<div
style={{
backgroundColor: accentColor,
width: `${params.data.percentage}%`,
}}
/>
</div>
)
}
Pattern: Flash-Aware Renderer
export function FlashValueRenderer(params: ICellRendererParams) {
const ctx = useDataGridContext()
const flash = ctx.flash.getFlashState(params.node.id, params.colDef.field!)
return (
<div
className={cn(
'transition-all duration-300',
flash.isActive && `flash-${flash.severity}`,
flash.direction === 'up' && 'text-green-400',
flash.direction === 'down' && 'text-red-400',
)}
>
{params.value}
</div>
)
}
Canonical source: src/lib/data-grid/renderers/ValueCellRenderer.tsx
Pattern 6: Hybrid Drag System — GRID-TO-CANVAS
When: Dragging rows from AG-Grid onto a tldraw canvas.
Drag Phase Transitions
┌─────────────────────────────────┐
│ AG-Grid Internal Drag │
│ (rowDragManaged=true) │
└──────────────┬──────────────────┘
│ onRowDragMove
▼
Check: outside grid bounds?
│
┌──────┴──────┐
NO YES
│ │
Continue Create ghost
in grid shape on canvas
│ │
│ onPointerMove
│ updateGhost()
│ │
└─────┬───────┘
│ onPointerUp
spawnDataCard()
Remove ghost
Drag State Schema
interface DragState {
isDragging: boolean
isOutsideGrid: boolean // Key flag for phase transition
rowData: DataGridRow | null
ghostId: string | null // tldraw shape ID
}
Phase Handlers
const onRowDragMove = useCallback((event: RowDragMoveEvent) => {
const { clientX, clientY } = event.event
const gridRect = gridRef.current?.getBoundingClientRect()
const isOutside = !gridRect ||
clientX < gridRect.left || clientX > gridRect.right ||
clientY < gridRect.top || clientY > gridRect.bottom
if (isOutside && !dragState.isOutsideGrid) {
// Transition: GridInternal → CanvasTracking
const ghostId = createGhostShape(dragState.rowData, { x: clientX, y: clientY })
setDragState(prev => ({ ...prev, isOutsideGrid: true, ghostId }))
} else if (!isOutside && dragState.isOutsideGrid) {
// Transition: CanvasTracking → GridInternal
removeGhostShape(dragState.ghostId)
setDragState(prev => ({ ...prev, isOutsideGrid: false, ghostId: null }))
}
}, [dragState, createGhostShape, removeGhostShape])
Effect Service for Drag
export interface GridDragServiceApi {
readonly getState: Effect.Effect<DragState>
readonly dispatch: (event: GridDragEvent) => Effect.Effect<void>
readonly subscribe: (handler: (state: DragState) => void) => Effect.Effect<() => void>
readonly isDragging: Effect.Effect<boolean>
readonly getPhase: Effect.Effect<DragPhase>
}
Canonical source: src/lib/data-grid/services/GridDragService.ts
Pattern 7: Theme Composition via composeAgGridTheme
When: Converting a GridVariant to an AG-Grid theme.
import { themeQuartz } from 'ag-grid-community'
export function composeAgGridTheme(variant: GridVariant) {
const { colors, density, typography } = variant
return themeQuartz.withParams({
// Core colors
backgroundColor: colors.background.base,
foregroundColor: colors.text.primary,
accentColor: colors.signal.accent,
// Header
headerBackgroundColor: colors.background.header,
headerTextColor: colors.text.secondary,
// Rows
oddRowBackgroundColor: colors.background.alternateRow,
rowHoverColor: `${colors.background.base}cc`,
selectedRowBackgroundColor: `${colors.signal.accent}15`,
// Typography
fontFamily: typography.fontFamily,
fontSize: density.fontSize,
headerFontSize: density.fontSizeXs,
// Density-driven spacing
rowHeight: density.rowHeight,
headerHeight: density.headerHeight,
cellHorizontalPaddingScale: density.paddingX / 8,
// Borders
borderColor: colors.border.primary,
wrapperBorderRadius: 0,
})
}
Canonical source: src/lib/data-grid/composer/theme-composer.ts
Pattern 8: Color Extraction Helpers
When: Extracting semantic colors from variant for custom UI.
export function extractStatusColors(variant: GridVariant) {
return {
active: variant.colors.signal.positive,
pending: variant.colors.signal.warning ?? variant.colors.signal.accent,
inactive: variant.colors.signal.neutral ?? variant.colors.text.muted,
error: variant.colors.signal.negative,
default: variant.colors.text.muted,
} as const
}
export function extractFlashConfig(variant: GridVariant) {
const flash = variant.colors.flash
return {
enabled: variant.behavior.microInteractions?.enableCellFlash ?? true,
upColor: flash.up,
downColor: flash.down,
durationMs: flash.durationMs,
}
}
Pattern 9: Legacy Basic Patterns
Theme Creation via themeQuartz (Direct)
import { themeQuartz } from 'ag-grid-community'
import { TMNL_TOKENS } from './data-grid-theme'
export const tmnlDataGridTheme = themeQuartz.withParams({
backgroundColor: TMNL_TOKENS.colors.background,
foregroundColor: TMNL_TOKENS.colors.text.primary,
borderColor: TMNL_TOKENS.colors.border,
headerBackgroundColor: TMNL_TOKENS.colors.surface,
// ... etc
})
Basic Cell Renderer (Non-Context)
const IdCellRenderer = (params: ICellRendererParams) => (
<span
style={{
color: TMNL_TOKENS.colors.text.muted,
fontSize: TMNL_TOKENS.typography.sizes.xs,
fontFamily: TMNL_TOKENS.typography.fontFamily,
letterSpacing: '0.05em',
}}
>
{params.value}
</span>
)
Column Definitions
const columnDefs: ColDef[] = [
{
field: 'id',
headerName: 'ID',
width: 80,
cellRenderer: IdCellRenderer,
sortable: true,
},
{
field: 'value',
headerName: 'Value',
width: 120,
cellRenderer: ValueCellRenderer,
comparator: (a, b) => a - b,
},
]
Anti-Patterns (BANNED)
1. Importing CSS Files
// BANNED - No CSS imports with v34
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-quartz.css'
// Use theme prop instead
2. Missing Module Registration
// BANNED - Grid will render blank
<AgGridReact rowData={data} columnDefs={cols} />
// Must register modules FIRST
3. useState for Grid Data
// BANNED when data crosses boundaries
const [rowData, setRowData] = useState([])
// Use Atom.make + service methods instead
4. Direct Theme Customization
// BANNED - Bypasses variant system
const theme = themeQuartz.withParams({ backgroundColor: '#123' })
// Use GridVariant + composeAgGridTheme() instead
5. Cell Renderer Without Context Fallback
// BANNED - Breaks outside grid context
function BadRenderer(params) {
const ctx = useDataGridContext() // Throws if no context!
return <span style={{ color: ctx.variant.colors.text.primary }}>...</span>
}
// CORRECT - Graceful fallback
function GoodRenderer(params) {
const ctx = useDataGridContextMaybe()
const color = ctx?.variant.colors.text.primary ?? COLORS.textPrimary
return <span style={{ color }}>...</span>
}
6. Creating Atoms Inside Components
// BANNED - Recreates on every render
function BadGrid() {
const rowDataAtom = Atom.make([]) // BAD!
return <AgGridReact ... />
}
// CORRECT - Module-level atoms
const rowDataAtom = Atom.make<RowData[]>([])
function GoodGrid() {
const rowData = useAtomValue(rowDataAtom)
return <AgGridReact rowData={rowData} ... />
}
Decision Tree: Which API to Use
Building a data grid?
│
├─ Simple, one-off grid?
│ └─ Use: Direct AgGridReact with composeAgGridTheme()
│
├─ Grid with header, status bar, controls?
│ └─ Use: Tmnl.DataGrid compound component
│
├─ Grid in tldraw shape?
│ └─ Use: V2 pattern (data-grid-shape-v2.tsx)
│
├─ Need real-time flash updates?
│ └─ Use: useFlashTracker + FlashValueRenderer
│
└─ Need custom variant?
└─ Clone tmnlDenseDark and modify
File Locations Summary
| Component | File | Purpose |
|---|---|---|
| Tmnl.DataGrid | src/lib/data-grid/components/UnifiedDataGrid.tsx |
Compound component |
| DataGridContext | src/lib/data-grid/components/DataGridContext.tsx |
Per-grid context |
| composeAgGridTheme | src/lib/data-grid/composer/theme-composer.ts |
Variant → theme |
| Flash system | src/lib/data-grid/flash/index.ts |
Cell highlighting |
| ValueCellRenderer | src/lib/data-grid/renderers/ValueCellRenderer.tsx |
Context-aware |
| tmnlDenseDark | src/lib/data-grid/variants/tmnl-dense-dark.ts |
Canonical variant |
| V2 tldraw shape | src/components/tldraw/shapes/data-grid-shape-v2.tsx |
Modern integration |
| Hybrid drag | src/components/tldraw/shapes/data-grid-shape.tsx:366-594 |
Grid-to-canvas |
Integration Points
- effect-atom-integration — Atom-as-State for rowData
- tmnl-design-tokens — Token fallbacks in renderers
- common-conventions — Barrel exports, naming patterns
- effect-patterns — Effect.Service for GridDragService