SolidJS for React Developers

SolidJS has been around for a few years now. As a React developer, I wanted to give it a try. While it shares many similarities with React, it also introduces some key differences.
At first, I naively used SolidJS like React (old habits die hard) and it mostly worked... until it didn't. I quickly ran into issues that, while thoroughly documented in the SolidJS documentation, I did not fully comprehend. I needed to understand why those issues were happening to internalize them. I needed to understand how SolidJS works under the hood. In this article, I will share my findings and explain how SolidJS works internally.
Have We Met Before?
SolidJS is a reactive UI library that, on the surface, looks very similar to React.
It uses a component-based architecture, where each component is a function that returns JSX.
It provides functions similar to hooks, like createSignal
, createEffect
, and createMemo
.
It also supports familiar concepts such as Portals, Suspense, and Error Boundaries.
Even its ecosystem feels similar:
- TanStack (Query, Form, Router) supports Solid.
- Testing Library supports Solid, too.
- SUID is a port of Material UI to Solid.
- SolidStart is to Solid what Next.js or Remix is to React.
If you're a React developer, you'll feel right at home, at least initially.
import { createSignal } from 'solid-js';
const Counter = (props) => {
const [getCount, setCount] = createSignal(props.initialCount);
return (
<div>
<p>Count: {getCount()}</p>
<button onClick={() => setCount(getCount() + 1)}>Increment</button>
</div>
);
};
// Can you spot all the differences and their reason?
However, if you start doing conditional rendering, destructuring props, or using async functions inside effects as you would in React, you'll quickly encounter issues and wonder why things aren't working.
SolidJS is not React, and it doesn't work like React.
Let's take a deeper look.
Let's Get Real: No Virtual DOM
SolidJS doesn't use a virtual DOM. Instead, it uses fine-grained reactivity.
Solid calls each component function once to initialize reactivity. After that, Solid updates only the specific DOM nodes that need changing.
In contrast, React would re-render an entire component when one of its props or state variables changes.
Because of this, in Solid:
- Components must be fully initialized up-front.
- Components must not include conditionals (if, ternary, or array.map).
Instead, Solid provides components to handle control flow:
Simple Conditionals with <Show>
:
<Switch when={getData()} fallback={<div>Loading...</div>}>
Display data
</Switch>
Complex Conditionals with <Switch>
and <Match>
<Switch>
<Match when={getStatus() === 'loading'}>
<div>Loading...</div>
</Match>
<Match when={getStatus() === 'success'}>
...
</Match>
<Match when={getStatus() === 'error'}>
An error occurred
</Match>
</Switch>
List Rendering with <For>
<For each={data()}>
{(item, index) => ...}
</For>
There are other helpers too (Portal
, Suspense
, Dynamic
, ErrorBoundary
) — see the Solid documentation
Note: Using ternaries or
array.map
inside JSX will still work, but you'll lose the performance benefits of fine-grained reactivity.
Encouraging Signals
As mentioned earlier, SolidJS looks similar to React:
// In React
const [counter, setCounter] = useState(0);
useEffect(() => {
console.log(`Count is ${counter}`);
}, [counter])
// In Solid
const [getCounter, setCounter] = createSignal(0);
createEffect(() => {
console.log(`Count is ${getCounter()}`)
})
It uses signals instead of React's useState
, but the syntax is quite similar.
However, there are two key differences to understand:
- React's
useState
returns the state value directly (counter
);createSignal
returns a getter function (getCounter
). - React's
useEffect
requires a dependency array; SolidJS'screateEffect
automatically tracks dependencies.
Remember, in SolidJS, components aren't re-executed when their state changes — yet Solid still knows what to update.
How does it do that?
That's because SolidJS's signals act like event emitters, following the Observer Pattern.
A Quick Recap: The Observer Pattern
An Observable keeps track of a list of Observers and notifies them when it changes.
Here's a simple version:
const createObservable = () => {
const observers = [];
return {
subscribe: (callback) => {
observers.push(callback);
},
unsubscribe: (callback) => {
observers = observers.filter((observer) => observer !== callback);
},
notify: (data) => {
observers.forEach((observer) => observer(data));
},
};
};
const observable = createObservable();
const logName = (name) => console.log(`Name is ${name}`);
observable.subscribe(logName);
observable.notify('SolidJs');
// Will log: "Name is SolidJs"
Signals From The Ground Up
SolidJS's createSignal
is a function that creates a reactive signal, which is an Observable value that can be read and updated.
The following snippet shows a simplified implementation that does not handle effect cleanup and other optimization, but it gives you a good idea of how SolidJS works under the hood.
// We need a global variable to track the current running effect across the app
// We use an array to be able to track the nested effects
const runningEffects = [];
const createSignal = (value) => {
// This tracks the observers of the signals
// We use a set to avoid registering the same observer twice in case the effect calls the same getter twice
const subscriptions = new Set();
const getter = () => {
// The `runningEffect` variable will hold the currently executing effect if any
const runningEffect = runningEffects[runningEffects.length - 1];
if (runningEffect) {
// If there is a running effect, we register it as an observer of the signal
// Since we know that the effect is calling the getter
subscriptions.add(runningEffect);
}
return value;
};
const setter = (nextValue) => {
// We update the value of the signal.
value = nextValue;
for (const subscription of [...subscriptions]) {
// We notify all observers of the change in the underlying value.
subscription.execute();
}
};
return [getter, setter];
}
// When creating an effect
const createEffect = (effectFn) => {
// We create an object for the effect.
// This object will hold the execute function.
const effectContext = {
execute,
};
const execute = () => {
// We then add the current effect to the `runningEffects` array.
// This will allow the signal getter called by the effect to register it as an observer.
runningEffects.push(effectContext);
try {
// We execute the effect.which will call the signal getter it uses.
// Which in turn will register the effect as an observer.
effectFn();
} finally {
// After the effect is executed, we remove the current effect from the running effects.
runningEffects.pop();
}
};
// we call the execute function to run the effect the first time during the initialization.
// On later updates, the signal setter will notify their subscribed effects of the update.
execute();
}
In this implementation:
The signals expose a setter and a getter and track a set of observers internally.
- The getter will register the current effect as an observer when it is called.
- The setter will update the value and notify all the registered observers of the change.
The
createEffect
function creates an execute function that- add the current effect to the runningEffects array
- call the effect function
- remove the current effect from the runningEffects array
Important: Avoid async code inside createEffect. The effects use a runningEffects global variable to register the current effect as an observer. With asynchronous logic, an effect could call a getter asynchronously, at a time when the effect would not be the current running Effect, and the getter would not register the effect to the correct observer.
// Do not do this:
const [getData, setData] = createSignal(null);
const [getUser, setUser] = createSignal('admin');
const [getId, setId] = createSignal('id');
createEffect(async () => {
const authToken = await fetch(`https://api.example.com/auth/${getUser()}`);
// after the first await, the effect is no longer the current running effect.
// so when we call getId() here, it will register to another effect. And next time the id signal change, it will not trigger this effect but another random one.
const data = await fetch(`https://api.example.com/data/${getId()}?token=${authToken}`);
setData(data);
});
Instead, you can add another createEffect inside the effect (something that is not possible in React).
createEffect(async () => {
const authToken = await fetch(`https://api.example.com/auth/${getUser()}`);
// after the first await, the effect is no longer the current running effect.
createEffect(async () => {
// so we create another effect that can be rerun when the id signal changes
const data = await fetch(`https://api.example.com/data/${getId()}?token=${authToken}`);
setData(data);
});
});
Gotcha! Prop Getters
We now know how SolidJS uses signals to handle reactivity in state and effects. But how does it maintain reactivity for props?
The answer is: it uses Getter functions.
SolidJs converts all props passed to a component into getters so that they get evaluated lazily, and always return the latest value.
So when you pass the name
prop to a component, it creates a getter for that prop:
<DisplayName name={name()} />
// will become under the hood:
DisplayName({
get name() {
return name();
}
});
And guess what happens if you destructure a key that has a getter from the props object? It gets converted into a normal value, that loses the laziness and gets evaluated immediately, losing the reactivity.
In other words, it will work on first render, but when the prop changes, the destructured value will stay the same.
So avoid destructuring props in Solid, since it will break the reactivity:
const DisplayName = (props) => {
const { name } = props; // This will break the reactivity
return <div>{name}</div>;
};
// Do this instead
const DisplayName = (props) => {
return <div>{props.name}</div>;
};
SolidJS Has Something Else In Store For Us
Signals are great — but what if you need complex or nested state? Enter stores.
Stores are similar to signals, but instead of returning a getter/setter pair, they return a proxy object and a setStore
function. This function takes a key and a value (or a function):
import { createStore } from 'solid-js/store';
const [store, setStore] = createStore({
name: 'SolidJs',
stars: 33284,
});
const updateName = (name) => {
setStore('name', name);
}
const incrementStar = () => {
setStore('stars', currentAge => currentAge + 1);
}
We can use a store like a normal object, and Solid will handle the reactivity for us. setStore
will update the store and notify all observers of the change. So the proxy allows SolidJS to keep the reactivity even on dynamic property names.
However, just like with props, we must be careful when using stores. Since they are proxies, we must not destructure them or it will break the reactivity. Just like with the props-getter.
Takeaway
Now that we understand how SolidJS works internally. Let's summarize the key rules:
- Avoid conditionals (if, ternaries, array.map) directly in components.
- Avoid async code inside
createEffect
. - Do not destructure props or stores if you want to preserve reactivity.
Luckily, SolidJS provides an ESLint plugin that warns you when you're doing something that might break reactivity.
If you follow these rules, and you grasp the mental model, you'll be able to enjoy the performance benefits of SolidJS's fine-grained reactivity.