From React
React and Solid share many application concepts, but they update interfaces differently. This guide maps familiar React patterns to their closest Solid concepts and highlights where a direct translation would miss part of Solid's model.
Your knowledge of components, props, JSX, events, context, refs, and composition still applies. The main shift is how state changes reach the DOM. Thinking in Solid shows that shift inside one feature built end to end; this page maps the concepts one by one.
Start with the execution model
A React component renders from its current props and state. When that state changes, React runs the component again to produce its next result.
A Solid component runs when Solid mounts that component. Signals and stores then notify only the computations and JSX expressions that read them. The component function does not need to run again.
The following counters express the same interface:
React
import { useState } from "react";
function Counter() { const [count, setCount] = useState(0); const doubled = count * 2;
return ( <button onClick={() => setCount((value) => value + 1)}> {count} × 2 = {doubled} </button> );}Solid
import { createSignal } from "solid-js";
function Counter() { const [count, setCount] = createSignal(0); const doubled = () => count() * 2;
return ( <button onClick={() => setCount((value) => value + 1)}> {count()} × 2 = {doubled()} </button> );}The Solid signal returns an accessor and a setter.
An accessor is a function that reads a reactive value.
Calling count() inside JSX subscribes that expression to the signal.
Calling doubled() inside JSX lets the same tracking scope discover its read of count().
A tracking scope records the reactive values read while it runs so Solid can update that work later. Keep reactive reads inside JSX or another tracking scope. A read at the top level of a Solid component is a one-time read because component bodies run untracked.
See Reactivity for tracking, scheduling, ownership, and disposal.
Translate hooks by purpose
React calls hooks in a fixed order during renders to access preserved state and schedule behavior. Solid creates primitives during component setup. Computations and cleanup attach to an owner, which provides the lifetime of the mounted component or reactive scope. Solid primitives are regular function calls and do not use hook ordering or naming rules.
Translate each hook according to the role it serves:
- Local scalar state (
useState) usually maps tocreateSignal. - Object or collection state, including state managed by
useReducer, can map tocreateStore; see Replace a reducer with store functions. - Cached derived state (
useMemo) can map tocreateMemo.useCallbackandReact.memohave no counterpart; see Remove what has no Solid counterpart. - Imperative synchronization (
useEffect,useLayoutEffect) can map tocreateEffect, and setup that runs once on mount maps toonSettled; see Run setup once on mount. - Shared subtree state can map to
createContextanduseContext. - A store outside the framework, read with
useSyncExternalStore, maps to a signal written from its subscription; see Read an external store. - Pending UI from
useTransitionanduseDeferredValuemaps toisPending,latest, andcreateDeferred; see Show pending work. - Optimistic mutations with
useOptimisticmap toactionand the optimistic primitives; see Write data with actions. - Reusable stateful behavior (a custom hook) can become a custom primitive: a plain function that creates and returns Solid primitives; see Turn a custom hook into a custom primitive.
These mappings describe related responsibilities rather than interchangeable implementations. Choose the Solid primitive from the data flow that the code needs.
Read and write local state
useState and createSignal both provide a value and a setter.
The visible syntax differs because a Solid signal uses an accessor.
The counter above shows the same state update in each library.
Both setters accept updater functions for values derived from the previous state:
setCount((value) => value + 1);Scheduling note
Solid stages signal and store writes made outside a synchronous flush scope.
It commits the reactive queue on a microtask, after the current JavaScript task completes.
Application event handlers normally need no explicit flush.
Use flush() when a test or imperative integration must observe the committed state or DOM synchronously.
Work with object and collection state
React applications often update an object by producing a new object. A Solid store provides a read-only reactive view with a setter that mutates a draft.
React
const [profile, setProfile] = useState({ name: "Ada", role: "Engineer",});
function rename(name: string) { setProfile((current) => ({ ...current, name }));}Solid
import { createStore } from "solid-js";
const [profile, setProfile] = createStore({ name: "Ada", role: "Engineer",});
function rename(name: string) { setProfile((draft) => { draft.name = name; });}Reading profile.name inside a tracking scope subscribes to that property.
Unrelated property updates do not require that reader to run again.
Signals also work for objects when the whole value has one identity and changes as a unit. See Stores for store setters, projections, reconciliation, and optimistic stores.
Replace a reducer with store functions
A useReducer reducer returns a new state object for every action.
With a store, each action becomes a function that changes the draft in place.
React
type Action = { type: "toggle"; id: string };
function reducer(state: { todos: Todo[] }, action: Action) { switch (action.type) { case "toggle": return { todos: state.todos.map((todo) => todo.id === action.id ? { ...todo, done: !todo.done } : todo ), }; }}
const [state, dispatch] = useReducer(reducer, { todos: [] });Solid
import { createStore } from "solid-js";
const [state, setState] = createStore({ todos: [] as Todo[] });
function toggle(id: string) { setState((draft) => { const todo = draft.todos.find((item) => item.id === id); if (todo) todo.done = !todo.done; });}Toggle one todo and only the JSX that reads that todo's done updates; the other rows do not run.
Named functions such as toggle take the place of dispatch and action objects, and adding an item is draft.todos.push(todo) inside the setter.
The reducer habit of building a new array carries over without an error:
// Avoid: a fresh array replaces the one every reader depends onsetState((draft) => { draft.todos = draft.todos.map((todo) => todo.id === id ? { ...todo, done: true } : todo );});Every reader of state.todos runs again, and For recreates the toggled row instead of updating it.
With attribution enabled, development reports [IMMUTABLE_UPDATE_IN_STORE].
Change the item in place, as toggle does above.
A write outside the setter, such as state.todos.push(todo), is ignored, and development reports [STORE_WRITE_OUTSIDE_SETTER].
Derive values from state
React can calculate a value during render and can use useMemo as a performance optimization.
React may discard that cache, so application behavior cannot depend on its persistence.
A Solid derived function runs in the tracking scope that calls it.
createMemo creates a reactive computation whose cached result and equality behavior are part of the reactive graph.
Use a function for a small derivation consumed in one place:
const fullName = () => `${firstName()} ${lastName()}`;
return <p>{fullName()}</p>;Use createMemo for an expensive derivation, when several consumers need the same result, or when the result should form an equality boundary:
import { createMemo } from "solid-js";
const fullName = createMemo(() => `${firstName()} ${lastName()}`);
return ( <> <h1>{fullName()}</h1> <label> Display name <input value={fullName()} readonly /> </label> </>);The memo tracks firstName() and lastName().
Consumers track the memo rather than its individual inputs.
State that can be calculated from other reactive values usually remains a derivation. It does not need a second signal plus an effect that keeps the two values synchronized. See Avoid unnecessary effects for synchronous derivation, async derivation, and temporary writable overrides.
Keep an editable value derived from props
An editable field may follow a prop until the user changes it locally, then reset when the upstream prop changes. Both React and Solid can express this without an effect.
React recommends first checking whether the value can be calculated during render or whether a key can reset the component.
When one piece of local state must adjust to a prop change, React also documents comparing the current prop with information stored from the previous render:
React
function NameField({ name }: { name: string }) { const [previousName, setPreviousName] = useState(name); const [draft, setDraft] = useState(name);
if (name !== previousName) { setPreviousName(name); setDraft(name); }
return ( <input value={draft} onChange={(event) => setDraft(event.currentTarget.value)} /> );}The guarded setters ask React to retry the current render with state derived from the new prop. This follows React's adjusting state during render guidance and avoids committing a stale value before an effect corrects it.
Solid
Solid can create a writable derived signal by passing a function to createSignal:
function NameField(props: { name: string }) { const [draft, setDraft] = createSignal(() => props.name);
return ( <input value={draft()} onInput={(event) => setDraft(event.currentTarget.value)} /> );}The derivation supplies props.name until setDraft places a local override over it.
When a dependency of the derivation changes, the source produces the next value and replaces that override.
No synchronization effect or previous-prop bookkeeping is required.
The function form means “writable derivation”; it does not store the function itself as the signal value.
Use the function form of createStore for the same pattern with nested form state.
See Avoid unnecessary effects for the store version and guidance on choosing independent local state instead.
Translate effects by responsibility
React pairs an imperative effect callback with a declared dependency array. Solid uses a tracked compute function to discover dependencies through reactive reads, then passes its result to an untracked effect function.
React
useEffect(() => { const connection = connect(roomId); return () => connection.close();}, [roomId]);Solid
import { createEffect } from "solid-js";
createEffect( () => props.roomId, (roomId) => { const connection = connect(roomId); return () => connection.close(); });The compute function reads every reactive dependency that should trigger the effect. Its return value becomes the input to the effect function. The effect function performs imperative work and can return cleanup. Reads inside the effect function do not become dependencies.
Not every React effect becomes a Solid effect:
- Calculate renderable values with a function or memo.
- Start interaction-specific work in the event handler that observed the interaction.
- Put asynchronous derived data in an async memo or store projection.
- Use an effect when a settled reactive result must update an imperative system outside Solid.
This distinction is often more useful than translating a dependency array mechanically.
React compares each dependency with the previous render and skips the effect when none changed. Solid runs the effect function, and the cleanup before it, whenever a value read by the compute function changes, even when the compute function returns an equal result:
// Avoid: restarts the timer on every change to countcreateEffect( () => count() > 100, (large) => { const id = setTimeout(() => report(large), 5000); return () => clearTimeout(id); });
// Prefer: the memo stops the update when its result is unchangedconst large = createMemo(() => count() > 100);createEffect(large, (isLarge) => { const id = setTimeout(() => report(isLarge), 5000); return () => clearTimeout(id);});Change count from 1 to 2 and the Avoid version clears its timer and starts a new one, so a count that keeps changing never reports.
The Prefer version leaves the timer running, because the memo's value is still false.
Run setup once on mount
A useEffect with an empty dependency array, such as one that starts an interval and returns () => clearInterval(id), runs once after mount and cleans up on unmount.
Solid's onSettled has the same shape:
import { createSignal, onSettled } from "solid-js";
function Clock() { const [now, setNow] = createSignal(Date.now());
onSettled(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); });
return <time>{new Date(now()).toLocaleTimeString()}</time>;}The time advances every second, and the interval stops when the component is removed.
onSettled runs its callback once, after the component has mounted and any pending async work beneath it has settled.
Its reads are not tracked, and the function it returns runs when the component's owner is disposed.
It does not run during server rendering.
There is no separate useLayoutEffect timing.
Both onSettled and an effect function run after Solid has written the DOM for that update, so either one can measure elements.
Keep props reactive
React props are values from the current component render, so destructuring them is common. Potentially changing Solid props may be backed by getters. The child reads the current value when a tracking scope accesses that prop.
Keep the props object intact when its values can change:
function Greeting(props: { name: string; punctuation?: string }) { return ( <p> Hello, {props.name} {props.punctuation ?? "."} </p> );}Destructuring in the parameter list reads those values at component setup time:
function Greeting({ name }: { name: string }) { return <p>Hello, {name}</p>;}Use the destructured form only when a one-time value is intentional. Destructuring reads getter-backed props eagerly. Solid emits a development warning when a component reads reactive values at the top level.
Create a memo when a local name should retain reactive behavior:
function Greeting(props: { first: string; last: string }) { const name = createMemo(() => `${props.first} ${props.last}`); return <p>Hello, {name()}</p>;}Default and rest props
React components often take defaults and the remaining props from the parameter pattern, as in function Button({ variant = "primary", children, ...rest }).
Solid uses merge and omit, which return objects that read through to props on every access:
import { merge, omit } from "solid-js";import type { JSX } from "@solidjs/web";
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "ghost";};
function Button(props: ButtonProps) { const merged = merge({ variant: "primary" } as const, props); const rest = omit(merged, "variant", "children");
return ( <button data-variant={merged.variant} {...rest}> {merged.children} </button> );}<Button variant="ghost" type="submit">Save</Button> renders a submit button with data-variant="ghost".
Change variant in the parent and the attribute updates on the same button element.
merged.children is props.children; resolve it with children only when the component inspects or reuses the rendered children.
An explicit undefined is a value in merge.
<Button variant={undefined}> drops the attribute instead of falling back to "primary", where a React default parameter would have applied.
Handle events and form controls
Event props remain camelCase, such as onClick and onInput.
Solid handlers receive native DOM events, and event.currentTarget is typed as the element that owns the handler.
React
const [query, setQuery] = useState("");
return ( <input value={query} onChange={(event) => setQuery(event.currentTarget.value)} />);Solid
const [query, setQuery] = createSignal("");
return ( <input value={query()} onInput={(event) => setQuery(event.currentTarget.value)} />);onInput observes each browser input event.
Use onChange when the native change event timing is the intended behavior.
With onChange and a bound value, keep the typed text in a draft signal written from onInput: if another attribute in the same template changes, the binding re-runs and erases text that was never committed.
Event handlers run outside reactive tracking. They can read current signal and store values without subscribing to them.
Translate DOM properties and styles
Most JSX attributes remain recognizable. Some DOM-facing details differ:
- Use
classinstead ofclassName. - The Solid
classprop accepts strings, conditional objects, and nested arrays. - Style objects use CSS names such as
"background-color"rather than JavaScript names such asbackgroundColor. - Numeric style values do not receive automatic units.
- Use
textContentfor escaped text-only content andinnerHTMLonly for trusted or sanitized markup.
<button class={["button", { active: selected(), pending: saving() }]} style={{ "background-color": props.background, "inline-size": `${props.width}px`, }}> Save</button>See the style, textContent, and innerHTML references for their DOM behavior.
Render conditions and lists
JavaScript ternaries and logical expressions work directly in Solid JSX. The JSX compiler recognizes this native control-flow syntax and creates a reactive conditional around it.
Array methods such as map also return valid JSX.
They remain ordinary function calls, so the compiler cannot infer row identity or lifecycle from the call itself.
Solid's list components make those update semantics explicit.
Conditional content
A React conditional can keep its ternary form. The Solid version calls reactive accessors:
return <>{user() ? <Profile user={user()!} /> : <SignIn />}</>;The compiler tracks user() and updates the active branch when its truthiness changes.
Use Show when the code benefits from an explicit fallback, a narrowed value, or control over identity:
import { Show } from "solid-js";
return ( <Show when={user()} fallback={<SignIn />}> {(currentUser) => <Profile user={currentUser()} />} </Show>);By default, Show preserves its child across truthy changes and passes a narrowed accessor to its function child.
With keyed, it passes the raw value and remounts the child when the value's identity changes.
List content
A React list commonly uses map with a key:
return todos.map((todo) => <TodoRow key={todo.id} todo={todo} />);A direct Solid map also renders:
return ( <> {todos().map((todo) => ( <TodoRow todo={todo} /> ))} </>);Because map is an ordinary function call, reevaluating this expression creates the mapped row output again.
For a reactive list, For preserves rows according to an explicit identity mode:
import { For } from "solid-js";
return ( <For each={todos()} keyed={(todo) => todo.id}> {(todo) => <TodoRow todo={todo()} />} </For>);The React key and the Solid keyed function both express row identity.
By default, For uses item identity.
It can instead use positional identity with keyed={false} or a key function such as the example above.
Use Repeat for positional rendering over a store without diffing array items or identities.
Its from and count props can represent a sliding window while preserving rows whose absolute indexes remain in the overlapping range.
Share state with context
Both libraries use context to scope a value to descendants. In Solid, the context object is also its provider component.
import { type Accessor, type Element, type Setter, createContext, createSignal, useContext,} from "solid-js";
type Theme = "light" | "dark";type ThemeContextValue = { theme: Accessor<Theme>; setTheme: Setter<Theme>;};
const ThemeContext = createContext<ThemeContextValue>();
function ThemeProvider(props: { children: Element }) { const [theme, setTheme] = createSignal<Theme>("light");
return ( <ThemeContext value={{ theme, setTheme }}>{props.children}</ThemeContext> );}
function ThemeButton() { const { theme, setTheme } = useContext(ThemeContext);
return ( <button onClick={() => setTheme((value) => (value === "light" ? "dark" : "light")) } > Current theme: {theme()} </button> );}A default-less context throws when code reads it without an enclosing provider.
Pass signals, stores, or service objects through context when descendants need reactive state, including application-wide state provided at the root of App.
Avoid module-level signals or stores for shared state: they have no owner, and on the server one module instance is shared across requests.
Read an external store
React reads a store that lives outside the framework with useSyncExternalStore(subscribe, () => navigator.onLine, () => true): a subscribe function, a client snapshot, and a server snapshot.
In Solid, the subscription callback writes a signal:
import { createSignal, onSettled } from "solid-js";
function createOnlineStatus() { const [online, setOnline] = createSignal(true);
onSettled(() => { const update = () => setOnline(navigator.onLine); update(); return subscribe(update); });
return online;}subscribe is the same function in both versions: it registers a callback and returns a function that unsubscribes.
Turn the network off in the browser's developer tools and online() becomes false; every expression that reads it updates.
onSettled does three jobs here:
- It does not run during server rendering, so the server never touches
navigator; it only reserves the hydration ID the client will use, so the two trees stay aligned. - The signal starts at
true, the value the server rendered, so the first client render matches the server HTML;update()then writes the real value once the component has settled. The same write in the component body throws[REACTIVE_WRITE_IN_OWNED_SCOPE]. - The unsubscribe function it returns runs when the component's owner is disposed.
When the subscription depends on a reactive input, such as an ID prop, subscribe in an effect instead, as Integrate non-Solid code shows, so it resubscribes when the input changes.
A computation can also return an async iterable, and each value the iterator produces becomes the memo's next value:
import { createMemo } from "solid-js";
function onlineChanges(): AsyncIterable<boolean> { return { [Symbol.asyncIterator]() { let changed = true; // emit the current value first let wake = () => {}; const unsubscribe = subscribe(() => { changed = true; wake(); }); return { next: () => new Promise<IteratorResult<boolean>>((resolve) => { wake = () => { changed = false; resolve({ value: navigator.onLine, done: false }); }; if (changed) wake(); }), async return() { unsubscribe(); return { value: undefined, done: true }; }, }; }, };}
const online = createMemo(() => onlineChanges(), { loadingValue: true });loadingValue answers the first read, and waiting for a later value does not make the memo pending: isPending(() => online()) stays false and no Loading boundary shows.
When the memo's owner is disposed, Solid calls the iterator's return(), which unsubscribes.
Prefer the onSettled version for a source that exists only in the browser, since onSettled never runs on the server.
An async function* looks shorter, but while the generator waits at an await for the next change, a return() call is queued behind that await.
The subscription stays open until the next change arrives, or forever if none does.
Implement next and return directly, as above.
Turn a custom hook into a custom primitive
A custom hook becomes a custom primitive: a function, conventionally named createX, that runs once inside a component and returns accessors.
React
function useDebounced(value: string, ms: number) { const [debounced, setDebounced] = useState(value);
useEffect(() => { const id = setTimeout(() => setDebounced(value), ms); return () => clearTimeout(id); }, [value, ms]);
return debounced;}Solid
import { createEffect, createSignal, untrack, type Accessor } from "solid-js";
function createDebounced(value: Accessor<string>, ms: number) { const [debounced, setDebounced] = createSignal(untrack(value));
createEffect(value, (next) => { const id = setTimeout(() => setDebounced(next), ms); return () => clearTimeout(id); });
return debounced;}Call it with the signal itself, createDebounced(query, 300), or wrap a prop in a function, createDebounced(() => props.query, 300).
Type quickly and debounced() changes once, 300 milliseconds after the last keystroke.
The primitive takes an accessor and returns one, because a plain value is a snapshot taken when the primitive runs.
untrack reads the starting value once without subscribing the component body.
The effect's cleanup clears the pending timer when the value changes and when the component is removed.
The hook rules do not apply: call a primitive conditionally, in a loop, or from another primitive, as long as an owner such as a component is running it.
Returning debounced() instead of debounced hands back a value that never changes, and development warns [STRICT_READ_UNTRACKED] in the calling component.
Work with refs and owned setup
Both libraries can expose a DOM element to component code.
React
function SearchField() { const input = useRef<HTMLInputElement>(null);
return ( <> <input ref={input} type="search" /> <button onClick={() => input.current?.select()}>Select query</button> </> );}Solid
Solid can assign the element to a local variable through a ref callback:
function SearchField() { let input!: HTMLInputElement;
return ( <> <input ref={(element) => (input = element)} type="search" /> <button onClick={() => input.select()}>Select query</button> </> );}React stores the committed element in the ref object's current property.
The Solid callback receives the element after Solid creates it and assigns it to the local variable.
Solid also accepts an assignable variable or an array of callbacks through the ref prop.
Pass and compose refs through components
React 19 accepts ref as a regular component prop, so new components do not need forwardRef.
forwardRef remains available, but React marks it for deprecation in a future release.
Code targeting earlier React versions may still use forwardRef to receive and pass that ref.
Solid also receives a component ref through props.ref.
Pass it to the element as one entry in a ref array when the component needs the same element locally:
import type { Ref } from "solid-js";
type SearchFieldProps = { ref?: Ref<HTMLInputElement>;};
function SearchField(props: SearchFieldProps) { let input!: HTMLInputElement;
return ( <> <input ref={[props.ref, (element) => (input = element)]} type="search" /> <button onClick={() => input.select()}>Select query</button> </> );}Solid recursively flattens the array and invokes each ref callback in order.
The parent receives the element through props.ref, while the component can compose local element access and additional directives without a wrapper callback.
Use a ref directive when element behavior needs reusable setup, native event options, owned reactive work, or cleanup.
See Refs and directives and the ref reference for that lifecycle and callback composition.
Map asynchronous UI by state
React applications coordinate asynchronous UI through framework APIs, Suspense boundaries, transitions, and data libraries. Solid represents asynchronous reads as part of its reactive graph.
Map each responsibility separately:
- A React Suspense fallback is closest to a Solid
Loadingfallback. - A React error boundary is closest to a Solid
Erroredboundary. - A
SuspenseList-style reveal policy is closest to Solid'sReveal. - Pending transition UI is closest to Solid's automatic held updates with
isPending. - Mutation coordination can use a Solid
action.
These concepts serve related UI responsibilities, but their runtime contracts are not interchangeable.
An async memo remains an accessor:
import { type Accessor, Errored, Loading, createMemo } from "solid-js";
function UserProfile(props: { user: Accessor<User> }) { return ( <Loading fallback={<p>Loading user...</p>}> <article> <h2>{props.user().name}</h2> <p>{props.user().bio}</p> </article> </Loading> );}
function Page(props: { id: string }) { const user = createMemo(async () => { const response = await fetch(`/api/users/${props.id}`); if (!response.ok) throw new Error("Could not load user"); return response.json() as Promise<User>; });
return ( <Errored fallback={(error) => <p>{String(error())}</p>}> <UserProfile user={user} /> </Errored> );}Loading handles a read that has no settled answer.
Errored handles an error that travels through the reactive graph.
The async memo is created in Page, but Loading sits in UserProfile directly above the reads of props.user().
A loading boundary only needs to be an owner ancestor of the read that can report “not ready.”
It does not need to wrap the component that created the computation or the parent call site for the component that performs the read.
The boundary can also sit farther up the owner tree when one fallback should cover a larger coherent region.
Async coordination follows data
Solid waits on reactive data rather than treating a component as the unit of suspension. A not-ready read blocks that reactive output, but it does not abort setup of the remaining nested component tree.
import { render } from "@solidjs/web";import { Loading, createMemo } from "solid-js";import { fetchTimeIn3Seconds } from "./api.js";
function A() { const a = createMemo(fetchTimeIn3Seconds);
return ( <div class="box"> A completed in {a()} seconds. <B /> </div> );}
function B() { const b = createMemo(fetchTimeIn3Seconds);
return ( <div class="box"> B completed in {b()} seconds. <C /> </div> );}
function C() { const c = createMemo(fetchTimeIn3Seconds);
return <div class="box">C completed in {c()} seconds.</div>;}
render( () => ( <Loading fallback="Loading..."> <A /> </Loading> ), document.getElementById("root")!);A starts its request before a() reports that its value is not ready.
Solid continues setting up the nested JSX, so B and then C start their own requests in the same pass.
All three requests overlap even though the components are nested.
The one Loading boundary above A handles every not-ready read beneath it.
It detaches the in-progress branch from visible output, retains its reactive ownership offscreen, and shows the fallback.
Component setup and ownership remain in place while the async expressions continue toward resolution.
When the values settle, Solid updates the reactive text expressions and reveals the retained branch.
The component functions do not run again.
A computation that reads another async result still waits for that dependency. Parallel work comes from independent data dependencies rather than the depth of the component tree.
React applications can also achieve parallel requests through framework loading, caching, or preloading. This example shows the Solid default: pending status belongs to the data read, so a pending expression does not stop nested components from setting up their own independent work.
Coordinate several loading regions
React explored coordinating several Suspense boundaries through the experimental SuspenseList API.
React 19 does not include SuspenseList in its stable API, though applications may encounter the concept in experimental releases or framework abstractions.
Solid's Reveal coordinates the reveal timing of sibling Loading boundaries:
import { Loading, Reveal } from "solid-js";
<Reveal order="sequential"> <Loading fallback={<ProfileSkeleton />}> <Profile /> </Loading> <Loading fallback={<ActivitySkeleton />}> <Activity /> </Loading></Reveal>;Reveal does not start async work or create loading state.
It controls when its direct loading regions become visible:
"sequential"reveals them in registration order."together"waits until every direct region has its first visible content."natural"lets each region reveal when its own work settles.
Nested Reveal groups compose as one slot in their parent group.
See Boundaries for ordering, collapsed fallbacks, and group membership.
Updates are coordinated implicitly
React provides startTransition to mark selected state updates as non-blocking.
Solid does not require a wrapper that marks an update.
All signal and store writes made during one uninterrupted synchronous call stack join the same coordinated update.
function selectUser(id: string) { setSelectedId(id); setPanel("profile");}Both writes above belong to one update. This implicit grouping is not a priority or interruptibility API like React's transition APIs.
When its downstream work remains synchronous, Solid commits the update on the normal microtask flush. When participating computations wait for async work, the unit becomes a held update. Solid keeps the current committed view visible, then reveals the participating writes together when that work settles. That retained-UI behavior is the closest analogue to a React transition.
Use isPending when the held view needs an updating indicator.
This behavior is automatic rather than an explicit startTransition call.
Solid action coordinates generator-based mutations, optimistic state, and refresh behavior.
The shared term “action” does not imply that action APIs in different ecosystems have identical contracts.
See Async reactivity for readiness, held updates, errors, actions, optimistic state, and durable refresh.
Show pending work
useTransition returns an isPending flag for a transition that startTransition marks.
In Solid every write that leads to async work is held, so the indicator reads the data directly.
React
const [tab, setTab] = useState("posts");const [isPending, startTransition] = useTransition();
<button onClick={() => startTransition(() => setTab("photos"))}>Photos</button>;<Suspense fallback={<Spinner />}> <div style={{ opacity: isPending ? 0.5 : 1 }}> <TabContent tab={tab} /> </div></Suspense>;Solid
import { createMemo, createSignal, isPending, latest, Loading } from "solid-js";
function Tabs() { const [tab, setTab] = createSignal("posts"); const content = createMemo(() => fetchTab(tab()));
return ( <> <button disabled={latest(tab) === "photos"} onClick={() => setTab("photos")} > Photos </button> <Loading fallback={<Spinner />}> <div class={{ dimmed: isPending(() => content()) }}>{content()}</div> </Loading> </> );}Click Photos and the button is disabled at once; the posts stay on screen, dimmed, until the photos arrive, and then the content switches.
While the write is held, tab() itself still returns "posts", and so does everything derived from it.
latest(tab) reads the value in flight, which is what the control the user touched should show, and isPending is true while a change to content is in flight.
To show the fallback again instead of the dimmed old content, use <Loading on={tab()}>; Show a placeholder again explains it.
useDeferredValue lets the input update at once while a slow result lags behind it.
createDeferred gives an async result that lags but does not hold the input:
import { createDeferred, createSignal } from "solid-js";
const [query, setQuery] = createSignal("");const results = createDeferred(() => api.search(query()));Type in the search box and query() updates on each keystroke; results() keeps the previous list, with isPending(() => results()) true, until the new list arrives.
Write data with actions
React 19 shows an expected value with useOptimistic while a transition or form action runs, and data libraries offer mutation hooks with rollback callbacks.
Solid runs a mutation as an action, and an optimistic store shows the expected result while it runs.
React
const [optimistic, addOptimistic] = useOptimistic(todos, (list, todo: Todo) => [ ...list, todo,]);
function addTodo(text: string) { startTransition(async () => { addOptimistic({ id: crypto.randomUUID(), text, done: false, pending: true, }); await api.addTodo(text); });}Solid
import { For, Loading, action, createOptimisticStore, refresh } from "solid-js";
function TodoList() { const [todos, setTodos] = createOptimisticStore( () => api.listTodos(), [] as Todo[] );
const addTodo = action(function* (text: string) { setTodos((list) => { list.push({ id: crypto.randomUUID(), text, done: false, pending: true }); }); yield api.addTodo(text); refresh(todos); });
return ( <> <button onClick={() => addTodo("Write docs")}>Add</button> <Loading fallback={<Spinner />}> <ul> <For each={todos}> {(todo) => <li class={{ pending: todo.pending }}>{todo.text}</li>} </For> </ul> </Loading> </> );}Click Add and the new row appears at once with the pending class.
When api.addTodo resolves, refresh(todos) reloads the list and the server's copy replaces the row; if the request fails, the row disappears with no rollback code.
createOptimisticStore is the list and its optimistic overlay in one, and the action discards the overlay when it settles.
Each yield waits for its promise and resumes the action's transaction; code after a plain await runs outside it.
For a single value, such as a like count, use createOptimistic.
With Solid Router, the action calls a server function, and the router revalidates its cache from the response instead of an explicit refresh.
Wrap the mutation in formAction when the form must work before JavaScript loads.
Data loading and mutations covers both, and Mutations covers pending flags, affects, and failures.
Remove what has no Solid counterpart
Several React APIs exist to limit re-rendering or to work around it. A Solid component runs once, so these have nothing to translate to:
React.memo: a parent update does not run the child component again, so there is no render to skip.useCallback: a function created in the component body is created once and keeps its identity.useMemoaround cheap values: a plain function such as() => count() * 2is enough; keepcreateMemofor expensive or shared work, as Derive values from state describes.- Dependency arrays: the compute function tracks what it reads.
- Workarounds for stale closures, such as a ref that mirrors the latest state: a handler that calls
count()reads the current value. - The hook rules: primitives are plain function calls.
- Code that guards against Strict Mode running effects twice: Solid does not run component bodies or effects twice in development.
Plan an incremental migration
React and Solid JSX look similar, but their compiled component values and runtime ownership are not interchangeable. Treat a route, page, island, or separate application root as the boundary between the two renderers.
A practical migration sequence is:
- Create the Solid application shell and choose its routing and rendering setup.
- Move framework-independent types, validation, data clients, and utility functions.
- Migrate leaf UI components and their local state.
- Move shared state and context after the consuming components have clear Solid boundaries.
- Translate derived state and effects by responsibility.
- Move async reads, loading UI, errors, and mutations into Solid's async model.
- Retire the React root or route boundary after its descendants have moved.
Use Quick start to create a Solid project. The routing overview describes the router-independent application boundary and links to supported routers.
Habits that still compile
Each of these React habits compiles in Solid and then misbehaves.
Development builds report most of them by code; codes marked "attribution" appear once attribution.enable() from solid-js/attribution is running, as Debugging reactivity describes.
| React habit | What happens in Solid | What reports it |
|---|---|---|
| Destructuring props, or reading a prop or signal at the top of the component | The value never updates | STRICT_READ_UNTRACKED |
An early return that depends on props | The component stays on its first branch | STRICT_READ_UNTRACKED |
Calling a translator in the body: const label = t("save") | The text keeps its first language | STRICT_READ_UNTRACKED if t reads a signal; nothing if it reads a plain variable |
Passing the accessor: value={count} | The child receives a function | A TypeScript error |
| Building fresh objects for list items on each update | For recreates every row, and focus and input state are lost | UNSTABLE_LIST_IDENTITY (attribution); key by ID with keyed={(item) => item.id} |
| Copying a derived value into state from an effect | The copy is one update behind | EFFECT_RELAY_TEAR (attribution) |
| Setting state during render | Throws | REACTIVE_WRITE_IN_OWNED_SCOPE |
Translating useEffect(fn) to createEffect(fn) | Throws | MISSING_EFFECT_FN |
| Reading a signal inside the effect function | The effect does not re-run when it changes | STRICT_READ_UNTRACKED |
Reading a signal after await in an async memo | The memo does not re-run when it changes | UNTRACKED_READ_AFTER_AWAIT (V8 engines) |
| Writing a store outside its setter | The write is ignored | STORE_WRITE_OUTSIDE_SETTER |
createContext<T | undefined>(undefined) for an optional context | useContext throws without a provider | ContextNotFoundError; use null as the default |
onClick={ready() ? save : wait} | The first handler stays bound | Nothing; decide inside one handler |
A lowercase onclick | The handler is never attached | LOWERCASE_EVENT_ATTRIBUTE |
| A controlled input whose handler rejects a keystroke | The rejected text stays in the field | Nothing; assign event.currentTarget.value in the handler |
value={props.name} onChange={commit} with no draft signal | When another attribute in the same template changes, the binding re-runs and erases the typed text | UNCOMMITTED_INPUT_OVERWRITTEN; write a draft signal from onInput, or use defaultValue |
Review the translation
Before considering a migrated component complete, check:
- Signal values are read by calling their accessors.
- Reads intended to drive reactive updates occur in JSX, a memo, an async computation, or an effect compute function.
- Event handlers and intentional one-time snapshots can read values without tracking them.
- Props that can change remain on the props object or are wrapped in a reactive derivation.
- Calculated values remain derivations instead of synchronized copies of state.
- Effects send settled reactive results to imperative systems.
- Interaction-specific work starts in the event handler or action that observed it.
- Reactive lists use a
Forkeying mode orRepeatrange that matches how the collection changes. - Async reads have an appropriate loading and error boundary.
- Renderer-specific React components do not cross directly into the Solid component tree.
A successful migration preserves application behavior while expressing its data flow through Solid's execution model.