Frontend

Solid.js: Building Reactive Web Apps

Mayur Dabhi
Mayur Dabhi
June 9, 2026
14 min read

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.

Why Solid.js?

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:

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.

1

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.

2

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.

Terminal
# 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
3

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:

Project Structure
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.

src/index.jsx
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.

Signal createSignal(0) count() / setCount() createMemo Derived / computed value (cached) JSX Expression <p>{count()}</p> createEffect Side effects (logs, fetch, subscriptions) DOM Update Only changed nodes updated. No diffing, no reconciliation Solid.js Reactive Dependency Graph

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.

Don't Destructure Props!

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.

src/components/Greeting.jsx: Props & Children
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:

mergeProps & splitProps: Reactive-Safe Patterns
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>

Show Component: Conditional Rendering
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>

For Component: Reactive List Rendering
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.

createStore: Nested Reactive State
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.

createResource: Async Data Fetching
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 user.name user.address.city setUser() JS Proxy Intercepts get/set Tracks fine-grained notify Subscribers DOM node: city createEffect createMemo Store Proxy: Fine-Grained Property Update Flow

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:

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.

Solid.js JavaScript Reactive Signals Frontend Performance
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with React, Solid.js, Node.js, and modern frontend tooling.