SolidJS Anti-Patterns We Learned the Hard Way
After migrating our entire frontend from React to SolidJS, we spent weeks hunting down subtle bugs. Most of them traced back to the same root cause: writing React code in a SolidJS codebase.
This is a catalog of every mistake we made, organized by category. If you’re migrating from React or just getting started with SolidJS, this might save you some pain.
The Core Mental Model Shift
SolidJS looks like React. It is not React.
In React, your component function runs on every render. Props are new objects each time. State changes trigger re-execution of the entire function body.
In SolidJS, your component function runs once. The JSX creates a reactive graph. Signals and effects update that graph without re-running the component. This is why SolidJS is fast. It’s also why React patterns break.
1. Props Destructuring
This is the most common mistake. It’s also the most insidious because your code looks fine and works initially.
The Anti-Pattern
// Loses reactivity - props are captured at function call time
function Badge({ class: className, variant, ...props }: BadgeProps) {
return <span class={cn(badgeVariants({ variant }), className)} {...props} />;
}
When the parent changes variant, nothing happens. The destructured value was captured when the component mounted.
The Fix
import { splitProps } from "solid-js"
function Badge(props) {
const [local, rest] = splitProps(props, ["class", "variant"])
return (
<span
class={cn(badgeVariants({ variant: local.variant }), local.class)}
{...rest}
/>
)
}
splitProps maintains the reactive connection. Access local.variant in JSX and it updates when the parent changes.
We found this pattern in 7 UI component files: badge, dialog, scroll-area, separator, skeleton, avatar, and popover. Every one of them was silently broken.
2. Store Reactivity with Sets and Records
We tried to track expanded cards with a Set<string>. Clicking one card expanded all three.
The Anti-Pattern Progression
// Attempt 1: Set doesn't work with fine-grained reactivity
const [expandedCards, setExpandedCards] = createSignal<Set<string>>(new Set());
// Attempt 2: Record still doesn't work - entire signal updates
const [expandedCards, setExpandedCards] = createSignal<Record<string, boolean>>(
{},
);
// Attempt 3: createStore with Record - broken because API returned duplicate IDs
const [expandedCards, setExpandedCards] = createStore<Record<string, boolean>>(
{},
);
Every attempt failed. The issue: createSignal tracks the entire object reference. When any key changes, all subscribers update.
The Fix
import { createStore } from "solid-js/store"
// Use array index for guaranteed unique keys
const [expandedCards, setExpandedCards] = createStore<boolean[]>([])
const toggleCard = (index: number) => {
setExpandedCards(index, (prev) => !prev)
}
// Access directly in JSX - not through a helper function
<For each={items()}>
{(item, index) => (
<div class={expandedCards[index()] ? "open" : "closed"}>
<button onClick={() => toggleCard(index())}>Toggle</button>
</div>
)}
</For>
Key insights:
createStoreprovides per-property reactivity- Array indices guarantee uniqueness (API data might not)
- Access store properties directly in JSX, not through helper functions
3. Helper Functions Breaking Reactive Tracking
This one is subtle. You write a clean helper function and reactivity stops working.
The Anti-Pattern
{(provider) => {
// Helper function - looks clean, breaks tracking
const isSelected = () =>
props.activeFilters.vendor === provider.id ||
props.activeFilters.provider === provider.id
return (
<button data-selected={isSelected() ? "" : undefined}>
{provider.name}
</button>
)
}}
The helper function creates a new getter for each item in the loop. While reactivity is technically maintained (the getter is called in JSX), it’s verbose and can cause issues if the getter is stored or passed around.
The Fix
{(provider) => (
<button
data-selected={
(props.activeFilters.vendor === provider.id ||
props.activeFilters.provider === provider.id)
? "" : undefined
}
>
{provider.name}
</button>
)}
Inline the expression. It’s more direct and there’s no intermediate function to confuse the reactive system.
For complex derived state, use createMemo:
const subjects = createMemo(
() => searchResults() ?? topSubjectsQuery.subjects(),
);
const loading = createMemo(
() =>
topSubjectsQuery.isLoading() || (isSearching() && subjects().length === 0),
);
createMemo caches the computation and only recalculates when dependencies change.
4. setTimeout Patterns from React
React batches state updates asynchronously. Developers learn to use setTimeout(fn, 0) to ensure state is “ready.” SolidJS updates synchronously. These patterns are not just unnecessary---they cause bugs.
The Anti-Pattern
const handleImageClick = (src: string) => {
setMediaUrl(src);
// "Small delay to ensure state is set before opening modal"
setTimeout(() => {
openImageViewer();
}, 10);
};
This is cargo cult programming. The comment reveals the confusion: SolidJS state is set immediately. The timeout just delays the modal opening for no reason.
The Fix
const handleImageClick = (src: string) => {
setMediaUrl(src);
openImageViewer(); // State is already set. Just call it.
};
If you need atomic updates across multiple signals, use batch:
import { batch } from "solid-js";
batch(() => {
setNodeData(node);
setOnDelete(() => deleteCallback);
});
openModal();
We found 6 files with unnecessary timing workarounds. Every one of them was a React habit that didn’t translate.
5. Missing Effect Cleanup
Effects that create timers, event listeners, or animation frames need cleanup. Skip it and you get memory leaks.
The Anti-Pattern
createEffect(() => {
const audio = audioRef();
if (!audio) return;
// Listeners added but never removed
audio.addEventListener("playing", () => setIsPlaying(true));
audio.addEventListener("pause", () => setIsPlaying(false));
audio.addEventListener("ended", () => setIsPlaying(false));
});
Every time this effect re-runs, it adds new listeners. The old ones stick around.
The Fix
createEffect(() => {
const audio = audioRef();
if (!audio) return;
const onPlaying = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => setIsPlaying(false);
audio.addEventListener("playing", onPlaying);
audio.addEventListener("pause", onPause);
audio.addEventListener("ended", onEnded);
onCleanup(() => {
audio.removeEventListener("playing", onPlaying);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("ended", onEnded);
});
});
Named function references enable proper cleanup. onCleanup runs before the effect re-runs and when the component unmounts.
Same pattern for animation frames:
createEffect(() => {
let rafId: number | null = null;
rafId = requestAnimationFrame(() => autoResizeTextarea());
onCleanup(() => {
if (rafId !== null) cancelAnimationFrame(rafId);
});
});
6. Closure Captures in Async Callbacks
This is the trickiest anti-pattern because it involves understanding when values are captured versus when they’re read.
The Anti-Pattern
createEffect(() => {
const updateState = getUpdateState();
if (updateState.justUpdated) {
setTimeout(async () => {
// updateState was captured when setTimeout was called
// If it changed, we're using stale data
if (await shouldShowForVersion(updateState.currentVersion)) {
setCurrentVersion(updateState.currentVersion);
}
}, 1500);
}
});
updateState is captured in the closure at scheduling time, not execution time. If state changes during the 1500ms delay, you’re operating on stale data.
The Fix
Use signals that are read at execution time:
const [shouldShowUpdate, setShouldShowUpdate] = createSignal(false);
createEffect(() => {
const updateState = getUpdateState();
if (updateState.justUpdated) {
setShouldShowUpdate(true);
}
});
createEffect(() => {
if (shouldShowUpdate()) {
const timer = setTimeout(async () => {
const currentState = getUpdateState(); // Read fresh state
if (await shouldShowForVersion(currentState.currentVersion)) {
setCurrentVersion(currentState.currentVersion);
}
setShouldShowUpdate(false);
}, 1500);
onCleanup(() => clearTimeout(timer));
}
});
The key insight: Signals are read when called. Closures capture values when created. Use signals for any state that needs to be current at execution time.
7. Promise.resolve().then() for Microtask Delays
Another React pattern that backfires.
The Anti-Pattern
const showMemoryNode = (node: Memory) => {
if (!isModalActuallyOpen && currentNodeData?.id === node.id) {
context.setNodeData(null);
// "Force a microtask delay to ensure SolidJS processes the null state"
Promise.resolve().then(() => {
context.setNodeData(node); // node captured at Promise creation time
openModal();
});
return;
}
};
Same problem as setTimeout: the parameters are captured when the Promise is created, not when the callback executes.
The Fix
const showMemoryNode = (node: Memory) => {
batch(() => {
context.setNodeData(null);
context.setNodeData(node); // SolidJS handles this correctly
});
openModal();
};
SolidJS’s reactivity system doesn’t need microtask delays. batch groups updates if you need atomicity.
8. Conditional Rendering with && and Ternaries
React developers write {condition && <Component />}. It works. In SolidJS, it creates problems.
The Anti-Pattern
function UserProfile(props) {
if (!props.userId) return null // Early return before hooks is also bad
return <ProfileContent userId={props.userId} />
}
// Or ternary in JSX
{loading ? <Loading /> : <Content data={data} />}
SolidJS builds a reactive graph once. Early returns and ternaries don’t fit that model---they execute once and don’t update.
The Fix
function UserProfile(props) {
return (
<Show when={props.userId} fallback={null}>
<ProfileContent userId={props.userId} />
</Show>
)
}
// Switch for multiple conditions
<Switch>
<Match when={status() === "loading"}>
<LoadingState />
</Match>
<Match when={status() === "error"}>
<ErrorState />
</Match>
<Match when={status() === "success"}>
<SuccessState />
</Match>
</Switch>
<Show> and <Switch>/<Match> are reactive. They update when their conditions change.
9. List Rendering with .map()
React uses .map() for lists. SolidJS has <For>.
The Anti-Pattern
// Creates new array on every parent update
{messages().map((message) => (
<ChatMessage key={message.id} data={message} />
))}
The Fix
// Memoize the source array
const reversedMessages = createMemo(() => [...messages()].reverse())
// Use For component
<For each={reversedMessages()}>
{(message) => <ChatMessage data={message} />}
</For>
<For> tracks items by reference. Memoize array transformations (reverse, sort, filter) with createMemo to prevent unnecessary recalculation.
10. Animation Timeout Without Cleanup
One more cleanup gotcha, specific to animation delays.
The Anti-Pattern
const hideConfirmationDialog = () => {
setOpen(false);
// Clear config after animation finishes
setTimeout(() => setConfig(null), 300);
};
Call this twice quickly and you have two timeouts queued. The config gets nulled unpredictably.
The Fix
let animationTimer: number | null = null;
const hideConfirmationDialog = () => {
setOpen(false);
if (animationTimer) clearTimeout(animationTimer);
animationTimer = setTimeout(() => {
setConfig(null);
animationTimer = null;
}, 300);
};
// Or better, use a signal:
createEffect(() => {
if (!open()) {
const timer = setTimeout(() => setConfig(null), 300);
onCleanup(() => clearTimeout(timer));
}
});
Track timers at module level or use effects with cleanup.
The Audit Results
We ran a comprehensive audit across our codebase. Here’s what we found:
| Category | Files Affected | Severity |
|---|---|---|
| Props destructuring | 7 | High |
| Timing workarounds | 6 | Critical |
| Missing effect cleanup | 5 | Medium |
| Store/signal access | 4 | Medium |
| Closure captures | 3 | Critical |
27 anti-patterns across 19 files. All fixed in a single focused sprint.
Key Takeaways
-
Never destructure props. Use
splitPropsor accessprops.directly. -
Use
createStorefor collections where individual items need independent reactivity. -
Signals are read, closures capture. If you need current state in an async callback, read a signal at execution time.
-
Drop the timing workarounds. SolidJS state updates are synchronous. You don’t need
setTimeout(fn, 0). -
Always clean up effects that create timers, listeners, or animation frames.
-
Use
<Show>and<For>instead of ternaries,&&, and.map(). -
Memoize derived state with
createMemoto prevent recalculation.
The patterns are simple once you internalize them. The hard part is unlearning React habits. Your intuitions will be wrong for a while. That’s normal.
If you’re migrating from React, budget time for exactly these kinds of bugs. They’re subtle, they look correct at first glance, and they’ll bite you in production.
This post is based on our internal audits after migrating Ditto from React to SolidJS. All the anti-patterns described here were found in our actual codebase.
We’ve packaged everything we learned into a reusable skill for AI coding agents:
npx skills add https://github.com/omniaura/skills --skill solidjs-patterns
— Peyton