solid-js
Solid is a component library for web interfaces that compiles JSX into real DOM creation and targeted updates. It has no virtual DOM rerender pass: a component function normally runs once to build its view, while signals, memos, resources, and stores track exactly which expressions depend on changing state. The core package also supplies context, Suspense, error boundaries, portals, server rendering, hydration, and a universal renderer API. It feels familiar at the JSX level, but its accessor-based reactivity is materially different from React's component lifecycle.
Solid is one of the best choices for teams that want JSX with precise DOM updates and are willing to relearn reactivity. Do not choose it as a drop-in React substitute; the syntax travels more easily than the mental model.
Use it if
- You want JSX and component composition without virtual DOM rerenders
- Your interface updates frequently enough that fine-grained subscriptions and direct DOM updates are valuable
- You want signals, stores, resources, Suspense, context, portals, SSR, and hydration in one small core package
- Your team is comfortable learning accessor-based state and compiler-specific JSX semantics
- Your team expects React's rerender model: Solid component bodies run once, signals are read by calling accessors, and copying a reactive value outside a tracked expression can freeze it
- You routinely destructure component props: Solid props are reactive getters, and ordinary destructuring reads them once unless you use splitProps or access props.name directly
- You cannot add a JSX compiler step: the README recommends JSX, babel-preset-solid or a Solid-aware Vite plugin, plus jsx preserve and jsxImportSource solid-js for TypeScript
- You need Internet Explorer or long-frozen browser support: the project commits only to the last two years of Firefox, Safari, Chrome, and Edge
- You need the deepest third-party component and hiring ecosystem: Solid has active primitives and component libraries, but React and Vue still offer a wider set of vendor-maintained integrations
Setup reality
For a client app, use a Solid-aware Vite template or install solid-js with vite-plugin-solid; installing the runtime alone does not teach a generic JSX compiler Solid's update model. A manual Vite setup needs the Solid plugin in vite.config.ts and TypeScript compilerOptions with jsx set to preserve and jsxImportSource set to solid-js. There are no peer dependencies, credentials, native builds, or runtime config files, and 1.9.14 has three direct dependencies. Bundlephobia reports 8.357 KB gzipped for the main package, but routing, deployment, testing, and a production SSR stack are separate choices. The first surprise is semantic: createSignal returns an accessor and setter, so read count(), not count; component functions normally execute once; props are reactive getters that should not be casually destructured; and effects track synchronous reads, not values first accessed after an await or timer. Use createMemo for expensive derived work, onCleanup for subscriptions, and For or Index rather than Array.map when a list must update reactively. Import DOM rendering from solid-js/web, stores from solid-js/store, and core primitives from solid-js. Conditional exports intentionally select server implementations under Node, so use the compiler and supported bundler conditions instead of deep-importing dist files. SSR also requires matching server and client transforms and hydrate() rather than render(); most teams should use the SolidStart framework rather than assemble streaming SSR, routing, serialization, and deployment adapters by hand. Supported targets are modern browsers, Node LTS, recent Deno, and Cloudflare Workers, not IE.
Patterns
Create reactive state and mount a componentrender-counter
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
function Counter() {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount((n) => n + 1)}>
Count: {count()}
</button>
);
}
render(() => <Counter />, document.getElementById('app')!);count is an accessor and must be called; passing count itself gives the reactive expression to Solid, while count() reads its current value.
Cache expensive derived statederive-memo
import { createMemo, createSignal } from 'solid-js';
const [items, setItems] = createSignal<Product[]>([]);
const total = createMemo(() =>
items().reduce((sum, item) => sum + item.price, 0)
);
<p>Total: {total()}</p>A plain function is enough for cheap derived values; createMemo adds caching and only recomputes when tracked dependencies change.
React to one explicit dependencyrun-tracked-effect
import { createEffect, createSignal, on } from 'solid-js';
const [query, setQuery] = createSignal('');
createEffect(on(query, (value, previous) => {
console.log('query changed', previous, value);
}, { defer: true }));on() makes dependencies explicit, and defer skips the initial run; reads that happen later inside an async callback are not automatically tracked.
Render a keyed reactive listrender-list
import { For } from 'solid-js';
<ul>
<For each={todos()} fallback={<li>No tasks</li>}>
{(todo, index) => <li>{index() + 1}. {todo.title}</li>}
</For>
</ul>For keys rows by item identity and gives index as a signal; prefer Index when positions are stable but values at those positions change.
Show content with a fallbackrender-condition
import { Show } from 'solid-js';
<Show when={currentUser()} fallback={<a href="/login">Sign in</a>}>
{(user) => <p>Welcome, {user().name}</p>}
</Show>The callback form narrows the value and keeps access reactive; storing currentUser() in an ordinary variable outside JSX would capture one read.
Fetch data from a reactive sourceload-resource
import { createResource, Suspense } from 'solid-js';
const [userId, setUserId] = createSignal('42');
const [user, { refetch }] = createResource(userId, async (id) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});
<Suspense fallback={<p>Loading...</p>}>
<p>{user()?.name}</p>
</Suspense>The fetcher reruns when userId changes; fetch does not reject on HTTP errors, so check response.ok yourself.
Update nested state with a storeupdate-store
import { createStore } from 'solid-js/store';
const [state, setState] = createStore({
users: [{ id: 1, name: 'Ada', active: false }],
});
setState('users', (user) => user.id === 1, 'active', true);
<p>{state.users[0].active ? 'Active' : 'Inactive'}</p>Store values are proxies; use setState paths or produce-style helpers instead of mutating nested properties directly.
Split props without losing reactivitypreserve-reactive-props
import { splitProps, type ComponentProps } from 'solid-js';
type Props = { label: string } & ComponentProps<'button'>;
function ActionButton(props: Props) {
const [local, buttonProps] = splitProps(props, ['label']);
return <button {...buttonProps}>{local.label}</button>;
}Solid props are reactive getters; ordinary const { label } = props reads once and can prevent later parent updates from reaching the text.
Create a typed context with a guarded hookprovide-context
import { createContext, createSignal, useContext, type ParentProps } from 'solid-js';
const ThemeContext = createContext<ReturnType<typeof createSignal<string>>>();
export function ThemeProvider(props: ParentProps) {
const theme = createSignal('light');
return <ThemeContext.Provider value={theme}>{props.children}</ThemeContext.Provider>;
}
export function useTheme() {
const value = useContext(ThemeContext);
if (!value) throw new Error('ThemeProvider is missing');
return value;
}Without a provider, useContext returns the createContext default; throwing in a helper catches misplaced consumers early.
Dispose an external subscriptionclean-up-subscription
import { onCleanup, onMount } from 'solid-js';
function OnlineStatus() {
const [online, setOnline] = createSignal(navigator.onLine);
onMount(() => {
const update = () => setOnline(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
onCleanup(() => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
});
});
return <span>{online() ? 'Online' : 'Offline'}</span>;
}onCleanup runs when the owning component or reactive scope is disposed; register it in the same owner that created the subscription.
Lazy-load a route-sized componentlazy-load-component
import { lazy, Suspense } from 'solid-js';
const Reports = lazy(() => import('./Reports'));
<Suspense fallback={<p>Loading reports...</p>}>
<Reports />
</Suspense>The imported module must default-export a component; Suspense supplies the fallback while the code chunk and nested resources resolve.
Recover from a subtree errorcatch-render-errors
import { ErrorBoundary } from 'solid-js';
<ErrorBoundary fallback={(error, reset) => (
<section>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</section>
)}>
<Dashboard />
</ErrorBoundary>An error boundary catches rendering and reactive-scope failures below it, but it does not replace normal error handling in unrelated event handlers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react | npm | You prioritize the largest component, integration, and hiring ecosystem over fine-grained updates |
| vue | npm | You want fine-grained reactivity with a more batteries-included template and official tooling story |
| svelte | npm | You prefer compiler-driven components with concise single-file component syntax and signals called runes |