Skip to content

Quick Start Guide

Transform your React app from props hell to service-oriented architecture with this complete e-commerce example.

🎯 What You'll Build

A complete ProductService with reactive state, automatic dependency injection, and zero-props components. By the end, you'll have a working product catalog with real-time updates.

💡 Why these design choices? See our Architecture Decisions for the reasoning behind TDI2’s approach.


Terminal window
# Core packages
npm install @tdi2/di-core @tdi2/vite-plugin-di valtio
# Or with bun
bun add @tdi2/di-core @tdi2/vite-plugin-di valtio

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { diEnhancedPlugin } from '@tdi2/vite-plugin-di';
export default defineConfig({
plugins: [
react(),
diEnhancedPlugin({
enableFunctionalDI: true,
enableInterfaceResolution: true,
enableLifecycleHooks: true,
enableProfileSupport: true,
enableConfigurationSupport: true,
enableTestingSupport: true,
verbose: true, // See transformation logs
})
],
// TypeScript configuration for decorators
esbuild: {
target: 'es2020'
}
});
tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": false,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node"
}
}

Let’s build a ProductService for an e-commerce application:

services/interfaces/ProductServiceInterface.ts
export interface Product {
id: string;
name: string;
price: number;
description: string;
category: string;
imageUrl: string;
stock: number;
}
export interface ProductServiceInterface {
state: {
products: Product[];
selectedProduct: Product | null;
loading: boolean;
error: string | null;
searchTerm: string;
};
loadProducts(): Promise<void>;
loadProduct(id: string): Promise<void>;
searchProducts(term: string): void;
clearSearch(): void;
}
services/implementations/ProductService.ts
import { Service, Inject, Profile, PostConstruct, PreDestroy } from '@tdi2/di-core';
import type { ProductServiceInterface, Product } from '../interfaces/ProductServiceInterface';
@Service()
@Profile("development", "production") // Available in both environments
export class ProductService implements ProductServiceInterface {
state = {
products: [] as Product[],
selectedProduct: null as Product | null,
loading: false,
error: null as string | null,
searchTerm: '',
};
constructor(
@Inject() private productRepository: ProductRepository,
@Inject() private notificationService: NotificationService
) {}
@PostConstruct
async initialize(): Promise<void> {
// Called after dependency injection is complete
console.log('ProductService initialized');
// Could pre-load critical data here
}
@PreDestroy
async cleanup(): Promise<void> {
// Called before service destruction
console.log('ProductService cleaning up');
// Clean up any resources, subscriptions, etc.
}
async loadProducts(): Promise<void> {
if (this.state.products.length > 0) return; // Smart caching
this.state.loading = true;
this.state.error = null;
try {
this.state.products = await this.productRepository.getProducts();
this.notificationService.showSuccess(`Loaded ${this.state.products.length} products`);
} catch (error) {
this.state.error = error.message;
this.notificationService.showError('Failed to load products');
} finally {
this.state.loading = false;
}
}
async loadProduct(id: string): Promise<void> {
if (this.state.selectedProduct?.id === id) return;
this.state.loading = true;
this.state.error = null;
try {
this.state.selectedProduct = await this.productRepository.getProduct(id);
} catch (error) {
this.state.error = error.message;
this.notificationService.showError('Product not found');
} finally {
this.state.loading = false;
}
}
searchProducts(term: string): void {
this.state.searchTerm = term;
// Reactive filtering happens automatically in components
}
clearSearch(): void {
this.state.searchTerm = '';
}
}

The repository pattern separates data fetching from business logic:

repositories/interfaces/ProductRepository.ts
export interface ProductRepository {
getProducts(): Promise<Product[]>;
getProduct(id: string): Promise<Product>;
searchProducts(term: string): Promise<Product[]>;
}
repositories/implementations/ApiProductRepository.ts
import { Service, Profile } from '@tdi2/di-core';
@Service()
@Profile("production", "staging")
export class ApiProductRepository implements ProductRepository {
private readonly baseUrl = '/api/products';
async getProducts(): Promise<Product[]> {
const response = await fetch(this.baseUrl);
if (!response.ok) throw new Error('Failed to fetch products');
return response.json();
}
async getProduct(id: string): Promise<Product> {
const response = await fetch(`${this.baseUrl}/${id}`);
if (!response.ok) throw new Error('Product not found');
return response.json();
}
async searchProducts(term: string): Promise<Product[]> {
const response = await fetch(`${this.baseUrl}/search?q=${encodeURIComponent(term)}`);
if (!response.ok) throw new Error('Search failed');
return response.json();
}
}
services/implementations/NotificationService.ts
@Service()
@Profile("development", "production") // Available in both environments
export class NotificationService {
showSuccess(message: string): void {
// Integration with your notification system
console.log('', message);
// toast.success(message);
}
showError(message: string): void {
console.error('', message);
// toast.error(message);
}
}

Here’s where the magic happens - write components with service injection:

