Hello hello, I am back! It’s been awhile since my last blog post. Almost 6 months to be exact. I’ve been busy contributing to open source projects like SolidStart and TanStack Start, and writing less as a result.
That’s not why you’re here though. You’ve been playing with Solid and want to know what the actual best practices are. Below contains guidance on the things I see people stumble over the most. I’ve tried to keep it Solid-specific, rather than rehashing generic frontend fundamentals though plenty of those still apply.
Solid
Call functions when passing signals to JSX props
One of the first questions people ask when using Solid’s signals is whether to pass the signal itself or the value to JSX props.
function App() {
const [id, setId] = createSignal(0);
return (
<>
{/* Call the function */}
<User id={id()} name="Brenley" />
{/* or not */}
<User id={id} name="Brenley" />
</>
);
}
One of Solid’s principles is that components shouldn’t need to care whether a prop came from a signal or not. If you don’t call the function, the receiving component now has to treat its props differently. There is a reason there is no isSignal helper.
For example:
function App() {
const [id, setId] = createSignal(0);
return <User id={id} name="Brenley" />;
}
function User(props: { id: Accessor<number>; name: string }) {
return (
<h1>
{props.id()} – {props.name}
</h1>
);
}
This starts to feel awkward — one prop is reactive and the other isn’t. A cleaner approach is:
function App() {
const [id, setId] = createSignal(0);
return <User id={id()} name="Brenley" />;
}
function User(props: { id: number; name: string }) {
return (
<h1>
{props.id} – {props.name}
</h1>
);
}
The way I think about this is that any signal read inside JSX becomes a dependency. That’s exactly where Solid expects reactivity to live. In this case, you want the JSX to track the id() call and update when id changes.
Don’t destructure props
In Solid, JSX props are reactive by default. Under the hood, they’re accessed through getters so that reading a prop inside JSX or a reactive scope automatically tracks dependencies.
This works great as long as those getters are allowed to flow through your code.
When you destructure props, you extract the property value instead of preserving the getter. That breaks the reactive connection.
For example:
function User(props: { name: string }) {
const { name } = props;
return <h1>{name}</h1>;
}
At first glance this looks fine, but the destructured name is no longer reactive. If the parent updates name, this component won’t update.
The correct version is to access props directly:
function User(props: { name: string }) {
return <h1>{props.name}</h1>;
}
Here, the getter stays intact. Solid can track the read, and updates work as expected.
If you really need to destructure props, Solid provides splitProps, which preserves reactivity.
This is a common source of “why isn’t this updating?” bugs in Solid, and once you know how props work, it makes sense.
Use function wrappers when something needs to be reactive
In Solid, it’s important to be aware of where reactive tracking happens. Signals only create dependencies when they’re read inside a reactive scope such as JSX, createEffect, or createMemo.
The component body itself is not reactive. It runs once as setup code.
That means if you read a signal directly in the component body, it won’t update over time.
For example:
function App() {
const [count, setCount] = createSignal(0);
const doubled = count() * 2;
console.log(doubled); // logged once, never updates
}
Even if setCount is called later, this will only log once. The signal was read outside of any reactive scope.
To make this reactive, we need two changes:
- Delay the signal read by wrapping it in a function
- Execute that function inside a reactive scope
Here’s the same example using a function wrapper and createEffect:
function App() {
const [count, setCount] = createSignal(0);
const doubled = () => count() * 2;
createEffect(() => {
console.log(doubled());
});
}
Now the read happens inside the effect, so Solid can track the dependency and rerun the effect when count changes.
JSX expressions also create reactive scopes, so this works as well:
function App() {
const [count, setCount] = createSignal(0);
const doubled = () => count() * 2;
return (
<>
<div>{doubled()}</div>
<div>{doubled()}</div>
</>
);
}
We can improve this with one more change. Right now every time we call doubled() it will rerun the calculation. If it doesn’t change we can reuse the previous value. This is where createMemo comes in.
function App() {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
return (
<>
<div>{doubled()}</div>
<div>{doubled()}</div>
</>
);
}
Use Solid’s control-flow components (<Show>, <For>)
When coming from React, a common pattern is to use JavaScript conditionals directly in JSX for rendering:
function App() {
return <>{open() && <SidebarMenu />}</>;
}
This works in Solid, but it’s not the most idiomatic approach. Solid provides the <Show> control-flow component for a reason, and you should generally prefer it.
Here’s the equivalent using <Show>:
import { Show } from "solid-js";
function App() {
return (
<Show when={open()}>
<SidebarMenu />
</Show>
);
}
The key difference is that <Show> gives you explicit control-flow that Solid can optimize and reason about. The when condition is tracked, and the children are only evaluated when that condition is truthy.
As conditions get more complex <Show> scales much better:
<Show when={open()} fallback={<EmptyState />}>
<SidebarMenu />
</Show>
Beyond the technical benefits, I also find <Show> easier to read. It communicates “conditional rendering” directly, instead of relying on inline JavaScript expressions embedded in JSX.
Similar to conditionals, it’s tempting to reach for JavaScript array methods directly in JSX:
{
items().map(item => <Item item={item} />);
}
In this case <For> is the better default.
<For each={items()}>{item => <Item item={item} />}</For>
<For> exists for a reason. It preserves item identity and updates the DOM with minimal recalculations when the list changes. This becomes especially important for dynamic or frequently updated lists.
Use createEffect sparingly
createEffect should mostly be used as a last resort. Solid 2.0 will further reduce the need for effects by making common patterns more declarative.
The following are common scenarios where people reach for createEffect when they shouldn’t.
Fetching async data
It’s tempting to fetch data inside an effect:
const [posts, setPosts] = createSignal([]);
createEffect(async () => {
const data = await fetch("/api/posts").then(r => r.json());
setPosts(data);
});
This has several problems. The effect runs after the component renders, so you get a flash of empty state. There’s no built-in error handling or loading state. And if dependencies change, you can end up with race conditions.
Instead, use createResource or createAsync (in SolidStart):
const [posts] = createResource(() => fetch("/api/posts").then(r => r.json()));
This gives you proper Suspense integration, automatic loading/error states, and handles race conditions correctly.
State synchronization
Another common anti-pattern is using effects to keep two pieces of state in sync:
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Doe");
const [fullName, setFullName] = createSignal("");
createEffect(() => {
setFullName(`${firstName()} ${lastName()}`);
});
This works, but it introduces unnecessary indirection. The relationship between fullName and the source signals is hidden inside an effect.
The derived version is simpler:
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Doe");
const fullName = () => `${firstName()} ${lastName()}`;
The legitimate use case for createEffect is interacting with the outside world — especially the DOM or third-party libraries that Solid doesn’t control.
Derive as much as possible
This is another best practice that is pretty standard across frameworks just like the createEffect one above.
Solid builds a fine-grained dependency graph at runtime. When you derive values declaratively, Solid knows exactly what depends on what and can update only the minimal parts of your app when something changes.
The moment you reach for createEffect to keep values in sync, you step outside that graph.
For example, this is a common anti-pattern:
const [count, setCount] = createSignal(0);
const [double, setDouble] = createSignal(0);
createEffect(() => {
setDouble(count() * 2);
});
This works, but it introduces manual synchronization and makes the relationship between count and double implicit.
The derived version is simpler and strictly better:
const [count, setCount] = createSignal(0);
const double = createMemo(() => count() * 2);
Use stores when dealing with complex objects
When dealing with complex or nested objects, stores are usually the right tool instead of signals.
Signals are designed for primitive values or simple references. When you store an object in a signal and update it, you replace the entire object. That means everything reading the signal is notified, even if only a small part of the object changed.
Stores, on the other hand, provide fine-grained reactivity at the property level. You can update a nested property, and only the parts of your UI that depend on that specific property will re-render.
For example, prefer this:
const [board, setBoard] = createStore({
boards: ["Board 1", "Board 2"],
notes: ["Note 1", "Note 2"],
});
over this:
const [board, setBoard] = createSignal({
boards: ["Board 1", "Board 2"],
notes: ["Note 1", "Note 2"],
});
The difference becomes clear when you update. With a signal, you need to replace the whole object:
// Signal approach - replaces entire object
setBoard({
...board(),
notes: [...board().notes, "Note 3"],
});
With a store, you can update just what changed using mutable reactivity.
// Store approach - updates only the notes array
setBoard(notes => notes.push("Note 3"));
This granularity matters for performance. In the signal version, any component reading board() will re-evaluate when anything changes. In the store version, a component reading board.notes won’t react to changes in board.boards.
Another benefit is that stores are deeply reactive by default. If you access a nested property in JSX, Solid tracks that specific path:
function App() {
const [board, setBoard] = createStore({
settings: { theme: "light" },
notes: [],
});
return (
<>
{/* Only re-renders when theme changes */}
<ThemeDisplay theme={board.settings.theme} />
{/* Only re-renders when notes change */}
<NotesList notes={board.notes} />
</>
);
}
SolidStart
Don’t await in preload functions (loaders)
Something different about SolidStart than other frameworks is the concept of preloading instead of loaders.
A preload function is not meant to resolve data; it’s meant to start the work as early as possible.
SolidStart encourages not awaiting in preload and letting the component handle suspending and resolving the promise for you.
For example:
export const route = {
preload: () => getPosts(),
} satisfies RouteDefinition;
export default function Page() {
const posts = createAsync(() => getPosts());
}
Here we are just warming up the getPosts() cache. As soon as navigation starts, getPosts() begins executing. By the time the component renders, the promise may already be resolved or at least partially complete.
Use queries/server functions to get data from the server
The preload pattern above works because of the concept of queries.
In SolidStart, the query function wraps a server function with a unique key, enabling request deduplication, caching, and cache invalidation. Here’s a simple example:
const getPosts = query(async () => {
"use server";
return await db.from("posts").select();
}, "posts");
Use actions to mutate data
When it comes to mutating server data, actions are the missing half of queries.
If you use the concept of actions, revalidating data becomes so easy.
const addPost = action(async (formData: FormData) => {
"use server";
const post = await db
.from("posts")
.insert({ title: formData.get("title") })
.select()
.single();
throw redirect(`/posts/${post.id}`);
}, "addPost");
When an action completes, SolidStart knows that server state has changed. If you redirect after the mutation, the router automatically revalidates all queries for the next route.
Because both queries and actions are keyed, you can also opt into fine-grained control by revalidating specific keys instead of the entire route when needed.
Conclusion
These are the Solid best practices that come up for me most often — especially when helping people move from React.
If there’s one theme running through all of this, it’s to work with Solid’s reactivity model, not against it. Keep your getters intact, derive instead of sync, and let the framework do the heavy lifting.
If you have questions or want to share your own tips, find me on Twitter or drop by the Solid Discord. Happy coding!