@traffical/react 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1011 @@
1
+ # @traffical/react
2
+
3
+ React SDK for Traffical - a unified parameter decisioning platform for feature flags, A/B testing, and contextual bandits.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @traffical/react
9
+ # or
10
+ npm install @traffical/react
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Wrap your app with TrafficalProvider
16
+
17
+ ```tsx
18
+ import { TrafficalProvider } from '@traffical/react';
19
+
20
+ function App() {
21
+ return (
22
+ <TrafficalProvider
23
+ config={{
24
+ orgId: 'org_123',
25
+ projectId: 'proj_456',
26
+ env: 'production',
27
+ apiKey: 'pk_...',
28
+ }}
29
+ >
30
+ <MyComponent />
31
+ </TrafficalProvider>
32
+ );
33
+ }
34
+ ```
35
+
36
+ ### 2. Use the `useTraffical` hook in your components
37
+
38
+ ```tsx
39
+ import { useTraffical } from '@traffical/react';
40
+
41
+ function MyComponent() {
42
+ const { params, ready, track } = useTraffical({
43
+ defaults: {
44
+ 'ui.hero.title': 'Welcome',
45
+ 'ui.hero.color': '#007bff',
46
+ },
47
+ });
48
+
49
+ const handleCTAClick = () => {
50
+ // Track a user event (decisionId is automatically bound)
51
+ track('cta_click', { button: 'hero' });
52
+ };
53
+
54
+ if (!ready) return <div>Loading...</div>;
55
+
56
+ return (
57
+ <h1 style={{ color: params['ui.hero.color'] }} onClick={handleCTAClick}>
58
+ {params['ui.hero.title']}
59
+ </h1>
60
+ );
61
+ }
62
+ ```
63
+
64
+ ## API Reference
65
+
66
+ ### TrafficalProvider
67
+
68
+ Initializes the Traffical client and provides it to child components.
69
+
70
+ ```tsx
71
+ <TrafficalProvider config={config}>
72
+ {children}
73
+ </TrafficalProvider>
74
+ ```
75
+
76
+ #### Props
77
+
78
+ | Prop | Type | Required | Description |
79
+ |------|------|----------|-------------|
80
+ | `config.orgId` | `string` | Yes | Organization ID |
81
+ | `config.projectId` | `string` | Yes | Project ID |
82
+ | `config.env` | `string` | Yes | Environment (e.g., "production", "staging") |
83
+ | `config.apiKey` | `string` | Yes | API key for authentication |
84
+ | `config.baseUrl` | `string` | No | Base URL for the control plane API |
85
+ | `config.localConfig` | `ConfigBundle` | No | Local config bundle for offline fallback |
86
+ | `config.refreshIntervalMs` | `number` | No | Config refresh interval (default: 60000) |
87
+ | `config.unitKeyFn` | `() => string` | No | Function to get the unit key (user ID). If not provided, uses automatic stable ID |
88
+ | `config.contextFn` | `() => Context` | No | Function to get additional context |
89
+ | `config.trackDecisions` | `boolean` | No | Whether to track decision events (default: true) |
90
+ | `config.decisionDeduplicationTtlMs` | `number` | No | Decision dedup TTL (default: 1 hour) |
91
+ | `config.exposureSessionTtlMs` | `number` | No | Exposure dedup session TTL (default: 30 min) |
92
+ | `config.plugins` | `TrafficalPlugin[]` | No | Additional plugins to register |
93
+ | `config.eventBatchSize` | `number` | No | Max events before auto-flush (default: 10) |
94
+ | `config.eventFlushIntervalMs` | `number` | No | Auto-flush interval (default: 30000) |
95
+ | `config.initialParams` | `Record<string, unknown>` | No | Initial params from SSR |
96
+
97
+ ---
98
+
99
+ ### useTraffical
100
+
101
+ Primary hook for parameter resolution and decision tracking.
102
+
103
+ ```tsx
104
+ const { params, decision, ready, error, trackExposure, track } = useTraffical(options);
105
+ ```
106
+
107
+ #### Options
108
+
109
+ | Option | Type | Default | Description |
110
+ |--------|------|---------|-------------|
111
+ | `defaults` | `T` | Required | Default parameter values |
112
+ | `context` | `Context` | `undefined` | Additional context to merge |
113
+ | `tracking` | `"full" \| "decision" \| "none"` | `"full"` | Tracking mode |
114
+
115
+ #### Tracking Modes
116
+
117
+ | Mode | Decision Event | Exposure Event | Use Case |
118
+ |------|----------------|----------------|----------|
119
+ | `"full"` | Yes | Auto | Default. UI components that users see |
120
+ | `"decision"` | Yes | Manual | Manual exposure control (e.g., viewport tracking) |
121
+ | `"none"` | No | No | SSR, tests, internal logic |
122
+
123
+ #### Return Value
124
+
125
+ | Property | Type | Description |
126
+ |----------|------|-------------|
127
+ | `params` | `T` | Resolved parameter values |
128
+ | `decision` | `DecisionResult \| null` | Decision metadata (null when `tracking="none"`) |
129
+ | `ready` | `boolean` | Whether the client is ready |
130
+ | `error` | `Error \| null` | Any initialization error |
131
+ | `trackExposure` | `() => void` | Manually track exposure (no-op when `tracking="none"`) |
132
+ | `track` | `(event: string, properties?: object) => void` | Track event with bound decisionId (no-op when `tracking="none"`) |
133
+
134
+ #### Examples
135
+
136
+ ```tsx
137
+ // Full tracking (default) - decision + exposure events
138
+ const { params, decision, ready } = useTraffical({
139
+ defaults: { 'checkout.ctaText': 'Buy Now' },
140
+ });
141
+
142
+ // Decision tracking only - manual exposure control
143
+ const { params, decision, trackExposure } = useTraffical({
144
+ defaults: { 'checkout.ctaText': 'Buy Now' },
145
+ tracking: 'decision',
146
+ });
147
+
148
+ // Track exposure when element is visible
149
+ useEffect(() => {
150
+ if (isElementVisible && decision) {
151
+ trackExposure();
152
+ }
153
+ }, [isElementVisible, decision, trackExposure]);
154
+
155
+ // No tracking - for SSR, tests, or internal logic
156
+ const { params, ready } = useTraffical({
157
+ defaults: { 'ui.hero.title': 'Welcome' },
158
+ tracking: 'none',
159
+ });
160
+ ```
161
+
162
+ ---
163
+
164
+ ### useTrafficalTrack
165
+
166
+ Hook to track user events for A/B testing and bandit optimization.
167
+
168
+ > **Tip:** For most use cases, use the bound `track` from `useTraffical()` instead. It automatically includes the `decisionId`. Use this standalone hook for advanced scenarios like cross-component event tracking or server-side tracking.
169
+
170
+ ```tsx
171
+ // Recommended: use bound track from useTraffical
172
+ const { params, track } = useTraffical({
173
+ defaults: { 'checkout.ctaText': 'Buy Now' },
174
+ });
175
+
176
+ const handlePurchase = (amount: number) => {
177
+ track('purchase', { value: amount, orderId: 'ord_123' });
178
+ };
179
+
180
+ // Advanced: standalone hook when you need to attribute to a specific decision
181
+ const standaloneTrack = useTrafficalTrack();
182
+
183
+ standaloneTrack('purchase', { value: amount }, { decisionId: someOtherDecision.decisionId });
184
+ ```
185
+
186
+ ### useTrafficalReward (deprecated)
187
+
188
+ > **Deprecated:** Use `useTrafficalTrack()` instead.
189
+
190
+ Hook to track rewards for A/B testing and bandit optimization.
191
+
192
+ ---
193
+
194
+ ### useTrafficalClient
195
+
196
+ Hook to access the Traffical client directly.
197
+
198
+ ```tsx
199
+ const { client, ready, error } = useTrafficalClient();
200
+
201
+ if (ready && client) {
202
+ const version = client.getConfigVersion();
203
+ const stableId = client.getStableId();
204
+ }
205
+ ```
206
+
207
+ ---
208
+
209
+ ### useTrafficalPlugin
210
+
211
+ Hook to access a registered plugin by name.
212
+
213
+ ```tsx
214
+ import { createDOMBindingPlugin, DOMBindingPlugin } from '@traffical/react';
215
+
216
+ // In your provider config:
217
+ // plugins: [createDOMBindingPlugin()]
218
+
219
+ // In a component:
220
+ const domPlugin = useTrafficalPlugin<DOMBindingPlugin>('dom-binding');
221
+
222
+ useEffect(() => {
223
+ domPlugin?.applyBindings();
224
+ }, [contentLoaded, domPlugin]);
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Best Practices
230
+
231
+ # Traffical React SDK — Usage Patterns
232
+
233
+ ## Mental Model
234
+
235
+ 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.
236
+
237
+ ```
238
+ ┌─────────────────────────────────────────────────────────────────────┐
239
+ │ Your Code │
240
+ │ │
241
+ │ 1. Define parameters with defaults │
242
+ │ 2. Use the resolved values │
243
+ │ 3. Track rewards on conversion │
244
+ └─────────────────────────────────────────────────────────────────────┘
245
+
246
+ │ (hidden from you)
247
+
248
+ ┌─────────────────────────────────────────────────────────────────────┐
249
+ │ Traffical │
250
+ │ │
251
+ │ • Layers & policies for mutual exclusivity │
252
+ │ • Bucket assignment & deterministic hashing │
253
+ │ • Thompson Sampling & contextual bandits │
254
+ │ • Statistical analysis & optimization │
255
+ └─────────────────────────────────────────────────────────────────────┘
256
+ ```
257
+
258
+ **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.
259
+
260
+ ---
261
+
262
+ ## Quick Start
263
+
264
+ ```tsx
265
+ import { useTraffical } from "@traffical/react";
266
+
267
+ function ProductPage() {
268
+ const { params, track } = useTraffical({
269
+ defaults: {
270
+ "ui.cta.text": "Buy Now",
271
+ "ui.cta.color": "#2563eb",
272
+ "pricing.showDiscount": true,
273
+ },
274
+ });
275
+
276
+ const handlePurchase = (amount: number) => {
277
+ // track has the decisionId already bound!
278
+ track("purchase", { value: amount, itemId: "prod_123" });
279
+ };
280
+
281
+ return (
282
+ <button
283
+ style={{ backgroundColor: params["ui.cta.color"] }}
284
+ onClick={() => handlePurchase(99.99)}
285
+ >
286
+ {params["ui.cta.text"]}
287
+ </button>
288
+ );
289
+ }
290
+ ```
291
+
292
+ That's it. Default tracking is enabled automatically, and `track` knows which decision to attribute conversions to.
293
+
294
+ ---
295
+
296
+ ## API Reference
297
+
298
+ ### `useTraffical(options)`
299
+
300
+ The primary hook for parameter resolution and experiment tracking.
301
+
302
+ ```tsx
303
+ const { params, decision, ready, error, trackExposure, track } = useTraffical({
304
+ defaults: { /* parameter defaults */ },
305
+ context: { /* optional additional context */ },
306
+ tracking: "full" | "decision" | "none", // default: "full"
307
+ });
308
+ ```
309
+
310
+ | Option | Type | Default | Description |
311
+ |--------|------|---------|-------------|
312
+ | `defaults` | `Record<string, ParameterValue>` | *required* | Default values for each parameter |
313
+ | `context` | `Record<string, unknown>` | `{}` | Additional context for targeting |
314
+ | `tracking` | `"full"` \| `"decision"` \| `"none"` | `"full"` | Controls event tracking behavior |
315
+
316
+ **Tracking Modes:**
317
+
318
+ | Mode | Decision Event | Exposure Event | Use Case |
319
+ |------|---------------|----------------|----------|
320
+ | `"full"` | ✅ Auto | ✅ Auto | UI shown to users (default) |
321
+ | `"decision"` | ✅ Auto | 🔧 Manual | Below-the-fold, lazy-loaded content |
322
+ | `"none"` | ❌ No | ❌ No | SSR, internal logic, tests |
323
+
324
+ ---
325
+
326
+ ## Use Cases
327
+
328
+ ### 1. Feature Flag
329
+
330
+ Control feature rollout without redeploying.
331
+
332
+ ```tsx
333
+ function Dashboard() {
334
+ const { params } = useTraffical({
335
+ defaults: {
336
+ "feature.newAnalytics": false,
337
+ },
338
+ });
339
+
340
+ if (params["feature.newAnalytics"]) {
341
+ return <NewAnalyticsDashboard />;
342
+ }
343
+ return <LegacyDashboard />;
344
+ }
345
+ ```
346
+
347
+ ### 2. A/B Test with Conversion Tracking
348
+
349
+ Test different variants and measure which performs better.
350
+
351
+ ```tsx
352
+ function PricingPage() {
353
+ const { params, track } = useTraffical({
354
+ defaults: {
355
+ "pricing.headline": "Simple, transparent pricing",
356
+ "pricing.showAnnualToggle": false,
357
+ "pricing.highlightPlan": "pro",
358
+ },
359
+ });
360
+
361
+ const handleSubscribe = (plan: string, amount: number) => {
362
+ // decisionId is automatically bound
363
+ track("subscription", { value: amount, plan });
364
+ };
365
+
366
+ return (
367
+ <div>
368
+ <h1>{params["pricing.headline"]}</h1>
369
+ <PricingCards
370
+ showAnnualToggle={params["pricing.showAnnualToggle"]}
371
+ highlightPlan={params["pricing.highlightPlan"]}
372
+ onSubscribe={handleSubscribe}
373
+ />
374
+ </div>
375
+ );
376
+ }
377
+ ```
378
+
379
+ ### 3. Dynamic UI Configuration
380
+
381
+ Adjust colors, copy, and layout without code changes.
382
+
383
+ ```tsx
384
+ function HeroBanner() {
385
+ const { params } = useTraffical({
386
+ defaults: {
387
+ "ui.hero.title": "Welcome to Our Platform",
388
+ "ui.hero.subtitle": "The best solution for your needs",
389
+ "ui.hero.ctaText": "Get Started",
390
+ "ui.hero.ctaColor": "#3b82f6",
391
+ "ui.hero.layout": "centered",
392
+ },
393
+ });
394
+
395
+ return (
396
+ <section className={`hero-${params["ui.hero.layout"]}`}>
397
+ <h1>{params["ui.hero.title"]}</h1>
398
+ <p>{params["ui.hero.subtitle"]}</p>
399
+ <button style={{ backgroundColor: params["ui.hero.ctaColor"] }}>
400
+ {params["ui.hero.ctaText"]}
401
+ </button>
402
+ </section>
403
+ );
404
+ }
405
+ ```
406
+
407
+ ### 4. Below-the-Fold Content (Manual Exposure)
408
+
409
+ Track exposure only when content is actually viewed.
410
+
411
+ ```tsx
412
+ function ProductRecommendations() {
413
+ const { params, trackExposure } = useTraffical({
414
+ defaults: {
415
+ "recommendations.algorithm": "collaborative",
416
+ "recommendations.count": 4,
417
+ },
418
+ tracking: "decision", // Decision tracked, exposure manual
419
+ });
420
+
421
+ const ref = useRef<HTMLDivElement>(null);
422
+
423
+ // Track exposure when section scrolls into view
424
+ useEffect(() => {
425
+ const observer = new IntersectionObserver(
426
+ ([entry]) => {
427
+ if (entry.isIntersecting) {
428
+ trackExposure();
429
+ observer.disconnect();
430
+ }
431
+ },
432
+ { threshold: 0.5 }
433
+ );
434
+
435
+ if (ref.current) observer.observe(ref.current);
436
+ return () => observer.disconnect();
437
+ }, [trackExposure]);
438
+
439
+ return (
440
+ <section ref={ref}>
441
+ <RecommendationGrid
442
+ algorithm={params["recommendations.algorithm"]}
443
+ count={params["recommendations.count"]}
444
+ />
445
+ </section>
446
+ );
447
+ }
448
+ ```
449
+
450
+ ### 5. Server-Side Rendering (No Tracking)
451
+
452
+ Use defaults during SSR, hydrate on client.
453
+
454
+ ```tsx
455
+ // Server Component (Next.js App Router)
456
+ async function ProductPage({ productId }: { productId: string }) {
457
+ // Server: use defaults directly (no SDK call)
458
+ const defaultPrice = 299.99;
459
+
460
+ return (
461
+ <TrafficalProvider>
462
+ <ProductDetails productId={productId} defaultPrice={defaultPrice} />
463
+ </TrafficalProvider>
464
+ );
465
+ }
466
+
467
+ // Client Component
468
+ "use client";
469
+ function ProductDetails({ productId, defaultPrice }: Props) {
470
+ const { params, ready } = useTraffical({
471
+ defaults: {
472
+ "pricing.basePrice": defaultPrice,
473
+ "pricing.discount": 0,
474
+ },
475
+ });
476
+
477
+ // Shows defaultPrice immediately, updates when SDK ready
478
+ const price = params["pricing.basePrice"] * (1 - params["pricing.discount"] / 100);
479
+
480
+ return <Price value={price} loading={!ready} />;
481
+ }
482
+ ```
483
+
484
+ ### 6. Component with Self-Contained Parameters
485
+
486
+ Reusable component that owns its experiment surface.
487
+
488
+ ```tsx
489
+ function CheckoutButton({ onCheckout }: { onCheckout: () => void }) {
490
+ const { params } = useTraffical({
491
+ defaults: {
492
+ "checkout.button.text": "Complete Purchase",
493
+ "checkout.button.color": "#22c55e",
494
+ "checkout.button.showIcon": true,
495
+ },
496
+ });
497
+
498
+ return (
499
+ <button
500
+ onClick={onCheckout}
501
+ style={{ backgroundColor: params["checkout.button.color"] }}
502
+ >
503
+ {params["checkout.button.showIcon"] && <ShoppingCartIcon />}
504
+ {params["checkout.button.text"]}
505
+ </button>
506
+ );
507
+ }
508
+ ```
509
+
510
+ ### 7. Multiple Event Types
511
+
512
+ Track different conversion events for the same decision.
513
+
514
+ ```tsx
515
+ function CheckoutFlow() {
516
+ const { params, track } = useTraffical({
517
+ defaults: {
518
+ "checkout.showExpressOption": true,
519
+ "checkout.showUpsells": false,
520
+ },
521
+ });
522
+
523
+ const handleAddUpsell = () => {
524
+ track("upsell_accept", { upsellId: "premium" });
525
+ };
526
+
527
+ const handleComplete = (orderValue: number) => {
528
+ track("checkout_complete", { value: orderValue });
529
+ };
530
+
531
+ return (
532
+ <div>
533
+ {params["checkout.showExpressOption"] && <ExpressCheckout />}
534
+ {params["checkout.showUpsells"] && (
535
+ <UpsellSection onAccept={handleAddUpsell} />
536
+ )}
537
+ <CheckoutForm onComplete={handleComplete} />
538
+ </div>
539
+ );
540
+ }
541
+ ```
542
+
543
+ ---
544
+
545
+ ## Architecture Patterns
546
+
547
+ ### Pattern A: Page-Level Parameters (Recommended for Simple Pages)
548
+
549
+ All parameters defined at page level, passed as props to children.
550
+
551
+ ```tsx
552
+ function ProductPage() {
553
+ const { params, decision } = useTraffical({
554
+ defaults: {
555
+ "product.showReviews": true,
556
+ "product.showRelated": true,
557
+ "pricing.discount": 0,
558
+ "ui.ctaColor": "#2563eb",
559
+ },
560
+ });
561
+
562
+ return (
563
+ <>
564
+ <ProductDetails
565
+ showReviews={params["product.showReviews"]}
566
+ ctaColor={params["ui.ctaColor"]}
567
+ />
568
+ <PricingSection discount={params["pricing.discount"]} />
569
+ {params["product.showRelated"] && <RelatedProducts />}
570
+ </>
571
+ );
572
+ }
573
+ ```
574
+
575
+ **Pros:** Single decision for attribution, clear data flow, testable components
576
+ **Cons:** Prop drilling, parent knows about all params
577
+
578
+ ### Pattern B: Component-Level Parameters (Recommended for Reusable Components)
579
+
580
+ Each component owns its parameters.
581
+
582
+ ```tsx
583
+ // ProductDetails owns its params
584
+ function ProductDetails() {
585
+ const { params } = useTraffical({
586
+ defaults: {
587
+ "product.showReviews": true,
588
+ "product.imageSize": "large",
589
+ },
590
+ });
591
+ // ...
592
+ }
593
+
594
+ // PricingSection owns its params
595
+ function PricingSection() {
596
+ const { params } = useTraffical({
597
+ defaults: {
598
+ "pricing.discount": 0,
599
+ "pricing.showOriginal": true,
600
+ },
601
+ });
602
+ // ...
603
+ }
604
+ ```
605
+
606
+ **Pros:** Encapsulated, portable, self-documenting
607
+ **Cons:** Multiple decisions (handled via deduplication)
608
+
609
+ ### Pattern C: Context + Pure Components (Recommended for Complex Pages)
610
+
611
+ Single decision distributed via context, pure components for rendering.
612
+
613
+ ```tsx
614
+ // Context provider with all params
615
+ function ProductPageProvider({ children }) {
616
+ const traffical = useTraffical({
617
+ defaults: {
618
+ "product.showReviews": true,
619
+ "pricing.discount": 0,
620
+ "ui.ctaColor": "#2563eb",
621
+ },
622
+ });
623
+
624
+ return (
625
+ <ProductPageContext.Provider value={traffical}>
626
+ {children}
627
+ </ProductPageContext.Provider>
628
+ );
629
+ }
630
+
631
+ // Pure component, testable without Traffical
632
+ function PricingSection({ discount, showOriginal }: Props) {
633
+ // Pure rendering logic
634
+ }
635
+
636
+ // Wrapper that connects to Traffical
637
+ function ConnectedPricingSection() {
638
+ const { params } = useProductPageContext();
639
+ return (
640
+ <PricingSection
641
+ discount={params["pricing.discount"]}
642
+ showOriginal={params["pricing.showOriginal"]}
643
+ />
644
+ );
645
+ }
646
+ ```
647
+
648
+ **Pros:** Single decision, no prop drilling, testable leaf components
649
+ **Cons:** More boilerplate
650
+
651
+ ---
652
+
653
+ ## Best Practices
654
+
655
+ ### 1. Always Provide Sensible Defaults
656
+
657
+ Defaults are used when:
658
+ - No experiment is running
659
+ - User doesn't match targeting conditions
660
+ - SDK is still loading
661
+
662
+ ```tsx
663
+ // ✅ Good: Works without any experiment
664
+ const { params } = useTraffical({
665
+ defaults: {
666
+ "pricing.discount": 0,
667
+ "ui.buttonColor": "#3b82f6",
668
+ },
669
+ });
670
+
671
+ // ❌ Bad: Undefined behavior without experiment
672
+ const { params } = useTraffical({
673
+ defaults: {
674
+ "pricing.discount": undefined, // What does this mean?
675
+ },
676
+ });
677
+ ```
678
+
679
+ ### 2. Group Related Parameters
680
+
681
+ Parameters that should vary together belong in the same `useTraffical()` call.
682
+
683
+ ```tsx
684
+ // ✅ Good: Related params together
685
+ const { params } = useTraffical({
686
+ defaults: {
687
+ "pricing.basePrice": 299,
688
+ "pricing.discount": 0,
689
+ "pricing.showOriginal": true,
690
+ },
691
+ });
692
+
693
+ // ⚠️ Caution: Separate calls = separate decisions
694
+ const pricing = useTraffical({ defaults: { "pricing.basePrice": 299 } });
695
+ const discount = useTraffical({ defaults: { "pricing.discount": 0 } });
696
+ ```
697
+
698
+ ### 3. Track Events at Conversion Points
699
+
700
+ Events enable Traffical to learn which variants perform best. Use the bound `track` from `useTraffical()` — it automatically includes the `decisionId`.
701
+
702
+ ```tsx
703
+ const { params, track } = useTraffical({
704
+ defaults: { "checkout.showUpsells": false },
705
+ });
706
+
707
+ // ✅ Track meaningful conversions
708
+ const handlePurchase = (amount: number) => {
709
+ track("purchase", { value: amount, orderId: "ord_123" });
710
+ };
711
+
712
+ // ✅ Track micro-conversions too
713
+ const handleAddToCart = () => {
714
+ track("add_to_cart", { itemId: "sku_456" });
715
+ };
716
+ ```
717
+
718
+ ### 4. Use Consistent Naming Conventions
719
+
720
+ ```
721
+ category.subcategory.name
722
+
723
+ feature.* → Feature flags (boolean)
724
+ ui.* → Visual variations (string, number)
725
+ pricing.* → Pricing experiments (number)
726
+ copy.* → Copywriting tests (string)
727
+ experiment.* → Explicit variants (string)
728
+ ```
729
+
730
+ ### 5. Handle Loading State
731
+
732
+ ```tsx
733
+ const { params, ready } = useTraffical({
734
+ defaults: { "ui.heroVariant": "default" },
735
+ });
736
+
737
+ // Option A: Show defaults immediately (recommended)
738
+ // On page navigation, resolved values render immediately (no flicker)
739
+ return <Hero variant={params["ui.heroVariant"]} />;
740
+
741
+ // Option B: Show loading state (only for initial page load if needed)
742
+ if (!ready) return <HeroSkeleton />;
743
+ return <Hero variant={params["ui.heroVariant"]} />;
744
+ ```
745
+
746
+ > **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.
747
+
748
+ ---
749
+
750
+ ## Flicker-Free SSR (Next.js App Router)
751
+
752
+ 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.
753
+
754
+ ### The Problem
755
+
756
+ Without special handling, here's what happens:
757
+ 1. Server renders with defaults (no userId during SSR)
758
+ 2. Client hydrates with defaults
759
+ 3. SDK fetches config bundle
760
+ 4. SDK resolves with userId → content changes (FLICKER!)
761
+
762
+ ### The Solution: Cookie-Based SSR + LocalConfig
763
+
764
+ 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.
765
+
766
+ #### Step 1: Middleware to Set UserId Cookie
767
+
768
+ ```typescript
769
+ // middleware.ts
770
+ import { NextResponse } from 'next/server';
771
+ import type { NextRequest } from 'next/server';
772
+
773
+ const COOKIE_NAME = 'traffical-userId';
774
+ const HEADER_NAME = 'x-traffical-userId';
775
+
776
+ function generateUserId(): string {
777
+ const array = new Uint8Array(6);
778
+ crypto.getRandomValues(array);
779
+ return `user_${Array.from(array, b => b.toString(16).padStart(2, '0')).join('')}`;
780
+ }
781
+
782
+ export function middleware(request: NextRequest) {
783
+ const existingUserId = request.cookies.get(COOKIE_NAME)?.value;
784
+ const userId = existingUserId || generateUserId();
785
+
786
+ // Pass userId via header for THIS request (cookie isn't available yet on first request)
787
+ const requestHeaders = new Headers(request.headers);
788
+ requestHeaders.set(HEADER_NAME, userId);
789
+
790
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
791
+
792
+ // Set cookie for NEXT request
793
+ if (!existingUserId) {
794
+ response.cookies.set(COOKIE_NAME, userId, {
795
+ httpOnly: false,
796
+ secure: process.env.NODE_ENV === 'production',
797
+ sameSite: 'lax',
798
+ maxAge: 60 * 60 * 24 * 365,
799
+ path: '/',
800
+ });
801
+ }
802
+
803
+ return response;
804
+ }
805
+
806
+ export const config = {
807
+ matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*$).*)'],
808
+ };
809
+ ```
810
+
811
+ #### Step 2: Server Layout Reads UserId
812
+
813
+ ```tsx
814
+ // app/layout.tsx
815
+ import { cookies, headers } from 'next/headers';
816
+
817
+ export default async function RootLayout({ children }) {
818
+ const headerStore = await headers();
819
+ const cookieStore = await cookies();
820
+
821
+ // Header for first request, cookie for subsequent
822
+ const userId = headerStore.get('x-traffical-userId') ||
823
+ cookieStore.get('traffical-userId')?.value || '';
824
+
825
+ return (
826
+ <html>
827
+ <body>
828
+ <Providers initialUserId={userId}>
829
+ {children}
830
+ </Providers>
831
+ </body>
832
+ </html>
833
+ );
834
+ }
835
+ ```
836
+
837
+ #### Step 3: Pass UserId Through Context
838
+
839
+ ```tsx
840
+ // context/Providers.tsx
841
+ 'use client';
842
+
843
+ export function Providers({ children, initialUserId }) {
844
+ return (
845
+ <DemoProvider initialUserId={initialUserId}>
846
+ <TrafficalWrapper>
847
+ {children}
848
+ </TrafficalWrapper>
849
+ </DemoProvider>
850
+ );
851
+ }
852
+
853
+ // context/DemoContext.tsx
854
+ export function DemoProvider({ children, initialUserId }) {
855
+ const [userId] = useState(initialUserId || '');
856
+
857
+ // Use userId as initial state - NOT in useEffect
858
+ // ...
859
+ }
860
+ ```
861
+
862
+ #### Step 4: Provide LocalConfig to SDK
863
+
864
+ Fetch the config bundle at build time and pass it to the provider:
865
+
866
+ ```typescript
867
+ // lib/traffical.ts
868
+ import configBundle from '@/data/config-bundle.json';
869
+
870
+ export const trafficalConfig = {
871
+ orgId: process.env.NEXT_PUBLIC_TRAFFICAL_ORG_ID,
872
+ projectId: process.env.NEXT_PUBLIC_TRAFFICAL_PROJECT_ID,
873
+ apiKey: process.env.NEXT_PUBLIC_TRAFFICAL_API_KEY,
874
+ // This is the key to flicker-free SSR!
875
+ localConfig: configBundle as ConfigBundle,
876
+ };
877
+ ```
878
+
879
+ #### Step 5: TrafficalWrapper Uses UserId
880
+
881
+ ```tsx
882
+ // context/TrafficalWrapper.tsx
883
+ export function TrafficalWrapper({ children }) {
884
+ const { userId } = useDemoContext();
885
+
886
+ const config = useMemo(() => ({
887
+ ...trafficalConfig,
888
+ unitKeyFn: () => userId, // Returns the server-provided userId
889
+ }), [userId]);
890
+
891
+ return (
892
+ <TrafficalProvider config={config}>
893
+ {children}
894
+ </TrafficalProvider>
895
+ );
896
+ }
897
+ ```
898
+
899
+ ### How It Works
900
+
901
+ ```
902
+ Request Flow (First Visit):
903
+ ─────────────────────────────────────────────────────────────────
904
+ 1. Request arrives (no cookie)
905
+ 2. Middleware generates userId → sets HEADER + COOKIE
906
+ 3. Server layout reads userId from HEADER
907
+ 4. Server passes userId to React via props
908
+ 5. useTraffical's useState resolves from localConfig + userId
909
+ 6. Server renders HTML with CORRECT variant
910
+ 7. Response sent with Set-Cookie header
911
+ 8. Client hydrates with SAME userId → NO FLICKER ✅
912
+ ─────────────────────────────────────────────────────────────────
913
+
914
+ Subsequent Requests:
915
+ ─────────────────────────────────────────────────────────────────
916
+ 1. Request arrives WITH cookie
917
+ 2. Middleware passes existing userId via header
918
+ 3. Same flow as above → consistent experience
919
+ ─────────────────────────────────────────────────────────────────
920
+ ```
921
+
922
+ ### Requirements
923
+
924
+ | Requirement | Why |
925
+ |-------------|-----|
926
+ | `localConfig` | Enables synchronous resolution without waiting for network |
927
+ | UserId in cookies | Server can read it during SSR |
928
+ | UserId via header on first request | Cookie isn't in request until second request |
929
+ | UserId as initial state (not useEffect) | Prevents hydration mismatch |
930
+
931
+ ### What This Solves
932
+
933
+ - ✅ **First page load** - No flicker, correct variant from the start
934
+ - ✅ **Client-side navigation** - Already worked (bundle cached)
935
+ - ✅ **Page refresh** - UserId persisted in cookie
936
+ - ✅ **New users** - UserId generated on first request
937
+
938
+ ---
939
+
940
+ ## FAQ
941
+
942
+ **Q: Do multiple `useTraffical()` calls cause multiple network requests?**
943
+
944
+ No. The SDK fetches the config bundle once and caches it. All resolution happens locally.
945
+
946
+ **Q: What happens if the SDK fails to load?**
947
+
948
+ Defaults are returned. Your app works normally, just without experiment variations.
949
+
950
+ **Q: Should I use `tracking: "none"` for SSR?**
951
+
952
+ Yes, if you're calling `useTraffical` in a server context. On the client, use the default `"full"` tracking.
953
+
954
+ **Q: Can I change parameter values from the dashboard without deploying?**
955
+
956
+ Yes! That's the point. Parameters are resolved from Traffical's config bundle, which updates independently of your code.
957
+
958
+ ---
959
+
960
+
961
+ ## Migration from Deprecated Hooks
962
+
963
+ The `useTrafficalParams` and `useTrafficalDecision` hooks are deprecated but still available for backward compatibility.
964
+
965
+ ### useTrafficalParams → useTraffical
966
+
967
+ ```tsx
968
+ // Before (deprecated)
969
+ const { params, ready } = useTrafficalParams({
970
+ defaults: { 'ui.hero.title': 'Welcome' },
971
+ });
972
+
973
+ // After
974
+ const { params, ready } = useTraffical({
975
+ defaults: { 'ui.hero.title': 'Welcome' },
976
+ tracking: 'none',
977
+ });
978
+ ```
979
+
980
+ ### useTrafficalDecision → useTraffical
981
+
982
+ ```tsx
983
+ // Before (deprecated) - auto exposure
984
+ const { params, decision } = useTrafficalDecision({
985
+ defaults: { 'checkout.ctaText': 'Buy Now' },
986
+ });
987
+
988
+ // After
989
+ const { params, decision } = useTraffical({
990
+ defaults: { 'checkout.ctaText': 'Buy Now' },
991
+ });
992
+
993
+ // Before (deprecated) - manual exposure
994
+ const { params, trackExposure } = useTrafficalDecision({
995
+ defaults: { 'checkout.ctaText': 'Buy Now' },
996
+ trackExposure: false,
997
+ });
998
+
999
+ // After
1000
+ const { params, trackExposure } = useTraffical({
1001
+ defaults: { 'checkout.ctaText': 'Buy Now' },
1002
+ tracking: 'decision',
1003
+ });
1004
+ ```
1005
+
1006
+ ---
1007
+
1008
+ ## License
1009
+
1010
+
1011
+