| name | solidjs |
| description | Conventions and idioms for SolidJS 1.x and SolidStart 1.x — fine-grained reactivity, signals, control-flow components, resources, and stores. Use when writing Solid or SolidStart code, when the user says "SolidJS", "createSignal", or "Solid Start", or when the repo contains app.config.ts with @solidjs/start or solid-js in package.json. |
SolidJS 1.x + SolidStart 1.x
Solid looks like React but is not React. The mental model difference drives every rule below.
Components run once
A Solid component function executes once, at mount. There are no re-renders. JSX compiles to real DOM nodes; only the reactive expressions inside (signal reads in effects, JSX, memos) rerun when their dependencies change. Consequences:
- You can safely use
if, loops, and top-level logic in a component body for setup, but anything that must react to change must read signals inside a tracking scope: JSX expressions, createEffect, createMemo, or component props accessed as getters.
- Dependencies are tracked automatically at runtime by what you read — there are no dependency arrays anywhere in Solid.
Primitives
const [count, setCount] = createSignal(0);
count();
setCount((c) => c + 1);
const doubled = createMemo(() => count() * 2);
createEffect(() => console.log(count()));
onCleanup(() => clearInterval(t));
onMount(() => {...});
- Read signals by calling them:
count(), not count. Passing count (uncalled) passes the getter — good for props; passing count() at setup passes a frozen snapshot.
createMemo only for expensive computations or values read in many places; plain derived functions (const total = () => a() + b()) are often enough.
createEffect is for escaping to the outside world (DOM APIs, loggers, charts). Do not use it to compute state from other state — use a memo or derived function.
- Explicit deps when needed:
createEffect(on(count, (c) => …, { defer: true })). untrack(() => sig()) reads without subscribing. batch groups writes.
Props are reactive getters — never destructure
function Card({ title, item }) { … }
function Card(props: { title: string; item: Item }) {
return <h2>{props.title}</h2>;
}
Same for spreads and early reads: const t = props.title at the top of the component freezes it. Use mergeProps(defaults, props) for defaults and splitProps(props, ['class']) to partition — both preserve reactivity. children(() => props.children) when you need to inspect/reuse children.
Control flow: components, not array methods
<Show when={user()} fallback={<Login />}>
{(u) => <Profile user={u()} />}
</Show>
<For each={items()}>{(item, i) => <Row item={item} index={i()} />}</For>
<Switch fallback={<NotFound />}>
<Match when={state() === 'loading'}><Spinner /></Match>
<Match when={state() === 'ready'}><View /></Match>
</Switch>
items().map(...) in JSX recreates every node on every change — use <For> (keyed by reference) or <Index> (keyed by index; better for primitives/fixed-length lists).
- Prefer
<Show> to ternaries for non-trivial branches; the callback form gives you a narrowed, non-null accessor.
<Dynamic component={comp()} /> for component-by-value, <Portal> for overlays, <ErrorBoundary> for error capture.
Async: resources and Suspense
const [post] = createResource(() => params.id, fetchPost);
<Suspense fallback={<Skeleton />}>
<h1>{post()?.title}</h1>
</Suspense>
Reading a resource under <Suspense> triggers the fallback; post.loading / post.error are available for manual handling. In SolidStart prefer createAsync + query (below).
Stores for nested state
const [state, setState] = createStore({ todos: [] as Todo[] });
setState('todos', (t) => t.id === id, 'done', true);
setState('todos', produce((todos) => { todos.push(newTodo); }));
setState('todos', reconcile(fromServer));
Use signals for atomic values, stores for objects/arrays/graphs. Store updates are fine-grained — only what changed re-executes. reconcile when replacing a store with fetched data so unchanged rows don't re-render.
SolidStart 1.x
- File routing under
src/routes/: index.tsx, posts/[id].tsx, posts.tsx + posts/ for layouts, (group) folders, *404.tsx catch-all. API routes export GET/POST handlers.
- Data loading:
query + createAsync, preloaded via route preload:
const getPost = query(async (id: string) => {
'use server';
return db.post.find(id);
}, 'post');
export const route = { preload: ({ params }) => getPost(params.id) } satisfies RouteDefinition;
export default function Post(props: RouteSectionProps) {
const post = createAsync(() => getPost(props.params.id));
return <Suspense fallback={<Skeleton />}><h1>{post()?.title}</h1></Suspense>;
}
- Mutations:
action(async (formData: FormData) => { 'use server'; … }) used as <form action={myAction} method="post">; pending/result via useSubmission(myAction). Actions automatically revalidate queries; scope with revalidate keys.
'use server' marks server-only functions callable from the client as RPC. Secrets/DB go behind it or in server-only utilities.
Pitfalls (especially with React habits)
- Destructuring props — kills reactivity. Always
props.x at use sites.
- Reading signals outside tracking scopes — a signal read at component top level runs once and never updates; move the read into JSX/effect/memo.
- Dependency arrays — don't exist.
createEffect(() => {...}, [dep]) is wrong; the second argument is the initial value. Tracking is automatic; use on() for explicit deps.
.map() in JSX — use <For>/<Index>.
createEffect to derive state — use createMemo/derived functions; effects run after render and cause extra work and tearing.
- Expecting
useState semantics — count is a getter function; JSX like {count} renders nothing useful, and conditions like count > 0 compare a function.
- Early returns for conditional rendering —
if (!props.ready) return null runs once and sticks forever. Use <Show>.
Read references/reactivity-gotchas.md when debugging "it doesn't update" issues or reviewing code written with React instincts.