@traffical/react
v0.4.1
Published
Traffical SDK for React - Provider and hooks for parameter resolution
Maintainers
Readme
@traffical/react
React SDK for Traffical - a unified parameter decisioning platform for feature flags, A/B testing, and contextual bandits.
Installation
bun add @traffical/react
# or
npm install @traffical/reactQuick Start
1. Wrap your app with TrafficalProvider
import { TrafficalProvider } from '@traffical/react';
function App() {
return (
<TrafficalProvider
config={{
orgId: 'org_123',
projectId: 'proj_456',
env: 'production',
apiKey: 'pk_...',
}}
>
<MyComponent />
</TrafficalProvider>
);
}2. Use the useTraffical hook in your components
import { useTraffical } from '@traffical/react';
function MyComponent() {
const { params, ready, track } = useTraffical({
defaults: {
'ui.hero.title': 'Welcome',
'ui.hero.color': '#007bff',
},
});
const handleCTAClick = () => {
// Track a user event (decisionId is automatically bound)
track('cta_click', { button: 'hero' });
};
if (!ready) return <div>Loading...</div>;
return (
<h1 style={{ color: params['ui.hero.color'] }} onClick={handleCTAClick}>
{params['ui.hero.title']}
</h1>
);
}API Reference
TrafficalProvider
Initializes the Traffical client and provides it to child components.
<TrafficalProvider config={config}>
{children}
</TrafficalProvider>Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| config.orgId | string | Yes | Organization ID |
| config.projectId | string | Yes | Project ID |
| config.env | string | Yes | Environment (e.g., "production", "staging") |
| config.apiKey | string | Yes | API key for authentication |
| config.baseUrl | string | No | Base URL for the control plane API |
| config.localConfig | ConfigBundle | No | Local config bundle for offline fallback |
| config.refreshIntervalMs | number | No | Config refresh interval (default: 60000) |
| config.unitKeyFn | () => string | No | Function to get the unit key (user ID). If not provided, uses automatic stable ID |
| config.contextFn | () => Context | No | Function to get additional context |
| config.trackDecisions | boolean | No | Whether to track decision events (default: true) |
| config.decisionDeduplicationTtlMs | number | No | Decision dedup TTL (default: 1 hour) |
| config.exposureSessionTtlMs | number | No | Exposure dedup session TTL (default: 30 min) |
| config.plugins | TrafficalPlugin[] | No | Additional plugins to register |
| config.eventBatchSize | number | No | Max events before auto-flush (default: 10) |
| config.eventFlushIntervalMs | number | No | Auto-flush interval (default: 30000) |
| config.initialParams | Record<string, unknown> | No | Initial params from SSR |
useTraffical
Primary hook for parameter resolution and decision tracking.
const { params, decision, ready, error, trackExposure, track, flushEvents } = useTraffical(options);Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| defaults | T | Required | Default parameter values |
| context | Context | undefined | Additional context to merge |
| tracking | "full" \| "decision" \| "none" | "full" | Tracking mode |
Tracking Modes
| Mode | Decision Event | Exposure Event | Use Case |
|------|----------------|----------------|----------|
| "full" | Yes | Auto | Default. UI components that users see |
| "decision" | Yes | Manual | Manual exposure control (e.g., viewport tracking) |
| "none" | No | No | SSR, tests, internal logic |
Return Value
| Property | Type | Description |
|----------|------|-------------|
| params | T | Resolved parameter values |
| decision | DecisionResult \| null | Decision metadata (null when tracking="none") |
| ready | boolean | Whether the client is ready |
| error | Error \| null | Any initialization error |
| trackExposure | () => void | Manually track exposure (no-op when tracking="none") |
| track | (event: string, properties?: object) => void | Track event with bound decisionId. Buffers events until decision is ready, so no need to gate on decision in useEffect deps. No-op when tracking="none" |
| flushEvents | () => Promise<void> | Flush all pending events immediately |
Examples
// Full tracking (default) - decision + exposure events
const { params, decision, ready } = useTraffical({
defaults: { 'checkout.ctaText': 'Buy Now' },
});
// Decision tracking only - manual exposure control
const { params, decision, trackExposure } = useTraffical({
defaults: { 'checkout.ctaText': 'Buy Now' },
tracking: 'decision',
});
// Track exposure when element is visible
useEffect(() => {
if (isElementVisible && decision) {
trackExposure();
}
}, [isElementVisible, decision, trackExposure]);
// No tracking - for SSR, tests, or internal logic
const { params, ready } = useTraffical({
defaults: { 'ui.hero.title': 'Welcome' },
tracking: 'none',
});useTrafficalTrack
Hook to track user events for A/B testing and bandit optimization.
Tip: For most use cases, use the bound
trackfromuseTraffical()instead. It automatically includes thedecisionId. Use this standalone hook for advanced scenarios like cross-component event tracking or server-side tracking.
// Recommended: use bound track from useTraffical
const { params, track } = useTraffical({
defaults: { 'checkout.ctaText': 'Buy Now' },
});
const handlePurchase = (amount: number) => {
track('purchase', { value: amount, orderId: 'ord_123' });
};
// Advanced: standalone hook when you need to attribute to a specific decision
const standaloneTrack = useTrafficalTrack();
standaloneTrack('purchase', { value: amount }, { decisionId: someOtherDecision.decisionId });useTrafficalReward (deprecated)
Deprecated: Use
useTrafficalTrack()instead.
Hook to track rewards for A/B testing and bandit optimization.
useTrafficalClient
Hook to access the Traffical client directly.
const { client, ready, error } = useTrafficalClient();
if (ready && client) {
const version = client.getConfigVersion();
const stableId = client.getStableId();
}useTrafficalPlugin
Hook to access a registered plugin by name.
import { createDOMBindingPlugin, DOMBindingPlugin } from '@traffical/react';
// In your provider config:
// plugins: [createDOMBindingPlugin()]
// In a component:
const domPlugin = useTrafficalPlugin<DOMBindingPlugin>('dom-binding');
useEffect(() => {
domPlugin?.applyBindings();
}, [contentLoaded, domPlugin]);Best Practices
Traffical React SDK — Usage Patterns
Mental Model
Traffical is parameter-first. You define parameters with defaults, and Traffical handles the rest—whether that's a static value, an A/B test, or an adaptive optimization. Your code doesn't need to know which.
┌─────────────────────────────────────────────────────────────────────┐
│ Your Code │
│ │
│ 1. Define parameters with defaults │
│ 2. Use the resolved values │
│ 3. Track rewards on conversion │
└─────────────────────────────────────────────────────────────────────┘
▲
│ (hidden from you)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Traffical │
│ │
│ • Layers & policies for mutual exclusivity │
│ • Bucket assignment & deterministic hashing │
│ • Thompson Sampling & contextual bandits │
│ • Statistical analysis & optimization │
└─────────────────────────────────────────────────────────────────────┘Key insight: Resolution is local and synchronous. The SDK fetches a config bundle once and caches it. Every useTraffical() call resolves instantly from cache—no network latency, no render flicker on page navigation.
Quick Start
import { useTraffical } from "@traffical/react";
function ProductPage() {
const { params, track } = useTraffical({
defaults: {
"ui.cta.text": "Buy Now",
"ui.cta.color": "#2563eb",
"pricing.showDiscount": true,
},
});
const handlePurchase = (amount: number) => {
// track has the decisionId already bound!
track("purchase", { value: amount, itemId: "prod_123" });
};
return (
<button
style={{ backgroundColor: params["ui.cta.color"] }}
onClick={() => handlePurchase(99.99)}
>
{params["ui.cta.text"]}
</button>
);
}That's it. Default tracking is enabled automatically, and track knows which decision to attribute conversions to.
API Reference
useTraffical(options)
The primary hook for parameter resolution and experiment tracking.
const { params, decision, ready, error, trackExposure, track } = useTraffical({
defaults: { /* parameter defaults */ },
context: { /* optional additional context */ },
tracking: "full" | "decision" | "none", // default: "full"
});| Option | Type | Default | Description |
|--------|------|---------|-------------|
| defaults | Record<string, ParameterValue> | required | Default values for each parameter |
| context | Record<string, unknown> | {} | Additional context for targeting |
| tracking | "full" | "decision" | "none" | "full" | Controls event tracking behavior |
Tracking Modes:
| Mode | Decision Event | Exposure Event | Use Case |
|------|---------------|----------------|----------|
| "full" | ✅ Auto | ✅ Auto | UI shown to users (default) |
| "decision" | ✅ Auto | 🔧 Manual | Below-the-fold, lazy-loaded content |
| "none" | ❌ No | ❌ No | SSR, internal logic, tests |
Use Cases
1. Feature Flag
Control feature rollout without redeploying.
function Dashboard() {
const { params } = useTraffical({
defaults: {
"feature.newAnalytics": false,
},
});
if (params["feature.newAnalytics"]) {
return <NewAnalyticsDashboard />;
}
return <LegacyDashboard />;
}2. A/B Test with Conversion Tracking
Test different variants and measure which performs better.
function PricingPage() {
const { params, track } = useTraffical({
defaults: {
"pricing.headline": "Simple, transparent pricing",
"pricing.showAnnualToggle": false,
"pricing.highlightPlan": "pro",
},
});
const handleSubscribe = (plan: string, amount: number) => {
// decisionId is automatically bound
track("subscription", { value: amount, plan });
};
return (
<div>
<h1>{params["pricing.headline"]}</h1>
<PricingCards
showAnnualToggle={params["pricing.showAnnualToggle"]}
highlightPlan={params["pricing.highlightPlan"]}
onSubscribe={handleSubscribe}
/>
</div>
);
}3. Dynamic UI Configuration
Adjust colors, copy, and layout without code changes.
function HeroBanner() {
const { params } = useTraffical({
defaults: {
"ui.hero.title": "Welcome to Our Platform",
"ui.hero.subtitle": "The best solution for your needs",
"ui.hero.ctaText": "Get Started",
"ui.hero.ctaColor": "#3b82f6",
"ui.hero.layout": "centered",
},
});
return (
<section className={`hero-${params["ui.hero.layout"]}`}>
<h1>{params["ui.hero.title"]}</h1>
<p>{params["ui.hero.subtitle"]}</p>
<button style={{ backgroundColor: params["ui.hero.ctaColor"] }}>
{params["ui.hero.ctaText"]}
</button>
</section>
);
}4. Below-the-Fold Content (Manual Exposure)
Track exposure only when content is actually viewed.
function ProductRecommendations() {
const { params, trackExposure } = useTraffical({
defaults: {
"recommendations.algorithm": "collaborative",
"recommendations.count": 4,
},
tracking: "decision", // Decision tracked, exposure manual
});
const ref = useRef<HTMLDivElement>(null);
// Track exposure when section scrolls into view
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
trackExposure();
observer.disconnect();
}
},
{ threshold: 0.5 }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [trackExposure]);
return (
<section ref={ref}>
<RecommendationGrid
algorithm={params["recommendations.algorithm"]}
count={params["recommendations.count"]}
/>
</section>
);
}5. Server-Side Rendering (No Tracking)
Use defaults during SSR, hydrate on client.
// Server Component (Next.js App Router)
async function ProductPage({ productId }: { productId: string }) {
// Server: use defaults directly (no SDK call)
const defaultPrice = 299.99;
return (
<TrafficalProvider>
<ProductDetails productId={productId} defaultPrice={defaultPrice} />
</TrafficalProvider>
);
}
// Client Component
"use client";
function ProductDetails({ productId, defaultPrice }: Props) {
const { params, ready } = useTraffical({
defaults: {
"pricing.basePrice": defaultPrice,
"pricing.discount": 0,
},
});
// Shows defaultPrice immediately, updates when SDK ready
const price = params["pricing.basePrice"] * (1 - params["pricing.discount"] / 100);
return <Price value={price} loading={!ready} />;
}6. Component with Self-Contained Parameters
Reusable component that owns its experiment surface.
function CheckoutButton({ onCheckout }: { onCheckout: () => void }) {
const { params } = useTraffical({
defaults: {
"checkout.button.text": "Complete Purchase",
"checkout.button.color": "#22c55e",
"checkout.button.showIcon": true,
},
});
return (
<button
onClick={onCheckout}
style={{ backgroundColor: params["checkout.button.color"] }}
>
{params["checkout.button.showIcon"] && <ShoppingCartIcon />}
{params["checkout.button.text"]}
</button>
);
}7. Multiple Event Types
Track different conversion events for the same decision.
function CheckoutFlow() {
const { params, track } = useTraffical({
defaults: {
"checkout.showExpressOption": true,
"checkout.showUpsells": false,
},
});
const handleAddUpsell = () => {
track("upsell_accept", { upsellId: "premium" });
};
const handleComplete = (orderValue: number) => {
track("checkout_complete", { value: orderValue });
};
return (
<div>
{params["checkout.showExpressOption"] && <ExpressCheckout />}
{params["checkout.showUpsells"] && (
<UpsellSection onAccept={handleAddUpsell} />
)}
<CheckoutForm onComplete={handleComplete} />
</div>
);
}8. Flushing Events Before Navigation
Ensure critical conversion events are sent before page navigation.
function CheckoutPage() {
const router = useRouter();
const { params, track, flushEvents } = useTraffical({
defaults: {
"checkout.ctaText": "Complete Purchase",
},
});
const handlePurchase = async (total: number) => {
// Track the purchase event
track("purchase", { value: total, orderId: "ord_123" });
// Flush events immediately to ensure they're sent before navigation
await flushEvents();
// Now safe to navigate away
router.replace("/checkout/success");
};
return (
<button onClick={() => handlePurchase(99.99)}>
{params["checkout.ctaText"]}
</button>
);
}Changing User Identity
Use client.identify() to switch user identity mid-session (e.g., after login or logout). The provider automatically re-renders all useTraffical hooks with new assignments.
import { useTrafficalClient } from '@traffical/react';
function LoginButton() {
const { client } = useTrafficalClient();
const handleLogin = async () => {
const user = await loginApi();
// All useTraffical hooks re-evaluate with the new identity
client?.identify(user.id);
};
return <button onClick={handleLogin}>Log In</button>;
}This also works from Traffical DevTools — changing the user ID in DevTools calls identify() under the hood, causing the UI to update in real time.
Architecture Patterns
Pattern A: Page-Level Parameters (Recommended for Simple Pages)
All parameters defined at page level, passed as props to children.
function ProductPage() {
const { params, decision } = useTraffical({
defaults: {
"product.showReviews": true,
"product.showRelated": true,
"pricing.discount": 0,
"ui.ctaColor": "#2563eb",
},
});
return (
<>
<ProductDetails
showReviews={params["product.showReviews"]}
ctaColor={params["ui.ctaColor"]}
/>
<PricingSection discount={params["pricing.discount"]} />
{params["product.showRelated"] && <RelatedProducts />}
</>
);
}Pros: Single decision for attribution, clear data flow, testable components Cons: Prop drilling, parent knows about all params
Pattern B: Component-Level Parameters (Recommended for Reusable Components)
Each component owns its parameters.
// ProductDetails owns its params
function ProductDetails() {
const { params } = useTraffical({
defaults: {
"product.showReviews": true,
"product.imageSize": "large",
},
});
// ...
}
// PricingSection owns its params
function PricingSection() {
const { params } = useTraffical({
defaults: {
"pricing.discount": 0,
"pricing.showOriginal": true,
},
});
// ...
}Pros: Encapsulated, portable, self-documenting Cons: Multiple decisions (handled via deduplication)
Pattern C: Context + Pure Components (Recommended for Complex Pages)
Single decision distributed via context, pure components for rendering.
// Context provider with all params
function ProductPageProvider({ children }) {
const traffical = useTraffical({
defaults: {
"product.showReviews": true,
"pricing.discount": 0,
"ui.ctaColor": "#2563eb",
},
});
return (
<ProductPageContext.Provider value={traffical}>
{children}
</ProductPageContext.Provider>
);
}
// Pure component, testable without Traffical
function PricingSection({ discount, showOriginal }: Props) {
// Pure rendering logic
}
// Wrapper that connects to Traffical
function ConnectedPricingSection() {
const { params } = useProductPageContext();
return (
<PricingSection
discount={params["pricing.discount"]}
showOriginal={params["pricing.showOriginal"]}
/>
);
}Pros: Single decision, no prop drilling, testable leaf components Cons: More boilerplate
Best Practices
1. Always Provide Sensible Defaults
Defaults are used when:
- No experiment is running
- User doesn't match targeting conditions
- SDK is still loading
// ✅ Good: Works without any experiment
const { params } = useTraffical({
defaults: {
"pricing.discount": 0,
"ui.buttonColor": "#3b82f6",
},
});
// ❌ Bad: Undefined behavior without experiment
const { params } = useTraffical({
defaults: {
"pricing.discount": undefined, // What does this mean?
},
});2. Group Related Parameters
Parameters that should vary together belong in the same useTraffical() call.
// ✅ Good: Related params together
const { params } = useTraffical({
defaults: {
"pricing.basePrice": 299,
"pricing.discount": 0,
"pricing.showOriginal": true,
},
});
// ⚠️ Caution: Separate calls = separate decisions
const pricing = useTraffical({ defaults: { "pricing.basePrice": 299 } });
const discount = useTraffical({ defaults: { "pricing.discount": 0 } });3. Track Events at Conversion Points
Events enable Traffical to learn which variants perform best. Use the bound track from useTraffical() — it automatically includes the decisionId.
const { params, track } = useTraffical({
defaults: { "checkout.showUpsells": false },
});
// ✅ Track meaningful conversions
const handlePurchase = (amount: number) => {
track("purchase", { value: amount, orderId: "ord_123" });
};
// ✅ Track micro-conversions too
const handleAddToCart = () => {
track("add_to_cart", { itemId: "sku_456" });
};Tracking in useEffect: The track function reads the current decision from a ref internally and buffers events if the decision isn't ready yet. You do not need decision in your dependency array:
const { params, ready, track } = useTraffical({
defaults: { "pdp.layout": "default" },
});
// ✅ Good: only depend on ready + the data you care about
useEffect(() => {
if (ready) {
track("page_view", { productId });
}
}, [ready, productId, track]);
// ❌ Avoid: decision in deps can cause duplicate events
useEffect(() => {
if (ready && decision) {
track("page_view", { productId });
}
}, [ready, decision, productId, track]);4. Use Consistent Naming Conventions
category.subcategory.name
feature.* → Feature flags (boolean)
ui.* → Visual variations (string, number)
pricing.* → Pricing experiments (number)
copy.* → Copywriting tests (string)
experiment.* → Explicit variants (string)5. Handle Loading State
const { params, ready } = useTraffical({
defaults: { "ui.heroVariant": "default" },
});
// Option A: Show defaults immediately (recommended)
// On page navigation, resolved values render immediately (no flicker)
return <Hero variant={params["ui.heroVariant"]} />;
// Option B: Show loading state (only for initial page load if needed)
if (!ready) return <HeroSkeleton />;
return <Hero variant={params["ui.heroVariant"]} />;Note: On client-side navigation (e.g., Next.js Link), params resolve synchronously—no loading state or flicker. Loading states are only relevant during the initial bundle fetch.
Flicker-Free SSR (Next.js App Router)
The classic A/B testing problem: users briefly see the default content before it switches to their assigned variant. This section shows how to eliminate that flicker entirely.
The Problem
Without special handling, here's what happens:
- Server renders with defaults (no userId during SSR)
- Client hydrates with defaults
- SDK fetches config bundle
- SDK resolves with userId → content changes (FLICKER!)
The Solution: Cookie-Based SSR + LocalConfig
By passing the userId from server to client via cookies AND embedding the config bundle at build time, resolution can happen synchronously on both server and client.
Step 1: Middleware to Set UserId Cookie
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const COOKIE_NAME = 'traffical-userId';
const HEADER_NAME = 'x-traffical-userId';
function generateUserId(): string {
const array = new Uint8Array(6);
crypto.getRandomValues(array);
return `user_${Array.from(array, b => b.toString(16).padStart(2, '0')).join('')}`;
}
export function middleware(request: NextRequest) {
const existingUserId = request.cookies.get(COOKIE_NAME)?.value;
const userId = existingUserId || generateUserId();
// Pass userId via header for THIS request (cookie isn't available yet on first request)
const requestHeaders = new Headers(request.headers);
requestHeaders.set(HEADER_NAME, userId);
const response = NextResponse.next({ request: { headers: requestHeaders } });
// Set cookie for NEXT request
if (!existingUserId) {
response.cookies.set(COOKIE_NAME, userId, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
path: '/',
});
}
return response;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*$).*)'],
};Step 2: Server Layout Reads UserId
// app/layout.tsx
import { cookies, headers } from 'next/headers';
export default async function RootLayout({ children }) {
const headerStore = await headers();
const cookieStore = await cookies();
// Header for first request, cookie for subsequent
const userId = headerStore.get('x-traffical-userId') ||
cookieStore.get('traffical-userId')?.value || '';
return (
<html>
<body>
<Providers initialUserId={userId}>
{children}
</Providers>
</body>
</html>
);
}Step 3: Pass UserId Through Context
// context/Providers.tsx
'use client';
export function Providers({ children, initialUserId }) {
return (
<DemoProvider initialUserId={initialUserId}>
<TrafficalWrapper>
{children}
</TrafficalWrapper>
</DemoProvider>
);
}
// context/DemoContext.tsx
export function DemoProvider({ children, initialUserId }) {
const [userId] = useState(initialUserId || '');
// Use userId as initial state - NOT in useEffect
// ...
}Step 4: Provide LocalConfig to SDK
Fetch the config bundle at build time and pass it to the provider:
// lib/traffical.ts
import configBundle from '@/data/config-bundle.json';
export const trafficalConfig = {
orgId: process.env.NEXT_PUBLIC_TRAFFICAL_ORG_ID,
projectId: process.env.NEXT_PUBLIC_TRAFFICAL_PROJECT_ID,
apiKey: process.env.NEXT_PUBLIC_TRAFFICAL_API_KEY,
// This is the key to flicker-free SSR!
localConfig: configBundle as ConfigBundle,
};Step 5: TrafficalWrapper Uses UserId
// context/TrafficalWrapper.tsx
export function TrafficalWrapper({ children }) {
const { userId } = useDemoContext();
const config = useMemo(() => ({
...trafficalConfig,
unitKeyFn: () => userId, // Returns the server-provided userId
}), [userId]);
return (
<TrafficalProvider config={config}>
{children}
</TrafficalProvider>
);
}How It Works
Request Flow (First Visit):
─────────────────────────────────────────────────────────────────
1. Request arrives (no cookie)
2. Middleware generates userId → sets HEADER + COOKIE
3. Server layout reads userId from HEADER
4. Server passes userId to React via props
5. useTraffical's useState resolves from localConfig + userId
6. Server renders HTML with CORRECT variant
7. Response sent with Set-Cookie header
8. Client hydrates with SAME userId → NO FLICKER ✅
─────────────────────────────────────────────────────────────────
Subsequent Requests:
─────────────────────────────────────────────────────────────────
1. Request arrives WITH cookie
2. Middleware passes existing userId via header
3. Same flow as above → consistent experience
─────────────────────────────────────────────────────────────────Requirements
| Requirement | Why |
|-------------|-----|
| localConfig | Enables synchronous resolution without waiting for network |
| UserId in cookies | Server can read it during SSR |
| UserId via header on first request | Cookie isn't in request until second request |
| UserId as initial state (not useEffect) | Prevents hydration mismatch |
What This Solves
- ✅ First page load - No flicker, correct variant from the start
- ✅ Client-side navigation - Already worked (bundle cached)
- ✅ Page refresh - UserId persisted in cookie
- ✅ New users - UserId generated on first request
FAQ
Q: Do multiple useTraffical() calls cause multiple network requests?
No. The SDK fetches the config bundle once and caches it. All resolution happens locally.
Q: What happens if the SDK fails to load?
Defaults are returned. Your app works normally, just without experiment variations.
Q: Should I use tracking: "none" for SSR?
Yes, if you're calling useTraffical in a server context. On the client, use the default "full" tracking.
Q: Can I change parameter values from the dashboard without deploying?
Yes! That's the point. Parameters are resolved from Traffical's config bundle, which updates independently of your code.
Type-Safe Event Tracking
The useTraffical hook supports a TEvents generic that enforces event names and property shapes at compile time. Combined with @traffical/cli generate-types, you get full type safety on every track() call.
1. Generate types from your config
bunx @traffical/cli generate-types
# → creates .traffical/traffical.generated.tsThis generates interfaces for each event's properties and a TrafficalEventProperties map:
// traffical.generated.ts (auto-generated, do not edit)
export interface PurchaseProperties {
order_total: number;
payment_method: "visa" | "mastercard" | "paypal";
item_count?: number;
}
export interface TrafficalEventProperties {
"purchase": PurchaseProperties;
"add_to_cart": AddToCartProperties;
// ...
}2. Create a typed wrapper
// lib/traffical.ts
import type { TrafficalEventProperties } from './traffical.generated';
import { useTraffical as useTrafficalBase, type UseTrafficalOptions, type ParameterValue } from '@traffical/react';
// Strict track — only allows events and properties defined in the schema
export type TrafficalTrack = <E extends Extract<keyof TrafficalEventProperties, string>>(
event: E,
properties?: TrafficalEventProperties[E],
options?: { decisionId?: string; unitKey?: string }
) => void;
export function useTraffical<T extends Record<string, ParameterValue>>(
options: UseTrafficalOptions<T>
) {
const result = useTrafficalBase<T>(options);
return { ...result, track: result.track as unknown as TrafficalTrack };
}3. Use it — TypeScript catches mistakes
import { useTraffical } from '@/lib/traffical';
function CheckoutPage() {
const { params, track } = useTraffical({
defaults: { 'checkout.ctaText': 'Buy Now' },
});
track('purchase', {
order_total: 99.99,
payment_method: 'visa',
}); // ✅ compiles
track('purchase', {
order_total: 99.99,
payment_method: 'bitcoin', // ❌ Type error: not in "visa" | "mastercard" | "paypal"
});
track('purchase', {
order_total: 99.99,
payment_method: 'visa',
random_field: true, // ❌ Type error: not in PurchaseProperties
});
track('nonexistent_event'); // ❌ Type error: not in TrafficalEventProperties
}Typing component props
Use TrafficalTrack when passing track as a prop to child components:
import type { TrafficalTrack } from '@/lib/traffical';
interface ProductCardProps {
product: Product;
track?: TrafficalTrack;
}
function ProductCard({ product, track }: ProductCardProps) {
const handleAdd = () => {
track?.('add_to_cart', {
product_id: product.id,
quantity: 1,
});
};
// ...
}Migration from Deprecated Hooks
The useTrafficalParams and useTrafficalDecision hooks are deprecated but still available for backward compatibility.
useTrafficalParams → useTraffical
// Before (deprecated)
const { params, ready } = useTrafficalParams({
defaults: { 'ui.hero.title': 'Welcome' },
});
// After
const { params, ready } = useTraffical({
defaults: { 'ui.hero.title': 'Welcome' },
tracking: 'none',
});useTrafficalDecision → useTraffical
// Before (deprecated) - auto exposure
const { params, decision } = useTrafficalDecision({
defaults: { 'checkout.ctaText': 'Buy Now' },
});
// After
const { params, decision } = useTraffical({
defaults: { 'checkout.ctaText': 'Buy Now' },
});
// Before (deprecated) - manual exposure
const { params, trackExposure } = useTrafficalDecision({
defaults: { 'checkout.ctaText': 'Buy Now' },
trackExposure: false,
});
// After
const { params, trackExposure } = useTraffical({
defaults: { 'checkout.ctaText': 'Buy Now' },
tracking: 'decision',
});