Common SolidJS / Solid-Query Bugs and How to Find Them

Shared by

Updated May 23, 2026

Common SolidJS / Solid-Query Bugs and How to Find Them

A practical field guide based on auditing the ditto-app codebase. Solid-query and SolidJS have a few sharp edges that don't exist in React; most bugs we found fall into one of six categories below. Each section gives the failure mode, a grep pattern to surface candidates, and the fix.


1. Accessing query.data without an isLoading guard inside a reactive scope

Why it's a bug. Solid-query suspends automatically when .data is read while the query is pending. Inside a createMemo, createEffect, or a JSX expression, that throw is caught by the nearest <Suspense> boundary — which in most SolidStart apps is the root. The entire subtree blocks until the query resolves. Optional chaining (query.data?.foo) does NOT save you — the bug is the .data access itself, not the dereference.

// ❌ Suspends the whole tree
const paidTiers = createMemo(() =>
  (tiersQuery.data?.tiers ?? []).filter(monthlyPriceForTier)
)

// ✅ Early return on isLoading
const paidTiers = createMemo(() => {
  if (tiersQuery.isLoading) return []
  return (tiersQuery.data?.tiers ?? []).filter(monthlyPriceForTier)
})

How to find these: Grep for \.data\? and \.data\. inside src/, then for each hit check whether the enclosing createMemo/createEffect has an if (X.isLoading) return ... (or ternary X.isLoading ? ... : ...) earlier in the body. Both forms are equivalent — early-return is just easier to scan.

grep -rn "\.data" src/ --include="*.tsx" --include="*.ts" | grep -v test

Then for each suspect file, look at the createMemo body. False positive rate is high (~70% in our audit had guards already), but the bugs that slip through block entire surfaces.


2. createResource(...) without initialValue

Why it's a bug. createResource suspends by default. If you wrap a component in <Suspense> and the resource hasn't loaded, the whole subtree blocks even when the component has its own loading UI ready to render.

// ❌ Will trigger Suspense on first load
const [prompts] = createResource(source, fetcher)

// ✅ Returns initialValue while loading; component's own .loading flag handles UI
const [prompts] = createResource(source, fetcher, { initialValue: null })

How to find these: grep -rn "createResource" src/ and check every call's third argument. If the JSX downstream already handles .loading, the fix is one line.


3. Destructuring query results

Why it's a bug. Solid-query results are proxies. Destructuring takes a one-time snapshot and breaks reactivity — subsequent updates to .data won't re-render the component.

// ❌ Breaks reactivity
const { data, isLoading } = useQuery(...)

// ✅ Access on the object
const query = useQuery(...)
query.data
query.isLoading

How to find these: grep -rn "const { .* } = use\(Query\|DittoQuery\)" src/. Cheap to enforce — there is no legitimate reason to destructure.


4. Calling Date methods on proxy-wrapped query data

Why it's a bug. SolidJS stores wrap objects in proxies. Date instances coming out of query results or stores are proxy-wrapped. Date.prototype.getTime, toISOString, toLocaleDateString, etc. require real Date internal slots — proxy-wrapped dates throw e.getTime is not a function.

// ❌ Throws on proxy-wrapped Dates from query data
const ts = bookmark.bookmarked_at.getTime()

// ✅ Wrap in new Date() first — restores the internal slot
const ts = new Date(bookmark.bookmarked_at).getTime()

How to find these: Grep for \.getTime\(\), \.toISOString\(\), \.toLocaleDateString\(\) and check whether the receiver came from a query result or store. Locally-constructed Dates (new Date(), dayjs(...).toDate()) are safe.


5. POST/PUT/DELETE in useQuery

Why it's a bug. useQuery is for reads. It refetches, caches, and re-runs on focus — none of which you want for a mutation. Mutations belong in createMutation.

How to find these: grep -rn "useQuery" src/ -A 10 | grep -E "method:\s*\"(POST|PUT|DELETE|PATCH)\"". Rare but catastrophic when it happens.


6. Direct useQuery instead of a project wrapper

Why it's a bug (in projects that have one). Project-specific wrappers (in ditto-app it's useDittoQuery) typically add: auth-gating, userId in cache key, automatic Zod validation, and a consistent fetcher. Bypassing the wrapper means you re-implement these by hand and usually get something wrong (e.g. missing the userId in the cache key, so logout/login shows the previous user's data).

How to find these: grep -rn "from \"@tanstack/solid-query\"" src/ to find every direct useQuery import, then check if the call hits a project API endpoint. Direct useQuery is fine for browser APIs, third-party endpoints, or polling jobs that don't need auth headers — only flag the ones hitting your own backend.


7. Merged boolean states (split-state pattern)

Why it's a bug. When two boolean states have different sources or lifecycles, merging them into one signal hides important distinctions. Example from ditto-app's SendMessage.tsx:

// ❌ One flag conflates "this device is streaming" and "any device is streaming"
const [isWaitingForResponse, setIsWaitingForResponse] = createSignal(false)

// ✅ Split — local SSE vs polled remote detection
const [isLocalStreamActive, setIsLocalStreamActive] = createSignal(false)
const [isWaitingForResponse, setIsWaitingForResponse] = createSignal(false)
// Now polling can stay enabled when !isLocalStreamActive() even while UI shows loading

Hard to grep for. Look for components where a single isLoading (or similar) is set from multiple unrelated code paths — that's the smell.


Audit workflow

When auditing a Solid codebase for these bugs:

  1. Parallel Explore agents work well. Split into 2–3 independent searches (suspense bugs, anti-patterns, perf hotspots) and run them in parallel — they don't overlap.
  2. Trust but verify. Agents over-flag isLoading-guarded sites. Read each flagged file directly before opening a fix PR — about 70% of "no guard" findings in our audit actually had ternary or early-return guards.
  3. Fix sibling patterns first. When you find a clean version of the pattern (e.g. PersonalitySidebarContent.tsx did sync-status memos correctly), use it as the template for the broken sibling (PersonalityAssessmentOverlay.tsx).
  4. Mechanical fixes are PR-per-file. Suspense guards don't change behavior after data loads; they only change the loading transition. Three independent worktree PRs is faster than one batch PR and keeps reviews scannable.

Project-specific notes for ditto-app

  • The CLAUDE.md "Hooks Requiring isLoading Guards" table is the canonical list — keep it updated when you add a new query hook.
  • useDittoQuery (in src/lib/useDittoQuery.ts) is the preferred wrapper. List endpoints should pass fallback: [] so .data is typed T[] instead of T[] | undefined.
  • The .claude/skills/js-perf-debugging/blocking-patterns.md skill has copy-paste fixes for createResource, placeholderData, and Suspense-boundary scoping.