Skip to main content
Advertisement

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

  1. Declarative UI: Declare what the state is, and Solid synchronizes the DOM automatically
  2. Fine-grained Reactivity: Updates only the exact DOM nodes that changed, not the entire component
  3. No Virtual DOM: Instead of comparing virtual trees at runtime, Signals directly manipulate the DOM
  4. 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

ItemReact 18Vue 3Svelte 5Solid.js 1.x
ReactivityVirtual DOM + diffingVirtual DOM + ProxyCompiler-generated codeSignal-based fine-grained reactivity
Bundle size~45 KB (gzip)~33 KB (gzip)~3 KB (runtime)~7 KB (gzip)
Component re-runsOn every state changeOn every state changeNo re-runsNo re-runs
Render performanceGoodGoodExcellentExcellent
Memory usageMediumMediumLowLow
Learning curveMediumLow–MediumLowMedium
Ecosystem sizeVery largeLargeMediumSmall–Medium
TypeScript supportGoodGoodGoodGood
SSR supportNext.jsNuxtSvelteKitSolidStart
Built-in state mgmtContext/Zustand separatePinia separateStore built-inStore built-in
Syntax familiarityJSXTemplateProprietaryJSX (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)

ScenarioSolid.jsReact 18Vue 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

ConceptDescription
Fine-grained ReactivityUpdates only the exact DOM nodes that changed
SignalThe fundamental reactive state unit in Solid.js
Component runs onceNo re-rendering; Effects handle updates instead
No Virtual DOMZero runtime comparison cost
Bundle size~7KB gzip (~6× smaller than React)
No destructuringDestructuring props/signals breaks reactivity
Ideal projectsHigh-performance SPAs, real-time UIs, embedded widgets
JSX syntaxSame 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.

Advertisement