Skip to main content

ADR-0015: React 18 as Frontend Framework

Status

Accepted - 2025-01-26


Context

TVL Platform needs a frontend framework for the web application with the following requirements:

Business Requirements

  • Fast time-to-market (build MVP.0 in < 3 months)
  • Rich user interfaces (complex forms, calendars, dashboards)
  • Large hiring pool (easy to find developers)

Technical Requirements

  • Component-based architecture (reusable UI)
  • TypeScript support (type safety)
  • Server-side rendering (SEO, performance)
  • Excellent developer experience (hot reload, debugging)
  • Rich ecosystem (UI libraries, state management, routing)

Constraints

  • TypeScript 5.3+ (ADR-0005)
  • Next.js 14 planned for meta-framework (ADR-0016)
  • Must work with Tailwind CSS (ADR-0017)
  • Team has React experience

Decision

React 18 as the frontend framework for the web application.

Rationale

  1. Industry Standard: Most popular frontend framework (40%+ market share)
  2. TypeScript Support: First-class TypeScript support
  3. Rich Ecosystem: 100,000+ React packages on npm
  4. Hiring: Largest talent pool (React is #2 most wanted framework - Stack Overflow 2024)
  5. Next.js Compatibility: Next.js built on React (seamless integration)

Alternatives Considered

Alternative 1: Vue 3

Rejected

Pros:

  • Simpler learning curve
  • Good TypeScript support
  • Excellent documentation
  • Composition API similar to React Hooks

Cons:

  • Smaller Ecosystem: Fewer libraries and components
  • Smaller Hiring Pool: Harder to find Vue developers
  • Less Momentum: React has more growth and investment
  • Team Unfamiliar: Team has React experience, not Vue

Decision: React larger ecosystem and hiring pool.


Alternative 2: Svelte / SvelteKit

Rejected

Pros:

  • Smallest bundle size (compiles to vanilla JS)
  • Excellent performance (no virtual DOM)
  • Simple syntax (less boilerplate)
  • Growing momentum

Cons:

  • Tiny Ecosystem: Far fewer libraries and components
  • Very Small Hiring Pool: Very hard to find Svelte developers
  • Immature Ecosystem: Newer, less battle-tested
  • TypeScript Support: Not as mature as React

Decision: Too risky for production app, small hiring pool.


Alternative 3: Angular

Rejected

Pros:

  • Full framework (includes routing, state, forms)
  • Excellent TypeScript support
  • Enterprise-proven

Cons:

  • Heavy: Large bundle size, slow builds
  • Steep Learning Curve: RxJS, dependency injection
  • Declining Popularity: Losing market share to React/Vue
  • Team Unfamiliar: Team has React experience, not Angular

Decision: React more flexible and lighter weight.


Alternative 4: Solid.js

Rejected

Pros:

  • Fastest performance (fine-grained reactivity)
  • React-like API (easy migration)
  • Small bundle size
  • No virtual DOM

Cons:

  • Tiny Ecosystem: Very few libraries
  • Extremely Small Hiring Pool: Almost impossible to hire
  • Immature: New framework (2021)
  • Risk: Not proven at scale

Decision: Too new and risky, no hiring pool.


Consequences

Positive

  1. Developer Experience

    • React DevTools (excellent debugging)
    • Fast Refresh (instant hot reload)
    • Great error messages
    • Excellent IDE support (VS Code, Cursor)
  2. Ecosystem

    • UI libraries (Radix, Headless UI, Material UI)
    • State management (Zustand, Redux, Jotai)
    • Forms (React Hook Form, Formik)
    • Routing (React Router, Next.js)
    • Testing (React Testing Library)
  3. Performance

    • Concurrent features (Suspense, transitions)
    • Server Components (Next.js 14)
    • Streaming SSR
    • Automatic batching
  4. Hiring

    • Largest React developer pool
    • Easy to train JavaScript developers
    • Many bootcamps teach React
  5. TypeScript

    • Excellent type inference
    • @types/react comprehensive
    • JSX type checking

Negative

  1. Learning Curve

    • Hooks can be confusing (useEffect, useMemo)
    • Mitigation: Team already knows React
  2. Boilerplate

    • More code than Vue/Svelte for same result
    • Mitigation: Use libraries (Shadcn UI reduces boilerplate)
  3. Choice Overload

    • Too many libraries (decision fatigue)
    • Mitigation: ADRs document our choices
  4. Bundle Size

    • React runtime ~40KB (larger than Svelte)
    • Mitigation: Next.js automatic code splitting

React 18 Features Used

1. Concurrent Features

import { Suspense, lazy } from 'react';

// Lazy load components
const BookingCalendar = lazy(() => import('./BookingCalendar'));

function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<BookingCalendar />
</Suspense>
);
}

2. Automatic Batching

