SolidJS's reactivity system is its crown jewel, enabling fine-grained updates without the performance overhead of Virtual DOM reconciliation. This comprehensive guide explores the reactive primitives that make SolidJS incredibly fast and efficient.
Understanding SolidJS Reactivity
SolidJS uses a reactive programming model where computations automatically track their dependencies and update when those dependencies change. Unlike React's manual dependency management, SolidJS automatically tracks what data each computation depends on.
Key Concepts
- Automatic Dependency Tracking: No manual dependency arrays
- Fine-Grained Updates: Only update what changes
- No Virtual DOM: Direct DOM manipulation
- Push-based Reactivity: Changes propagate immediately
Signals: The Foundation of Reactivity
Signals are reactive state containers that notify dependents when their value changes.
Creating Signals
import { createSignal } from 'solid-js';
const [count, setCount] = createSignal(0);
// Read signal
console.log(count()); // 0
// Update signal
setCount(1);
console.log(count()); // 1
// Update based on previous value
setCount(c => c + 1);
console.log(count()); // 2Signal Best Practices
- 1Use Descriptive Names: Clear signal purposes
- 2Type Signals Properly: Leverage TypeScript
- 3Keep Signals Focused: Single responsibility
interface User {
id: number;
name: string;
email: string;
}
function UserProfile() {
const [user, setUser] = createSignal<User | null>(null);
const [isLoading, setIsLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
// Type-safe signal usage
const loadUser = async (id: number) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${id}`);
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
return (
<div>
<Show when={isLoading()}>
<p>Loading...</p>
</Show>
<Show when={error()}>
<p>Error: {error()}</p>
</Show>
<Show when={user()}>
<h1>{user()!.name}</h1>
<p>{user()!.email}</p>
</Show>
</div>
);
}Computed Signals
Derive values from other signals automatically:
function ShoppingCart() {
const [items, setItems] = createSignal<CartItem[]>([]);
const [taxRate, setTaxRate] = createSignal(0.08);
// Computed values
const subtotal = () => items().reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = () => subtotal() * taxRate();
const total = () => subtotal() + tax();
return (
<div>
<p>Subtotal: ${subtotal()}</p>
<p>Tax: ${tax()}</p>
<p>Total: ${total()}</p>
</div>
);
}Effects: Reacting to Changes
Effects run code in response to signal changes. They're perfect for side effects like DOM manipulation, API calls, or subscriptions.
Basic Effects
import { createEffect } from 'solid-js';
function DocumentTitle() {
const [title, setTitle] = createSignal('Home');
createEffect(() => {
document.title = title();
});
return <input value={title()} onInput={(e) => setTitle(e.currentTarget.value)} />;
}Effect Dependencies
Effects automatically track their dependencies:
function UserData() {
const [userId, setUserId] = createSignal(1);
const [user, setUser] = createSignal<User | null>(null);
// This effect automatically tracks userId()
createEffect(async () => {
const id = userId();
const response = await fetch(`/api/users/${id}`);
const userData = await response.json();
setUser(userData);
});
return (
<div>
<select onInput={(e) => setUserId(parseInt(e.currentTarget.value))}>
<option value="1">User 1</option>
<option value="2">User 2</option>
</select>
<Show when={user()}>
<h1>{user()!.name}</h1>
</Show>
</div>
);
}Effect Cleanup
Return a cleanup function to run before the effect re-runs:
function WebSocketComponent() {
const [messages, setMessages] = createSignal<Message[]>([]);
const [isConnected, setIsConnected] = createSignal(false);
createEffect(() => {
if (!isConnected()) return;
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => {
setMessages((msgs) => [...msgs, JSON.parse(event.data)]);
};
// Cleanup function
return () => {
ws.close();
};
});
return (
<div>
<button onClick={() => setIsConnected(!isConnected())}>
{isConnected() ? 'Disconnect' : 'Connect'}
</button>
<ul>
<For each={messages()}>
{(msg) => <li>{msg.text}</li>}
</For>
</ul>
</div>
);
}Multiple Dependencies
Effects can track multiple signals:
function RectangleArea() {
const [width, setWidth] = createSignal(10);
const [height, setHeight] = createSignal(5);
const [area, setArea] = createSignal(0);
createEffect(() => {
// Tracks both width() and height()
setArea(width() * height());
});
return (
<div>
<input type="number" value={width()} onInput={(e) => setWidth(parseFloat(e.currentTarget.value))} />
<input type="number" value={height()} onInput={(e) => setHeight(parseFloat(e.currentTarget.value))} />
<p>Area: {area()}</p>
</div>
);
}Memos: Cached Computed Values
Memos cache computed values and only recalculate when dependencies change:
import { createMemo } from 'solid-js';
function ExpensiveList() {
const [items, setItems] = createSignal<number[]>([1, 2, 3, 4, 5]);
const [filter, setFilter] = createSignal('');
// Memoized filtered list
const filteredItems = createMemo(() => {
console.log('Filtering items...');
return items().filter(item => item.toString().includes(filter()));
});
// Memoized sum
const sum = createMemo(() => {
console.log('Calculating sum...');
return filteredItems().reduce((a, b) => a + b, 0);
});
return (
<div>
<input
value={filter()}
onInput={(e) => setFilter(e.currentTarget.value)}
placeholder="Filter items"
/>
<p>Filtered: {filteredItems().join(', ')}</p>
<p>Sum: {sum()}</p>
</div>
);
}When to Use Memos
- 1Expensive Computations: Filter, map, reduce large arrays
- 2Derived State: Computations based on multiple signals
- 3Performance Optimization: Avoid redundant calculations
function ProductList() {
const [products, setProducts] = createSignal<Product[]>([]);
const [searchTerm, setSearchTerm] = createSignal('');
const [category, setCategory] = createSignal<string>('all');
// Memoized filtered products
const filteredProducts = createMemo(() => {
return products().filter(product => {
const matchesSearch = product.name.toLowerCase().includes(searchTerm().toLowerCase());
const matchesCategory = category === 'all' || product.category === category;
return matchesSearch && matchesCategory;
});
});
// Memoized statistics
const stats = createMemo(() => {
const products = filteredProducts();
return {
total: products.length,
avgPrice: products.reduce((sum, p) => sum + p.price, 0) / products.length || 0,
maxPrice: Math.max(...products.map(p => p.price), 0),
};
});
return (
<div>
<input value={searchTerm()} onInput={(e) => setSearchTerm(e.currentTarget.value)} />
<select onInput={(e) => setCategory(e.currentTarget.value)}>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<div>
<p>Total: {stats().total}</p>
<p>Average Price: ${stats().avgPrice.toFixed(2)}</p>
</div>
<For each={filteredProducts()}>
{(product) => (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
)}
</For>
</div>
);
}Reactive Utilities
SolidJS provides several utilities for working with reactivity.
createRoot
Create an isolated reactive context:
import { createRoot, createSignal } from 'solid-js';
createRoot((dispose) => {
const [count, setCount] = createSignal(0);
// All signals and effects here are isolated
createEffect(() => {
console.log('Count:', count());
});
// Call dispose() to clean up
});onMount
Run code after component mounts:
import { onMount } from 'solid-js';
function DataComponent() {
const [data, setData] = createSignal(null);
onMount(async () => {
const response = await fetch('/api/data');
const json = await response.json();
setData(json);
});
return <div>{/* render data */}</div>;
}onCleanup
Run cleanup code when component unmounts:
import { onCleanup } from 'solid-js';
function TimerComponent() {
const [seconds, setSeconds] = createSignal(0);
const timer = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
onCleanup(() => {
clearInterval(timer);
});
return <div>Seconds: {seconds()}</div>;
}catchError
Handle errors in effects:
import { catchError } from 'solid-js';
createEffect(() => {
catchError(() => {
// Code that might throw
const result = riskyOperation(data());
setData(result);
}, (error) => {
console.error('Error:', error);
setError(error.message);
});
});on
Run effects only when specific dependencies change:
import { on } from 'solid-js';
function UserProfile() {
const [userId, setUserId] = createSignal(1);
const [user, setUser] = createSignal<User | null>(null);
// Only re-run when userId changes
createEffect(on(userId, async (id) => {
const response = await fetch(`/api/users/${id}`);
const userData = await response.json();
setUser(userData);
}));
return <div>{/* render user */}</div>;
}batch
Group multiple updates together:
import { batch } from 'solid-js';
function FormComponent() {
const [firstName, setFirstName] = createSignal('');
const [lastName, setLastName] = createSignal('');
const [fullName, setFullName] = createSignal('');
const updateName = () => {
batch(() => {
setFirstName('John');
setLastName('Doe');
setFullName('John Doe');
});
// Only one update happens here
};
return <div>{/* render form */}</div>;
}Advanced Reactivity Patterns
Reactive Collections
function TodoList() {
const [todos, setTodos] = createSignal<Todo[]>([]);
// Reactive operations
const addTodo = (text: string) => {
setTodos((todos) => [...todos, { id: Date.now(), text, completed: false }]);
};
const toggleTodo = (id: number) => {
setTodos((todos) =>
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const removeTodo = (id: number) => {
setTodos((todos) => todos.filter((todo) => todo.id !== id));
};
// Reactive computed values
const activeTodos = () => todos().filter((todo) => !todo.completed);
const completedTodos = () => todos().filter((todo) => todo.completed);
return (
<div>
<For each={todos()}>
{(todo) => (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>Remove</button>
</div>
)}
</For>
</div>
);
}Reactive Forms
function createForm<T>(initialValues: T) {
const [values, setValues] = createSignal<T>(initialValues);
const [errors, setErrors] = createSignal<Record<keyof T, string>>({} as any);
const [touched, setTouched] = createSignal<Record<keyof T, boolean>>({} as any);
const setValue = (field: keyof T, value: T[keyof T]) => {
setValues((values) => ({ ...values, [field]: value }));
};
const setFieldTouched = (field: keyof T) => {
setTouched((touched) => ({ ...touched, [field]: true }));
};
const setFieldError = (field: keyof T, error: string) => {
setErrors((errors) => ({ ...errors, [field]: error }));
};
const reset = () => {
setValues(initialValues);
setErrors({} as any);
setTouched({} as any);
};
return {
get values() { return values(); },
get errors() { return errors(); },
get touched() { return touched(); },
setValue,
setFieldTouched,
setFieldError,
reset,
};
}Reactive Async Operations
function useAsync<T>(asyncFn: () => Promise<T>) {
const [data, setData] = createSignal<T | null>(null);
const [error, setError] = createSignal<Error | null>(null);
const [isLoading, setIsLoading] = createSignal(false);
const execute = async () => {
setIsLoading(true);
setError(null);
try {
const result = await asyncFn();
setData(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setIsLoading(false);
}
};
return {
get data() { return data(); },
get error() { return error(); },
get isLoading() { return isLoading(); },
execute,
};
}Performance Best Practices
- 1Use Memos for Expensive Computations: Cache derived values
- 2Batch Updates: Group multiple state changes
- 3Avoid Inline Functions: Define handlers outside render
- 4Use For for Lists: Efficient list rendering
- 5Leverage Fine-Grained Updates: Let SolidJS optimize updates
Common Pitfalls
- 1Forgetting to Call Signals: Remember to call signal functions
- 2Mutating State Directly: Always use setters
- 3Creating Signals in Effects: Create signals in component scope
- 4Ignoring Cleanup: Clean up subscriptions and timers
Conclusion
SolidJS's reactivity system provides a powerful, efficient way to manage state and side effects. By understanding signals, effects, memos, and reactive utilities, you can build applications that are both performant and maintainable. The automatic dependency tracking eliminates the need for manual dependency management, while fine-grained updates ensure optimal performance.
Master these reactive primitives, and you'll be able to create complex, reactive applications with confidence and efficiency.