Solid.js: Building Reactive Web Apps
You've got a React dashboard with a table that updates every second. Every tick, a big chunk of the component tree re-renders, and you end up sprinkling React.memo, useMemo and useCallback everywhere just to keep scrolling smooth. Then a missing dependency in a useEffect array gives you a stale value, and you lose an afternoon tracking it down.
Solid.js, created by Ryan Carniato, attacks that problem from a different angle. It uses JSX and components like React, but there's no virtual DOM and components don't re-render. Signals update the exact DOM nodes that read them. In this post I'll cover how Solid differs from React, how to set up a project, signals, memos and effects, components and props (including the destructuring trap), control flow, stores, async data, and when I'd pick Solid over React.
Solid sits near the top of the js-framework-benchmark, close to hand-written vanilla JS, and well ahead of React on most DOM operations. The runtime is small too: roughly 7 KB gzipped, versus 40+ KB for React plus ReactDOM. Around it you get solid-router, SolidStart (a full-stack meta-framework) and TypeScript support out of the box.
What is Solid.js?
On the surface, Solid looks almost identical to React. You write JSX, compose components, and manage state. Underneath, the execution model differs in three important ways:
- No Virtual DOM: Solid compiles your JSX into real DOM operations at build time. Nothing gets diffed on each update. The DOM is patched only where data actually changed.
- Components run once: In React, a component function re-executes on every state change. In Solid, it runs exactly once during setup. After that, signals and effects do the work.
- Fine-grained reactivity: Solid knows which DOM nodes depend on which signals. When a signal changes, only the nodes that read it are updated. No subtree re-renders, no reconciliation phase.
The design borrows from reactive libraries like MobX and Knockout.js, applied to a modern component model with compiled JSX. Solid 1.0 shipped in 2021 after Carniato had spent years working on reactive systems.
Solid.js vs React: Key Differences
Plenty of React patterns carry over directly, but the mental model is different, and it helps to see the mapping side by side before writing code.
| Feature | Solid.js | React |
|---|---|---|
| Virtual DOM | No, compiles to direct DOM ops | Yes, reconciles a virtual tree |
| Re-renders | Never; only signal subscribers update | Component function re-runs on state change |
| State primitive | createSignal (getter/setter pair) |
useState (value + setter) |
| Derived state | createMemo (auto-memoized) |
useMemo (manual deps array) |
| Side effects | createEffect (auto-tracks deps) |
useEffect (manual deps array) |
| Bundle size | ~7 KB gzipped | 40+ KB gzipped (React + ReactDOM) |
| Performance | Near-native DOM speed | Good, but with VDOM overhead |
| Component model | Runs once, reactive via signals | Re-runs on every render cycle |
| Ecosystem maturity | Growing (solid-router, SolidStart) | Mature (Next.js, thousands of libs) |
The dependency array in React's useEffect causes a lot of bugs. People forget a dependency and get a stale closure, or list too many and the effect re-runs constantly. Solid's createEffect has no array. It records which signals were read while it ran and subscribes to those. That whole class of bug goes away, though you trade it for a new one (reading a signal outside a tracking scope), which I'll get to.
Getting Started with Solid.js
Setup takes a minute. Use the official Vite templates, or the npm create solid@latest CLI, which also scaffolds SolidStart projects.
Install Node.js
You need a recent Node.js. Current Vite versions want Node 20 or newer, so older installs will fail at npm run dev. Download from nodejs.org or use nvm to manage versions. Verify with node --version.
Create a New Solid App
Use the official Vite-based template with npx degit, or use the interactive npm create solid@latest CLI for a SolidStart project with routing included.
# Option A: Vite + Solid template (recommended for beginners)
npx degit solidjs/templates/js my-solid-app
# Option B: TypeScript variant
npx degit solidjs/templates/ts my-solid-app
# Option C: SolidStart (full-stack meta-framework)
npm create solid@latest my-solid-app
# Navigate into the project
cd my-solid-app
# Install dependencies
npm install
# Start the dev server (hot module replacement included)
npm run dev
Install Dependencies and Run
After npm install, run npm run dev to start the Vite dev server on http://localhost:3000 (or 5173 for newer Vite). Solid uses Vite's HMR for instant feedback.
Here's what your project structure looks like after scaffolding:
my-solid-app/
├── src/
│ ├── App.jsx # Root component
│ ├── App.module.css # CSS Modules (scoped styles)
│ ├── index.jsx # Entry point, mounts to #root
│ └── logo.svg
├── index.html # Vite HTML entry
├── package.json
└── vite.config.js # Vite config with Solid plugin
The entry point src/index.jsx uses Solid's render function. It does the same job as React's ReactDOM.createRoot, with one difference: you pass a function that returns the component, not the element itself.
import { render } from 'solid-js/web';
import App from './App';
// Mount the root component to #root in index.html
render(() => <App />, document.getElementById('root'));
Signals: The Core of Solid Reactivity
Everything in Solid comes back to signals. You get a getter/setter pair, and the getter is a function: calling it returns the current value and registers whoever called it as a subscriber. Calling the setter re-runs every subscriber.
So calling a signal as a function (count()) is how Solid tracks dependencies. Inside a reactive context (an effect, a memo, a JSX expression), every signal read gets wired up as a subscription. When the signal updates, only those subscribers run, not whole component trees. The flip side: if you read count() in the plain body of a component, outside any of those contexts, you get the value once and it never updates. That's the most common "why isn't my UI changing?" bug in Solid.
Signals propagate changes directly to their subscribers (memos, effects and DOM nodes) with no diffing step in between.
A signal is created with createSignal(initialValue). It returns a tuple of [getter, setter]. Call the getter as a function to read the value. That call is what registers the dependency.
import { createSignal } from 'solid-js';
function Counter() {
// count is a getter function, setCount is a setter
const [count, setCount] = createSignal(0);
return (
<div>
{/* Calling count() inside JSX tracks the dependency */}
<p>Count: {count()}</p>
<button onClick={() => setCount(count() + 1)}>
Increment
</button>
<button onClick={() => setCount(c => c - 1)}>
Decrement (functional update)
</button>
</div>
);
}
When setCount is called, only the DOM node showing {count()} updates. The component function doesn't run again.
createMemo creates a derived signal whose value is automatically recalculated only when its dependencies change. Unlike React's useMemo, there is no dependency array to maintain.
import { createSignal, createMemo } from 'solid-js';
function TemperatureConverter() {
const [celsius, setCelsius] = createSignal(0);
// createMemo auto-tracks that it depends on celsius()
const fahrenheit = createMemo(() => celsius() * 9/5 + 32);
const kelvin = createMemo(() => celsius() + 273.15);
return (
<div>
<input
type="number"
value={celsius()}
onInput={e => setCelsius(+e.target.value)}
/>
<p>{celsius()}°C = {fahrenheit()}°F = {kelvin()}K</p>
</div>
);
}
createMemo recomputes only when celsius() changes and caches the result, so several consumers can read it without repeating the work. For something this cheap, a plain function like () => celsius() * 9/5 + 32 would also work; memos pay off when the computation is expensive or has many readers.
createEffect runs a side effect whenever its reactive dependencies change. It tracks whichever signals are read while it runs, so there's no dependency array.
import { createSignal, createEffect } from 'solid-js';
function UserTracker() {
const [userId, setUserId] = createSignal(1);
const [user, setUser] = createSignal(null);
// createEffect auto-tracks userId() as a dependency
createEffect(() => {
// Runs immediately, then re-runs whenever userId() changes
console.log('Fetching user:', userId());
fetch(`https://api.example.com/users/${userId()}`)
.then(r => r.json())
.then(data => setUser(data));
});
return (
<div>
<button onClick={() => setUserId(id => id + 1)}>
Next User
</button>
<p>{user()?.name ?? 'Loading...'}</p>
</div>
);
}
Two gotchas here. First, unlike React, returning a function from createEffect does not register a cleanup (the return value is passed to the next run as the previous value). Use onCleanup inside the effect for timers and subscriptions. Second, if the user clicks fast, responses can arrive out of order and an older user can overwrite a newer one. For fetching, createResource (below) handles that for you.
Building Components in Solid.js
Components look like React function components: they take props and return JSX. The big difference is that a Solid component function runs exactly once. There's no render cycle. Everything reactive is set up during that single call, through signals and effects.
In Solid, props are reactive objects. Don't write const { name } = props. Destructuring reads the value once, at that moment, so the child stops updating when the parent changes it. Access props as props.name, and use splitProps from solid-js if you need to forward a subset. Install eslint-plugin-solid: its solid/reactivity rule flags this and similar mistakes before you ship them.
import { createSignal } from 'solid-js';
// Child component: props are reactive, access as props.name
function Greeting(props) {
return (
<div class="greeting">
{/* props.name is reactive and updates when parent changes it */}
<h2>Hello, {props.name}!</h2>
<p>Role: {props.role ?? 'Developer'}</p>
{/* props.children renders nested content */}
<div class="content">{props.children}</div>
</div>
);
}
// Parent component: passes props and children
function App() {
const [name, setName] = createSignal('Mayur');
return (
<div>
<input
value={name()}
onInput={e => setName(e.target.value)}
placeholder="Enter your name"
/>
<Greeting name={name()} role="Full Stack Dev">
{/* These become props.children in Greeting */}
<p>Welcome to Solid.js!</p>
</Greeting>
</div>
);
}
Using mergeProps and splitProps
When you need to handle default values or forward a subset of props, use mergeProps and splitProps. Unlike plain destructuring or spreading into a new object, both keep the props reactive:
import { mergeProps, splitProps } from 'solid-js';
function Button(props) {
// mergeProps provides reactive defaults
const merged = mergeProps({ type: 'button', variant: 'primary' }, props);
// splitProps separates local props from forwarded props
const [local, rest] = splitProps(merged, ['variant', 'children']);
return (
<button
{...rest}
class={`btn btn-${local.variant}`}
>
{local.children}
</button>
);
}
Control Flow, Stores & Async Data
Since components don't re-render, an if/else in the component body runs once and never again. Ternaries and .map() inside JSX expressions do technically react, but a .map() recreates every row whenever the array changes. Solid ships control flow components (<Show>, <For>, <Switch>, <Index>) that hook into signals properly and only touch what changed.
Conditional Rendering with <Show>
import { createSignal } from 'solid-js';
import { Show } from 'solid-js';
function LoginToggle() {
const [isLoggedIn, setIsLoggedIn] = createSignal(false);
return (
<div>
<Show
when={isLoggedIn()}
fallback={<button onClick={() => setIsLoggedIn(true)}>Log In</button>}
>
{/* Only mounted when isLoggedIn() is true */}
<div>
<p>Welcome back!</p>
<button onClick={() => setIsLoggedIn(false)}>Log Out</button>
</div>
</Show>
</div>
);
}
List Rendering with <For>
import { createSignal } from 'solid-js';
import { For } from 'solid-js';
function TodoList() {
const [todos, setTodos] = createSignal([
{ id: 1, text: 'Learn Solid.js', done: false },
{ id: 2, text: 'Build something reactive', done: false },
]);
const addTodo = (text) => {
setTodos(t => [...t, { id: Date.now(), text, done: false }]);
};
return (
<ul>
{/* For provides a key-tracked, reactive list */}
<For each={todos()}>
{(todo, index) => (
<li>
{index() + 1}. {todo.text}
</li>
)}
</For>
</ul>
);
}
Complex State with createStore
A signal holding a big object is all-or-nothing: replace the object and everything reading it updates. For nested objects and arrays, createStore gives you reactivity per property. It wraps your state in a Proxy that tracks property reads, so changing address.city only updates what reads address.city.
import { createStore } from 'solid-js/store';
function UserProfile() {
const [user, setUser] = createStore({
name: 'Mayur Dabhi',
skills: ['JavaScript', 'React', 'Solid.js'],
address: {
city: 'Mumbai',
country: 'India',
},
});
// Update a deeply nested property; only that node in the DOM updates
const updateCity = (city) => setUser('address', 'city', city);
// Append to a nested array
const addSkill = (skill) =>
setUser('skills', s => [...s, skill]);
return (
<div>
<h2>{user.name}</h2>
<p>City: {user.address.city}</p>
<button onClick={() => updateCity('Bengaluru')}>
Change City
</button>
</div>
);
}
Async Data with createResource
For fetching, use createResource instead of a hand-rolled effect. It works with <Suspense>, exposes loading and error on the returned getter, and when the source signal changes mid-request it only keeps the latest response, which fixes the race condition from the effect example.
import { createSignal, createResource, Suspense } from 'solid-js';
// Fetcher function: receives the source signal value
const fetchUser = async (id) => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
return response.json();
};
function UserCard() {
const [userId, setUserId] = createSignal(1);
// createResource re-fetches whenever userId() changes
const [user] = createResource(userId, fetchUser);
return (
<div>
<button onClick={() => setUserId(id => id + 1)}>
Load Next User
</button>
{/* Suspense shows fallback while resource is loading */}
<Suspense fallback={<p>Loading user...</p>}>
<Show when={user()} fallback={<p>No user found</p>}>
<div>
<h2>{user().name}</h2>
<p>{user().email}</p>
<p>{user().company.name}</p>
</div>
</Show>
</Suspense>
</div>
);
}
createStore uses JavaScript Proxies to intercept property reads and writes, enabling property-level reactivity within nested objects.
When I'd use Solid.js (and when I wouldn't)
I'd reach for Solid when rendering cost is the actual problem: dashboards with lots of live-updating cells, real-time feeds, big editable tables, or widgets embedded in someone else's page where every kilobyte counts. It's also a good fit for a new project with a small team that's comfortable with React's syntax and doesn't need half of npm's React-specific packages.
I'd stay on React when:
- You depend on the React ecosystem. Component libraries, form libraries, and things like React Native have no drop-in Solid equivalent. Porting them yourself usually costs more than the performance you gain.
- The team is large and hiring matters. Far more developers know React, and Solid's rules (don't destructure props, read signals inside tracking scopes) are easy for newcomers to break without noticing.
- Your app isn't slow. A typical CRUD app with forms and a few lists won't feel different to users. Switching frameworks for benchmark numbers is rarely worth a rewrite.
If you do try it, the mistakes I see most often are the ones covered above: destructuring props, reading a signal once in the component body and expecting it to update, using .map() for large lists instead of <For>, and fetching inside createEffect instead of createResource. Turn on eslint-plugin-solid from day one and it'll catch most of them. Even if you never ship Solid, its model is worth learning: Vue's Vapor mode, Preact Signals and Angular signals all follow similar ideas.