// React 18 automatically batches these updates
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// Only one re-render (React 17 would re-render twice)
}

3. Transitions

import { useTransition } from 'react';

function SearchForm() {
const [isPending, startTransition] = useTransition();
const [searchQuery, setSearchQuery] = useState('');

function handleChange(e) {
// Mark as low-priority update
startTransition(() => {
setSearchQuery(e.target.value);
});
}

return (
<input value={searchQuery} onChange={handleChange} />
{isPending && <Spinner />}
);
}

4. Server Components (Next.js 14)

// app/bookings/page.tsx (Server Component by default)
async function BookingsPage() {
// Fetch on server (no client-side fetch needed)
const bookings = await db.query.bookings.findMany();

return (
<div>
{bookings.map(booking => (
<BookingCard key={booking.id} booking={booking} />
))}
</div>
);
}

Component Patterns

1. Functional Components with Hooks

import { useState, useEffect } from 'react';

export function BookingForm() {
const [guestName, setGuestName] = useState('');
const [errors, setErrors] = useState<string[]>([]);

useEffect(() => {
// Validate on mount
validateForm();
}, []);

return (
<form>
<input
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
/>
{errors.length > 0 && (
<div className="text-red-500">{errors.join(', ')}</div>
)}
</form>
);
}

2. Custom Hooks

// hooks/useBooking.ts
export function useBooking(bookingId: string) {
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetchBooking(bookingId).then(setBooking).finally(() => setLoading(false));
}, [bookingId]);

return { booking, loading };
}

// Usage
function BookingDetails({ bookingId }: { bookingId: string }) {
const { booking, loading } = useBooking(bookingId);

if (loading) return <Spinner />;
return <div>{booking.guestName}</div>;
}

3. Compound Components

// components/Tabs.tsx
export function Tabs({ children }: { children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(0);

return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}

Tabs.List = function TabsList({ children }) {
return <div className="flex gap-2">{children}</div>;
};

Tabs.Tab = function Tab({ index, children }) {
const { activeTab, setActiveTab } = useTabsContext();
return (
<button
className={activeTab === index ? 'active' : ''}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
};

// Usage
<Tabs>
<Tabs.List>
<Tabs.Tab index={0}>Overview</Tabs.Tab>
<Tabs.Tab index={1}>Details</Tabs.Tab>
</Tabs.List>
</Tabs>

TypeScript Integration

// types/booking.ts
export interface Booking {
id: string;
guestName: string;
checkIn: Date;
checkOut: Date;
totalCents: number;
status: 'pending' | 'confirmed' | 'cancelled';
}

// components/BookingCard.tsx
interface BookingCardProps {
booking: Booking;
onConfirm?: (id: string) => void;
}

export function BookingCard({ booking, onConfirm }: BookingCardProps) {
return (
<div>
<h3>{booking.guestName}</h3>
<p>{booking.status}</p>
{onConfirm && (
<button onClick={() => onConfirm(booking.id)}>
Confirm
</button>
)}
</div>
);
}

Testing with React Testing Library

// BookingCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { BookingCard } from './BookingCard';

describe('BookingCard', () => {
const mockBooking: Booking = {
id: '123',
guestName: 'John Doe',
checkIn: new Date('2025-02-01'),
checkOut: new Date('2025-02-05'),
totalCents: 50000,
status: 'pending'
};

it('should render booking details', () => {
render(<BookingCard booking={mockBooking} />);

expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('pending')).toBeInTheDocument();
});

it('should call onConfirm when button clicked', () => {
const onConfirm = vi.fn();
render(<BookingCard booking={mockBooking} onConfirm={onConfirm} />);

fireEvent.click(screen.getByText('Confirm'));
expect(onConfirm).toHaveBeenCalledWith('123');
});
});

Performance Optimization

1. React.memo (Prevent Re-renders)

export const BookingCard = React.memo(function BookingCard({ booking }) {
return <div>{booking.guestName}</div>;
});

2. useMemo (Expensive Calculations)

function BookingList({ bookings }: { bookings: Booking[] }) {
const totalRevenue = useMemo(() => {
return bookings.reduce((sum, b) => sum + b.totalCents, 0);
}, [bookings]);

return <div>Total: ${totalRevenue / 100}</div>;
}

3. useCallback (Stable Function References)

function BookingList({ onSelect }: { onSelect: (id: string) => void }) {
const handleClick = useCallback((id: string) => {
onSelect(id);
}, [onSelect]);

return <BookingCard onClick={handleClick} />;
}

Validation Checklist

  • React 18 installed
  • TypeScript types configured (@types/react)
  • React DevTools installed (browser extension)
  • Functional components with hooks (no class components)
  • Custom hooks for reusable logic
  • React Testing Library configured
  • Performance optimizations (memo, useMemo, useCallback)
  • Concurrent features enabled (Suspense)

References