18.1 Introduction to Solid.js
Solid.js is a declarative UI library centered on Fine-grained Reactivity. Created by Ryan Carniato, it achieves top-tier performance through Signal-based direct DOM updates — with no virtual DOM. While it retains the familiar JSX syntax and component model from React, its internal behavior is fundamentally different.
What is Solid.js?
Solid.js is a UI library that Ryan Carniato began developing in 2018 and released as v1.0 in 2021. The name "Solid" reflects its goal of a predictable and efficient reactivity system.
Core Philosophy of Solid.js
- Declarative UI: Declare what the state is, and Solid synchronizes the DOM automatically
- Fine-grained Reactivity: Updates only the exact DOM nodes that changed, not the entire component
- No Virtual DOM: Instead of comparing virtual trees at runtime, Signals directly manipulate the DOM
- Component functions run only once: Unlike React, component functions do not re-run on re-renders
Origin
Ryan Carniato spent years researching the idea that optimal UI updates could be achieved through reactivity alone, without the diffing cost of a virtual DOM. Solid.js was born from combining Knockout.js's reactivity system with React's JSX and component model.
How Top Performance Is Achieved Without a Virtual DOM
React's Approach (Virtual DOM)
State change
→ Component function re-runs (new virtual DOM tree created)
→ New virtual DOM diffed against the previous one
→ Only changed parts applied to the real DOM (patching)
This approach re-runs the entire component function every time, and incurs the cost of virtual DOM diffing.
Solid.js's Approach (Signal-based Direct Updates)
State (Signal) change
→ Effects subscribed to the Signal run immediately
→ Only the relevant DOM node is updated directly
In Solid.js, component functions run only once during initialization. When state changes afterward, the component function does not re-run — only the specific DOM update logic bound to the Signal is executed.
How It Works Internally
// Solid.js component
function Counter() {
const [count, setCount] = createSignal(0); // Create a Signal
// This function runs only once
// JSX is compiled into DOM update code wrapped in createEffect
return (
<div>
{/* count() is the Signal getter — tracked inside an Effect */}
<p>Count: {count()}</p>
<button onClick={() => setCount(count() + 1)}>Increment</button>
</div>
);
}
The code actually generated after compilation (simplified):
// Compiled output (conceptual representation)
function Counter() {
const [count, setCount] = createSignal(0);
// DOM nodes created (once)
const div = document.createElement('div');
const p = document.createElement('p');
const button = document.createElement('button');
p.textContent = 'Count: ' + count(); // Set initial value
// Register an Effect that subscribes to the count Signal
createEffect(() => {
// Only this text node is updated when count() changes
p.firstChild.nodeValue = 'Count: ' + count();
});
button.textContent = 'Increment';
button.addEventListener('click', () => setCount(count() + 1));
div.append(p, button);
return div;
}
Since the component function never re-runs, there is absolutely no unnecessary recomputation.
React vs Vue vs Svelte vs Solid.js Comparison
| Item | React 18 | Vue 3 | Svelte 5 | Solid.js 1.x |
|---|---|---|---|---|
| Reactivity | Virtual DOM + diffing | Virtual DOM + Proxy | Compiler-generated code | Signal-based fine-grained reactivity |
| Bundle size | ~45 KB (gzip) | ~33 KB (gzip) | ~3 KB (runtime) | ~7 KB (gzip) |
| Component re-runs | On every state change | On every state change | No re-runs | No re-runs |
| Render performance | Good | Good | Excellent | Excellent |
| Memory usage | Medium | Medium | Low | Low |
| Learning curve | Medium | Low–Medium | Low | Medium |
| Ecosystem size | Very large | Large | Medium | Small–Medium |
| TypeScript support | Good | Good | Good | Good |
| SSR support | Next.js | Nuxt | SvelteKit | SolidStart |
| Built-in state mgmt | Context/Zustand separate | Pinia separate | Store built-in | Store built-in |
| Syntax familiarity | JSX | Template | Proprietary | JSX (React-like) |
| File extension | .jsx/.tsx | .vue | .svelte | .jsx/.tsx |
Summary
- React: Largest ecosystem, proven technology, enterprise standard
- Vue: Approachable syntax, extensive learning resources
- Svelte: Minimal bundle via compiler, intuitive syntax
- Solid.js: Top performance, React-like syntax, cutting-edge trend
Why Solid.js Ranks at the Top of js-framework-benchmark
js-framework-benchmark is a framework performance comparison project maintained by Stefan Krause that measures various scenarios including rendering 1,000 rows, updating, deleting, and selecting items.
Why Solid.js Ranks So High
1. Fine-grained Updates
React: Change one item in a list → re-run parent component → diff entire list
Solid: Change one item in a list → update only that item's Signal → change only that text node
2. Minimal Initialization Overhead
Since component functions run only once, there is no need for closure recreation, dependency array comparisons, or Hook rule checks.
3. Automatic Batching
When multiple Signals change simultaneously, updates are automatically batched.
import { batch } from 'solid-js';
// Two Signal changes processed as a single DOM update
batch(() => {
setName('John Doe');
setAge(30);
});
4. No Forced Immutability
React requires creating a new object like setState({ ...prev, name: 'new name' }), but Solid allows direct mutation via createStore.
Benchmark Numbers (2024, lower is faster)
| Scenario | Solid.js | React 18 | Vue 3 |
|---|---|---|---|
| Create 1,000 rows (ms) | ~35 | ~55 | ~45 |
| Create 10,000 rows (ms) | ~310 | ~510 | ~420 |
| Replace 100 rows (ms) | ~16 | ~28 | ~22 |
| Highlight selected row (ms) | ~3 | ~8 | ~6 |
| Memory usage (MB) | ~4 | ~7 | ~6 |
Actual numbers vary by hardware, browser, and version.
Basic Component Examples
Hello World
// src/App.jsx
import { createSignal } from 'solid-js';
function App() {
return <h1>Hello, Solid.js!</h1>;
}
export default App;
// src/index.jsx
import { render } from 'solid-js/web';
import App from './App';
render(() => <App />, document.getElementById('root'));
Counter Component
import { createSignal, createMemo } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(0);
const [step, setStep] = createSignal(1);
// createMemo: computed value (recalculated only when count or step changes)
const doubled = createMemo(() => count() * 2);
const isEven = createMemo(() => count() % 2 === 0);
return (
<div class="counter">
<h2>Count: {count()}</h2>
<p>Doubled: {doubled()}</p>
<p>Parity: {isEven() ? 'Even' : 'Odd'}</p>
<div class="controls">
<label>
Step:
<input
type="number"
value={step()}
onInput={(e) => setStep(Number(e.target.value))}
min="1"
max="10"
/>
</label>
<button onClick={() => setCount(c => c - step())}>-{step()}</button>
<button onClick={() => setCount(0)}>Reset</button>
<button onClick={() => setCount(c => c + step())}>+{step()}</button>
</div>
</div>
);
}
export default Counter;
TodoApp Component
import { createSignal, createMemo, For, Show } from 'solid-js';
function TodoApp() {
const [todos, setTodos] = createSignal([
{ id: 1, text: 'Learn Solid.js', done: false },
{ id: 2, text: 'Understand fine-grained reactivity', done: false },
{ id: 3, text: 'Master Signals', done: true },
]);
const [input, setInput] = createSignal('');
const [filter, setFilter] = createSignal('all'); // 'all' | 'active' | 'done'
const remaining = createMemo(() =>
todos().filter(t => !t.done).length
);
const filteredTodos = createMemo(() => {
switch (filter()) {
case 'active': return todos().filter(t => !t.done);
case 'done': return todos().filter(t => t.done);
default: return todos();
}
});
const addTodo = () => {
const text = input().trim();
if (!text) return;
setTodos(prev => [
...prev,
{ id: Date.now(), text, done: false },
]);
setInput('');
};
const toggleTodo = (id) => {
setTodos(prev =>
prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
};
const removeTodo = (id) => {
setTodos(prev => prev.filter(t => t.id !== id));
};
const clearDone = () => {
setTodos(prev => prev.filter(t => !t.done));
};
return (
<div class="todo-app">
<h1>
Todo List
<span class="badge">{remaining()}</span>
</h1>
{/* Input form */}
<div class="input-row">
<input
value={input()}
onInput={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
placeholder="Add a new todo..."
/>
<button onClick={addTodo}>Add</button>
</div>
{/* Filter tabs */}
<div class="filters">
<For each={['all', 'active', 'done']}>
{(f) => (
<button
class={filter() === f ? 'active' : ''}
onClick={() => setFilter(f)}
>
{f === 'all' ? 'All' : f === 'active' ? 'Active' : 'Done'}
</button>
)}
</For>
</div>
{/* List */}
<ul>
<For each={filteredTodos()} fallback={<p>No items found.</p>}>
{(todo) => (
<li class={todo.done ? 'done' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>×</button>
</li>
)}
</For>
</ul>
{/* Bulk delete completed items */}
<Show when={todos().some(t => t.done)}>
<button class="clear-btn" onClick={clearDone}>
Clear Completed
</button>
</Show>
</div>
);
}
export default TodoApp;
Projects Where Solid.js Excels
Great Fits
- Apps where performance is critical: Large data tables, real-time dashboards, stock/trading UIs
- React developers learning something new: Low switching cost thanks to identical JSX syntax
- Bundle size-constrained environments: ~7KB gzip, roughly 6× smaller than React
- Developers who want a deep understanding of reactivity principles
- SPAs (Single Page Applications): SolidStart also supports SSR
- Game UIs, interactive visualizations: Heavy animation and frequent state updates
- Embedded widgets: Small UIs inserted into other applications
Less Ideal Fits
- Large team projects: Harder to hire experienced Solid developers compared to React/Vue
- When a rich UI component library is needed: React's ecosystem (Material UI, Ant Design, etc.) is far larger
- CMS-driven content sites: Next.js/Nuxt are more mature solutions
- Teams learning a framework for the first time: React/Vue have far more learning resources available
- Teams unfamiliar with Solid.js-specific gotchas: There are pitfalls such as the no-destructuring rule
Practical Example: Real-time Search Filter
import { createSignal, createMemo, For } from 'solid-js';
const FRUITS = [
'Apple', 'Banana', 'Cherry', 'Strawberry', 'Watermelon', 'Grape',
'Peach', 'Mango', 'Pineapple', 'Blueberry', 'Raspberry', 'Kiwi',
];
function SearchFilter() {
const [query, setQuery] = createSignal('');
// Recomputed only when query() changes (no component re-run!)
const filtered = createMemo(() => {
const q = query().toLowerCase();
return FRUITS.filter(f => f.toLowerCase().includes(q));
});
return (
<div>
<input
type="search"
value={query()}
onInput={(e) => setQuery(e.target.value)}
placeholder="Search fruits..."
style="padding: 0.5rem; width: 100%; margin-bottom: 1rem;"
/>
<p>
{filtered().length} result(s)
{query() && ` for "${query()}"`}
</p>
<ul>
<For each={filtered()} fallback={<li>No results found.</li>}>
{(fruit) => <li>{fruit}</li>}
</For>
</ul>
</div>
);
}
export default SearchFilter;
Practical Example: Timer (with cleanup)
import { createSignal, onCleanup, onMount } from 'solid-js';
function Timer() {
const [seconds, setSeconds] = createSignal(0);
const [running, setRunning] = createSignal(false);
let intervalId;
const start = () => {
if (running()) return;
setRunning(true);
intervalId = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const pause = () => {
setRunning(false);
clearInterval(intervalId);
};
const reset = () => {
pause();
setSeconds(0);
};
// Clean up the interval when the component unmounts
onCleanup(() => {
clearInterval(intervalId);
});
const formatTime = (s) => {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
};
return (
<div class="timer">
<div class="display">{formatTime(seconds())}</div>
<div class="controls">
<button onClick={start} disabled={running()}>Start</button>
<button onClick={pause} disabled={!running()}>Pause</button>
<button onClick={reset}>Reset</button>
</div>
</div>
);
}
export default Timer;
Pro Tips
Tip 1: The Fundamental Difference from React — Component Functions Run Only Once
This is the most important concept. It is the most common mistake React developers make when switching to Solid.
// ❌ React mindset (wrong approach in Solid)
function BadComponent() {
const [count, setCount] = createSignal(0);
// This console.log runs only once during initialization!
// Since the component function doesn't re-run on count changes, no log is printed
console.log('Rendered, count =', count()); // Reading count() here is not tracked
return <div>{count()}</div>;
}
// ✅ Correct approach: read Signals inside createEffect or JSX
function GoodComponent() {
const [count, setCount] = createSignal(0);
createEffect(() => {
// Must be read inside an Effect to enable reactive tracking
console.log('count changed:', count());
});
return <div>{count()}</div>;
}
Tip 2: Destructuring Destroys Reactivity
// ❌ Never do this — reactivity is lost!
function BadDestructure() {
const [state, setState] = createSignal({ name: 'John', age: 30 });
const { name, age } = state(); // ← Destructuring here breaks reactivity!
return <div>{name}</div>; // name will never update
}
// ✅ Correct approach: access Signals directly in JSX
function GoodAccess() {
const [state, setState] = createSignal({ name: 'John', age: 30 });
return <div>{state().name}</div>; // Always returns the latest value
}
Tip 3: Visualize the Signal Tree with Solid.js DevTools
Installing the Solid DevTools browser extension lets you visually inspect which Signals are connected to which components and Effects. This makes it easy to spot unnecessary updates at a glance.
Tip 4: Block Reactivity with untrack
import { createSignal, createEffect, untrack } from 'solid-js';
function Example() {
const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);
createEffect(() => {
// Runs only when a changes
// b() is read but not tracked (using untrack)
console.log('a =', a(), ', b (current) =', untrack(b));
});
return (
<div>
<button onClick={() => setA(a() + 1)}>Increment A (triggers Effect)</button>
<button onClick={() => setB(b() + 1)}>Increment B (no Effect trigger)</button>
</div>
);
}
Tip 5: Performance Measurement — No Need for why-did-you-render
Since Solid.js components never re-run, tools like React's why-did-you-render are unnecessary. Instead, measure only real DOM updates using the Performance tab in Chrome DevTools.
Tip 6: Create an Independent Reactive Context with createRoot
import { createRoot, createSignal } from 'solid-js';
// When you need a reactive context outside of a component
const dispose = createRoot((dispose) => {
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log('count:', count());
});
// Calling dispose() later cleans up all reactivity
return dispose;
});
// Clean up after 5 seconds
setTimeout(dispose, 5000);
Summary
| Concept | Description |
|---|---|
| Fine-grained Reactivity | Updates only the exact DOM nodes that changed |
| Signal | The fundamental reactive state unit in Solid.js |
| Component runs once | No re-rendering; Effects handle updates instead |
| No Virtual DOM | Zero runtime comparison cost |
| Bundle size | ~7KB gzip (~6× smaller than React) |
| No destructuring | Destructuring props/signals breaks reactivity |
| Ideal projects | High-performance SPAs, real-time UIs, embedded widgets |
| JSX syntax | Same as React, but fundamentally different internal behavior |
In the next chapter, we will set up a Solid.js development environment and explore the project structure.