components/ProductList.tsx
import { Inject } from '@tdi2/di-core';
import type { ProductServiceInterface } from '../services/interfaces/ProductServiceInterface';
interface ProductListProps {
productService: Inject<ProductServiceInterface>;
}
export function ProductList({ productService }: ProductListProps) {
const { products, loading, error, searchTerm } = productService.state;
// Filter products based on search term (reactive!)
const filteredProducts = products.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.category.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
productService.loadProducts();
}, []);
if (loading) return <div className="loading">Loading products...</div>;
if (error) return <div className="error">Error: {error}</div>;
return (
<div className="product-list">
<div className="search-bar">
<input
type="text"
placeholder="Search products..."
value={searchTerm}
onChange={(e) => productService.searchProducts(e.target.value)}
/>
{searchTerm && (
<button onClick={() => productService.clearSearch()}>
Clear Search
</button>
)}
</div>
<div className="products-grid">
{filteredProducts.length === 0 ? (
<div className="no-products">
{searchTerm ? 'No products match your search' : 'No products available'}
</div>
) : (
filteredProducts.map(product => (
<ProductCard
key={product.id}
productId={product.id}
onSelect={() => productService.loadProduct(product.id)}
/>
))
)}
</div>
</div>
);
}
components/ProductCard.tsx
interface ProductCardProps {
productId: string;
onSelect: () => void;
productService: Inject<ProductServiceInterface>;
}
export function ProductCard({ productId, onSelect, productService }: ProductCardProps) {
const product = productService.state.products.find(p => p.id === productId);
if (!product) return null;
return (
<div className="product-card" onClick={onSelect}>
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<p className="price">${product.price}</p>
<p className="description">{product.description}</p>
<div className="stock">
{product.stock > 0 ? `${product.stock} in stock` : 'Out of stock'}
</div>
</div>
);
}

Configure the DI container and wrap your app with the provider:

main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { DIContainer, DIProvider } from '@tdi2/di-core';
import { DI_CONFIG } from './.tdi2/di-config'; // Auto-generated by Vite plugin
import App from './App';
// Create and configure the DI container
const container = new DIContainer();
container.loadConfiguration(DI_CONFIG);
createRoot(document.getElementById('root')!).render(
<DIProvider container={container}>
<App />
</DIProvider>
);
App.tsx
import { ProductList } from './components/ProductList';
function App() {
return (
<div className="app">
<header>
<h1>E-Commerce Product Catalog</h1>
</header>
<main>
<ProductList /> {/* No props needed - DI handles it! */}
</main>
</div>
);
}
export default App;

When you build your app, TDI2’s Vite plugin transforms your components:

function ProductList({ productService }: { productService: Inject<ProductServiceInterface> }) {
const { products, loading } = productService.state;
// Component logic...
}
function ProductList() {
// TDI2-GENERATED: Automatic service injection
const productService = useService<ProductServiceInterface>('ProductService');
// TDI2-GENERATED: Reactive state snapshots
const productServiceSnap = useSnapshot(productService.state);
const { products, loading } = productServiceSnap;
// Your original component logic (unchanged)
// Component logic...
}
⚡ The Magic

TDI2 automatically converts service props into useService hooks and adds reactive snapshots for optimal performance. Your production code contains zero DI abstractions!


Test your setup by running the development server:

Terminal window
bun run dev

You should see:

  1. Build logs showing TDI2 transformations (if verbose: true)
  2. Working product list with search functionality
  3. Reactive updates when you type in the search box
  4. No props drilling - components get data from services

// Development-only mock service
@Service()
@Profile("development")
export class MockEmailService implements EmailServiceInterface {
sendEmail(to: string, subject: string): Promise<void> {
console.log(`Mock email to ${to}: ${subject}`);
return Promise.resolve();
}
}
// Production SMTP service
@Service()
@Profile("production")
export class SmtpEmailService implements EmailServiceInterface {
sendEmail(to: string, subject: string): Promise<void> {
return this.smtpClient.send({ to, subject });
}
}
@Configuration()
export class DatabaseConfiguration {
@Bean()
@Profile("development")
createDevDatabase(): DatabaseConnection {
return new SqliteConnection('dev.db');
}
@Bean()
@Profile("production")
createProdDatabase(): DatabaseConnection {
return new PostgresConnection(process.env.DATABASE_URL);
}
}
Traditional ReactYour New TDI2 App
Props drilling through multiple levelsDirect service injection
Manual state synchronizationAutomatic reactive updates
Complex component testingSimple service unit tests
Tight coupling between componentsLoose coupling via interfaces
Performance optimization requiredAutomatic surgical re-renders
Environment configuration scatteredCentralized @Profile management
Manual lifecycle managementAutomatic @PostConstruct/@PreDestroy


TypeScript decorator errors?

tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": false
}
}

Services not injecting?

  • Ensure @Service() decorator on your service class
  • Verify Inject<InterfaceName> type annotation on component props
  • Check that interface name matches service class name pattern
  • For environment-specific services, verify @Profile() matches current environment

State not updating?

  • Confirm Valtio is installed: bun add valtio
  • Enable Valtio integration in plugin config
  • Check browser console for TDI2 transformation logs

Missing dependencies?

  • Ensure all @Inject() dependencies have corresponding @Service() implementations
  • Check that profile-specific services are active in current environment
  • Verify @Configuration classes are properly registered
  • Check the browser console for DI container errors

🎉 Congratulations! You’ve just built your first TDI2 application with reactive services, automatic dependency injection, and zero props drilling. Welcome to the future of React architecture!