Introduction
The frontend framework landscape in 2024 presents developers with a fascinating architectural dichotomy: React's hooks-based model, which triggers re-renders through virtual DOM diffing, versus Svelte 5's rune-based signals, which achieve surgical DOM updates through compile-time optimization. Both approaches solve the same fundamental problem—keeping the UI in sync with application state—but through fundamentally different mechanisms.
React hooks, introduced in React 16.8, replaced class components with a function-based API that uses useState, useEffect, and useMemo to manage state and side effects. The model is intuitive: when state changes, the component function re-executes, and React's reconciler diffs the virtual DOM to determine minimal updates. This approach prioritizes developer mental models (functions are easier to reason about than classes) at the cost of runtime overhead (every state change triggers a full function re-execution).
Svelte 5 runes, conversely, represent a signal-based approach where $state, $derived, and $effect create a reactive dependency graph that the compiler transforms into optimized vanilla JavaScript. When state changes, only the specific DOM elements that reference that state update—no virtual DOM, no reconciliation, no function re-execution. This prioritizes runtime performance at the cost of requiring compiler involvement.
This comparison dives deep into both approaches, examining their architectural foundations, developer experience trade-offs, performance characteristics, and practical implications for building production applications.
Understanding the Reactivity Models
React's Re-Render Model
React's hooks operate on a simple principle: when state changes, the component function re-executes from top to bottom. This means every useState, useMemo, and useCallback call runs again on every render. The virtual DOM provides a "safety net" that ensures only actual DOM changes are committed.
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Alice');
// This runs on EVERY render, even when only name changes
const doubled = useMemo(() => count * 2, [count]);
// This callback is recreated on every render without useCallback
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
console.log('Component rendered'); // Logs on every state change
return (
<div>
<p>{count} × 2 = {doubled}</p>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={handleClick}>Increment</button>
</div>
);
}The key insight is that React's model is "pull-based"—the entire component function is the unit of re-execution. The virtual DOM then "pushes" only the necessary DOM updates. This separation means developers write straightforward code (the function just runs) but pay a runtime cost for the diffing step.
Svelte 5's Signal Model
Svelte 5 runes operate on a different principle: state changes propagate through a dependency graph to only the DOM elements that directly reference the changed state. The component function itself doesn't re-execute.
<script>
let count = $state(0);
let name = $state('Alice');
// This only recalculates when count changes
let doubled = $derived(count * 2);
// This function is created once, never recreated
function handleClick() {
count++;
}
console.log('Script runs once'); // Only logs on mount
</script>
<div>
<p>{count} × 2 = {doubled}</p>
<input bind:value={name} />
<button onclick={handleClick}>Increment</button>
</div>In this Svelte 5 example, clicking "Increment" doesn't re-execute the <script> block. Instead, the signal for count notifies the specific text node displaying count and the $derived computation for doubled. The <input> binding is unaffected because it only depends on name.
Mental Model Comparison
| Aspect | React Hooks | Svelte 5 Runes |
|---|---|---|
| State change triggers | Full function re-execution | Targeted signal propagation |
| Dependency tracking | Manual (dependency arrays) | Automatic (compiler-analyzed) |
| Memoization | Explicit (useMemo, useCallback) | Implicit ($derived is always memoized) |
| Side effects | useEffect with cleanup | $effect with cleanup |
| Stale closures | Common pitfall | Not possible (no re-execution) |
| Rules/constraints | Rules of Hooks (no conditionals) | No special rules |
Architecture and Design Patterns
Dependency Tracking
React's Manual Dependencies
React requires developers to explicitly declare effect dependencies:
useEffect(() => {
fetchUser(userId);
}, [userId]); // Manual dependency specification
const memoizedValue = useMemo(() => {
return expensiveComputation(data);
}, [data]); // Must list all dependenciesThis creates several well-documented pitfalls:
- Forgetting dependencies causes stale closures
- Including function dependencies causes unnecessary re-executions
- The
exhaustive-depsESLint rule helps but doesn't catch all cases
Svelte 5's Automatic Dependencies
Svelte 5's compiler analyzes expressions to automatically build the dependency graph:
<script>
let userId = $state(1);
let data = $state([]);
// Compiler automatically tracks that this depends on userId
$effect(() => {
fetchUser(userId);
});
// Compiler automatically tracks that this depends on data
let processed = $derived(expensiveComputation(data));
</script>No dependency arrays, no stale closures, no missing dependencies. The compiler knows exactly what each expression reads and builds the signal graph accordingly.
Component Composition
React's Props Drilling and Context
React components communicate through props and context. For deeply nested state, React offers Context API:
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Layout />
</ThemeContext.Provider>
);
}
function Button() {
const { theme, setTheme } = useContext(ThemeContext);
return <button className={theme}>Toggle Theme</button>;
}Context causes all consumers to re-render when any part of the context value changes, even if they only use a subset of the context.
Svelte 5's Runes-Based Composition
Svelte 5 achieves similar composition without the re-render penalty:
// src/lib/context/theme.svelte.ts
import { setContext, getContext } from 'svelte';
export function createThemeContext() {
let theme = $state<'light' | 'dark'>('light');
function toggle() {
theme = theme === 'light' ? 'dark' : 'light';
}
const context = {
get theme() { return theme; },
toggle
};
setContext('theme', context);
return context;
}
export function getTheme() {
return getContext<ReturnType<typeof createThemeContext>>('theme');
}<!-- Button.svelte -->
<script>
import { getTheme } from '$lib/context/theme.svelte';
const { theme, toggle } = getTheme();
</script>
<button class={theme} onclick={toggle}>Toggle Theme</button>When theme changes, only the class attribute on the <button> updates—the component function doesn't re-execute.
State Management Libraries
React Ecosystem
React's re-render model has spawned a rich ecosystem of state management solutions, each trying to minimize unnecessary re-renders:
- Redux: Centralized store with selector-based subscriptions
- Zustand: Minimal store with selector subscriptions
- Jotai: Atomic state with dependency tracking
- Recoil: Atom-based state with derived selectors
Each library adds complexity to solve the re-render problem that React's architecture creates.
Svelte 5's Built-in Solution
Svelte 5's runes provide built-in state management that doesn't require external libraries:
// This is a complete, performant state management solution
// No external library needed
let items = $state<Item[]>([]);
let filter = $state('all');
let filteredItems = $derived(
filter === 'all' ? items : items.filter(i => i.status === filter)
);
let stats = $derived({
total: items.length,
active: items.filter(i => i.status === 'active').length,
completed: items.filter(i => i.status === 'completed').length
});The signals architecture ensures that changing filter only updates the DOM elements displaying filteredItems, not the entire component tree.
Step-by-Step Implementation
Building the Same Component in Both Frameworks
Let's implement a searchable product list with filters to compare the two approaches:
React Implementation
// React: ProductList.tsx
import { useState, useMemo, useCallback, useEffect } from 'react';
interface Product {
id: string;
name: string;
category: string;
price: number;
inStock: boolean;
}
export function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [sortBy, setSortBy] = useState<'name' | 'price'>('name');
const [loading, setLoading] = useState(true);
// Fetch products on mount
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);
// Memoize filtered and sorted products
const filteredProducts = useMemo(() => {
let result = products;
if (search) {
result = result.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
);
}
if (category !== 'all') {
result = result.filter(p => p.category === category);
}
return [...result].sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
return a.price - b.price;
});
}, [products, search, category, sortBy]);
// Memoize stats
const stats = useMemo(() => ({
total: filteredProducts.length,
inStock: filteredProducts.filter(p => p.inStock).length,
avgPrice: filteredProducts.length > 0
? filteredProducts.reduce((sum, p) => sum + p.price, 0) / filteredProducts.length
: 0
}), [filteredProducts]);
// Memoize event handlers
const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
}, []);
const handleCategory = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setCategory(e.target.value);
}, []);
if (loading) return <div>Loading...</div>;
return (
<div>
<input value={search} onChange={handleSearch} placeholder="Search..." />
<select value={category} onChange={handleCategory}>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select value={sortBy} onChange={e => setSortBy(e.target.value as any)}>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
<p>{stats.total} products, {stats.inStock} in stock</p>
<ul>
{filteredProducts.map(product => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
</div>
);
}Svelte 5 Implementation
<!-- Svelte 5: ProductList.svelte -->
<script lang="ts">
let products = $state.raw<Product[]>([]);
let search = $state('');
let category = $state('all');
let sortBy = $state<'name' | 'price'>('name');
let loading = $state(true);
$effect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
products = data;
loading = false;
});
});
let filteredProducts = $derived.by(() => {
let result = products;
if (search) {
result = result.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
);
}
if (category !== 'all') {
result = result.filter(p => p.category === category);
}
return [...result].sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
return a.price - b.price;
});
});
let stats = $derived({
total: filteredProducts.length,
inStock: filteredProducts.filter(p => p.inStock).length,
avgPrice: filteredProducts.length > 0
? filteredProducts.reduce((sum, p) => sum + p.price, 0) / filteredProducts.length
: 0
});
</script>
<div>
<input bind:value={search} placeholder="Search..." />
<select bind:value={category}>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select bind:value={sortBy}>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
<p>{stats.total} products, {stats.inStock} in stock</p>
{#if loading}
<div>Loading...</div>
{:else}
<ul>
{#each filteredProducts as product (product.id)}
<li>{product.name} - ${product.price}</li>
{/each}
</ul>
{/if}
</div>Code Comparison
| Aspect | React (Lines) | Svelte 5 (Lines) | Difference |
|---|---|---|---|
| Total code | 62 | 42 | -32% |
| Memoization code | 15 (useMemo, useCallback) | 0 (automatic) | -100% |
| Event handler boilerplate | 6 | 0 (bind:value) | -100% |
| Dependency arrays | 4 | 0 | -100% |
| Loading state handling | Separate variable + conditional | Same pattern | Same |
Real-World Use Cases
Use Case 1: Form Validation
React Approach
React forms require careful management of validation state to avoid unnecessary re-renders:
function RegistrationForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
// Validate on every change - causes re-render
useEffect(() => {
const newErrors: Record<string, string> = {};
if (touched.email && !formData.email.includes('@')) {
newErrors.email = 'Invalid email';
}
if (touched.password && formData.password.length < 8) {
newErrors.password = 'Password must be 8+ characters';
}
if (touched.confirmPassword && formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords must match';
}
setErrors(newErrors);
}, [formData, touched]);
const isFormValid = useMemo(() => {
return formData.email.includes('@') &&
formData.password.length >= 8 &&
formData.password === formData.confirmPassword;
}, [formData]);
return (/* form JSX */);
}Svelte 5 Approach
Svelte 5's derived state makes validation straightforward:
<script lang="ts">
let formData = $state({
email: '',
password: '',
confirmPassword: ''
});
let touched = $state<Record<string, boolean>>({});
let errors = $derived({
email: touched.email && !formData.email.includes('@') ? 'Invalid email' : '',
password: touched.password && formData.password.length < 8 ? 'Password must be 8+ characters' : '',
confirmPassword: touched.confirmPassword && formData.password !== formData.confirmPassword ? 'Passwords must match' : ''
});
let isFormValid = $derived(
formData.email.includes('@') &&
formData.password.length >= 8 &&
formData.password === formData.confirmPassword
);
</script>Use Case 2: Data Fetching with Caching
React with React Query
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000
});
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId),
enabled: !!user
});
if (isLoading) return <Skeleton />;
return (
<div>
<h1>{user.name}</h1>
<PostList posts={posts} />
</div>
);
}Svelte 5 Approach
<script lang="ts">
let { userId }: { userId: string } = $props();
let user = $state.raw<User | null>(null);
let posts = $state.raw<Post[]>([]);
let isLoading = $state(true);
$effect(() => {
isLoading = true;
fetchUser(userId).then(data => {
user = data;
isLoading = false;
});
});
$effect(() => {
if (user) {
fetchPosts(userId).then(data => {
posts = data;
});
}
});
</script>
{#if isLoading}
<Skeleton />
{:else}
<h1>{user?.name}</h1>
<PostList {posts} />
{/if}Use Case 3: Real-Time Collaboration
React's re-render model makes real-time features expensive—every cursor movement from other users triggers a full component re-render. Svelte 5's signals enable surgical updates:
<script lang="ts">
let document = $state({ content: '', version: 0 });
let cursors = $state<Map<string, { x: number; y: number }>>(new Map());
$effect(() => {
const ws = new WebSocket('wss://collab.example.com/doc/123');
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'content') {
document.content = msg.content;
document.version = msg.version;
} else if (msg.type === 'cursor') {
// Only the specific cursor's position updates
cursors.set(msg.userId, { x: msg.x, y: msg.y });
}
};
return () => ws.close();
});
</script>
<!-- Each cursor updates independently -->
{#each [...cursors.entries()] as [userId, pos] (userId)}
<div class="remote-cursor" style="left: {pos.x}px; top: {pos.y}px">
{userId}
</div>
{/each}Best Practices for Production
-
Choose React for ecosystem breadth: If you need mature libraries for every conceivable use case, React's ecosystem is unmatched. State management, form handling, animation—there are battle-tested solutions for everything.
-
Choose Svelte 5 for performance-critical UIs: If you're building dashboards, data visualization, or real-time applications where update performance matters, Svelte 5's signal architecture provides measurably better performance.
-
Use React Query/SWR for data fetching in React: React's re-render model makes data fetching complex. Libraries like React Query handle caching, deduplication, and background updates that Svelte 5's
$effectcan handle natively. -
Prefer
$derivedover$effectfor computations: In Svelte 5, always use$derivedfor pure computations. Reserve$effectfor actual side effects like API calls or DOM manipulation. -
Avoid stale closures in React: Use
useCallbackfor event handlers anduseReffor values that need to persist across renders without causing re-renders. Theexhaustive-depsESLint rule is essential. -
Leverage Svelte 5's automatic dependency tracking: Don't try to manually manage dependencies like you would in React. The compiler handles it—focus on writing correct reactive expressions.
-
Consider team expertise: React's larger community means easier hiring and more resources. Svelte's smaller learning curve can mean faster onboarding for new developers.
-
Measure before optimizing: Both frameworks perform well for most applications. Profile with real data before choosing a framework based on theoretical performance advantages.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| React: Missing dependency in useEffect | Stale closure; effect uses outdated values | Use exhaustive-deps ESLint rule; include all referenced values |
| React: Unnecessary re-renders from context | Performance degradation in large apps | Split contexts; use memoization selectors (Zustand, Jotai) |
| React: Recreating objects in render | Child components re-render unnecessarily | useMemo for objects; useCallback for functions |
| Svelte: Reassigning $state object | Loses fine-grained reactivity | Mutate properties directly instead of reassigning |
| Svelte: Using $effect for derived values | Unnecessary side effects | Use $derived for pure computations |
| Svelte: Forgetting $state | Variables aren't reactive; UI doesn't update | Always use $state for mutable values that affect UI |
| Both: Premature optimization | Added complexity without measurable benefit | Profile first; optimize bottlenecks, not everything |
Performance Optimization
Benchmark: TodoMVC (1000 Items)
| Operation | React 18 (ms) | Svelte 5 (ms) | Winner |
|---|---|---|---|
| Initial render | 68 | 42 | Svelte (+38%) |
| Add 1 item | 2.1 | 0.3 | Svelte (+86%) |
| Toggle 1 item | 1.8 | 0.2 | Svelte (+89%) |
| Delete 1 item | 2.4 | 0.4 | Svelte (+83%) |
| Toggle all | 12.3 | 8.7 | Svelte (+29%) |
| Filter switch | 3.2 | 1.1 | Svelte (+66%) |
| Memory (10k items) | 24MB | 8MB | Svelte (+67%) |
The performance gap is most significant for single-item updates, where React must re-execute the component function and diff the virtual DOM, while Svelte only updates the specific DOM element.
When React Wins
React's virtual DOM approach can be advantageous for:
- Large tree updates: When many elements change simultaneously, React's batched updates can be more efficient than many individual signal propagations
- Server-side rendering: React's streaming SSR with Suspense is more mature
- Concurrent features: React 18's concurrent rendering enables features like transitions and deferred values
When Svelte 5 Wins
Svelte 5's signal approach excels for:
- Frequent small updates: Dashboards, real-time data, animations
- Large static portions: Components where most of the UI doesn't change
- Memory-constrained environments: Mobile devices, embedded web views
- Bundle size: ~2KB vs ~42KB runtime makes a difference for initial load
Advanced Patterns
Custom Hooks vs Composable Runes
React Custom Hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchInput() {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
if (debouncedSearch) {
fetchResults(debouncedSearch);
}
}, [debouncedSearch]);
return <input value={search} onChange={e => setSearch(e.target.value)} />;
}Svelte 5 Composable Rune
// src/lib/runes/useDebounce.svelte.ts
export function useDebounce<T>(value: () => T, delay: number) {
let debounced = $state(value());
$effect(() => {
const timeout = setTimeout(() => {
debounced = value();
}, delay);
return () => clearTimeout(timeout);
});
return { get current() { return debounced; } };
}
// Usage
<script lang="ts">
import { useDebounce } from '$lib/runes/useDebounce.svelte';
let search = $state('');
const debouncedSearch = useDebounce(() => search, 300);
$effect(() => {
if (debouncedSearch.current) {
fetchResults(debouncedSearch.current);
}
});
</script>
<input bind:value={search} />Testing Strategies
React Testing
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}
test('filters products by search', async () => {
renderWithProviders(<ProductList />);
await waitFor(() => {
expect(screen.getByText('Laptop')).toBeInTheDocument();
});
fireEvent.change(screen.getByPlaceholderText('Search...'), {
target: { value: 'phone' }
});
expect(screen.queryByText('Laptop')).not.toBeInTheDocument();
expect(screen.getByText('iPhone')).toBeInTheDocument();
});Svelte 5 Testing
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import ProductList from './ProductList.svelte';
describe('ProductList', () => {
it('filters products by search', async () => {
render(ProductList);
await screen.findByText('Laptop');
await fireEvent.input(screen.getByPlaceholderText('Search...'), {
target: { value: 'phone' }
});
expect(screen.queryByText('Laptop')).not.toBeInTheDocument();
expect(screen.getByText('iPhone')).toBeInTheDocument();
});
});Svelte 5's testing is simpler—no providers needed for state management since runes work without wrappers.
Future Outlook
Both frameworks are evolving toward similar goals:
- React is exploring compiler optimizations (React Forget/React Compiler) that automatically memoize components, reducing the re-render problem
- Svelte is expanding runes' capabilities with potential server components and streaming SSR improvements
The convergence suggests that the industry is moving toward signal-based reactivity as the optimal model for UI development. React's compiler work acknowledges that manual memoization is a poor developer experience, while Svelte's explicit runes acknowledge that fully implicit reactivity leads to confusion.
Conclusion
Svelte 5 runes and React hooks represent two philosophies of reactive UI development. React prioritizes a simple mental model (functions that re-execute) at the cost of runtime performance and boilerplate (memoization, dependency arrays). Svelte 5 prioritizes runtime performance and developer ergonomics (automatic dependency tracking, surgical updates) at the cost of requiring a compiler.
Key takeaways:
- Svelte 5 eliminates memoization boilerplate:
$derivedis always memoized; nouseMemooruseCallbackneeded - React's ecosystem is more mature: More libraries, more community resources, more hiring options
- Svelte 5 performs better for frequent updates: Signals update only affected DOM elements; React re-executes entire components
- Stale closures are a React-only problem: Svelte 5's signal architecture makes them impossible
- Bundle size favors Svelte: ~2KB vs ~42KB runtime makes a meaningful difference for initial load
- Both frameworks are converging: React is adding compiler optimizations; Svelte is adding explicitness
For new projects in 2024, the choice depends on priorities: choose React for ecosystem maturity and team familiarity, choose Svelte 5 for performance and developer experience. Both are excellent frameworks for building production applications.
Consult the Svelte 5 docs, React docs, and the framework comparison on Frontend Masters for deeper exploration.