SolidJS Patterns
Purpose
Apply production patterns to SolidJS applications: async data with createResource, controlled forms with signals and Zod, component composition patterns, and animation.
Agent Protocol
Trigger
Exact user phrases: "SolidJS pattern", "SolidJS form", "SolidJS data fetching", "SolidJS component pattern", "SolidJS animation".
Input Context
Before activating, verify:
- SolidJS project with solid-js package.
- Whether @tanstack/solid-form or custom forms are used.
- If css transitions or Solid Flip is available for animation.
Output Artifact
No file output. Produces code patterns for data fetching, forms, composition, and animation.
Response Format
Code: show resource, form, and composition examples. No imports beyond SolidJS APIs.
No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.
Completion Criteria
- Data fetching uses createResource with Suspense boundaries.
- createResource refetches when source signal changes.
- Forms use controlled inputs with signals and Zod validation.
- Component composition uses JSX children as functions or slot patterns.
- Animation uses createTransition, Solid Flip, or CSS transitions with signals.
Max Response Length
Code: 15 lines per example.
Component Architecture / Decision Trees
Architecture Options
| Approach | Trade-off | When to Use |
|---|---|---|
| createResource + Suspense | Simple async data, loading states | API data fetching |
| createResource with mutate | Optimistic updates, cache control | Dashboard, live data |
| Controlled form with signals | Full control over validation | Complex forms |
| Children as functions (render props) | Flexible composition | List, Table, Dropdown |
| Slot pattern via props | Explicit API | Card, Layout, Modal |
| createTransition | Pending state for deferred updates | Tab switching, navigation |
Decision Tree: Data Fetching
Is data needed on page load?
├── Yes -> createResource in component
│ ├── Depends on signal? -> Pass signal as source
│ └── Static -> Pass constant source
└── No -> fetch on interaction (event handler)
Decision Tree: Form Complexity
How many fields?
├── 1-3 fields -> createSignal per field
├── 4-10 fields -> createStore for the form object
└── 10+ fields or nested -> createStore + field arrays
Decision Tree: Component Composition
Does the component need to customize rendering?
├── Yes -> Children as functions or render props
└── No -> Regular props + children
Does the component have optional sections?
├── Yes -> Slot pattern (header, footer, sidebar)
└── No -> Single children slot
Component Design Patterns
Data Fetching with Loading and Error States
function ProductList() {
const [products] = createResource(() => '/api/products', fetchProducts)
return (
<Suspense fallback={<ProductSkeleton />}>
<ErrorBoundary fallback={<div>Failed to load products</div>}>
<For each={products()}>
{(product) => <ProductCard product={product} />}
</For>
</ErrorBoundary>
</Suspense>
)
}
createResource with Dependent Fetching
function UserPosts() {
const params = useParams()
const userId = () => params.id
const [user] = createResource(userId, fetchUser)
const [posts] = createResource(user, (u) => u ? fetchPosts(u.id) : [])
return (
<Suspense fallback={<div>Loading...</div>}>
<h1>{user()?.name}'s Posts</h1>
<For each={posts()}>{(post) => <PostCard post={post} />}</For>
</Suspense>
)
}
Optimistic Update with mutate
function LikeButton(props: { postId: string }) {
const [liked, setLiked] = createSignal(false)
const toggleLike = async () => {
const previous = liked()
setLiked(!liked()) // optimistic update
try {
await api.toggleLike(props.postId)
} catch {
setLiked(previous) // rollback
}
}
return <button ? 'Unlike' : 'Like'}</button>
}
Complex Form with createStore
function OrderForm() {
const [form, setForm] = createStore({
customer: { name: '', email: '' },
items: [] as { productId: string; quantity: number }[],
shipping: { address: '', city: '', zip: '' },
errors: {} as Record<string, string>,
})
const addItem = () => setForm('items', items => [...items, { productId: '', quantity: 1 }])
const removeItem = (i: number) => setForm('items', items => items.filter((_, idx) => idx !== i))
const updateItem = (i: number, field: string, value: any) => setForm('items', i, field, value)
const total = createMemo(() => form.items.reduce((sum, i) => sum + i.quantity * 10, 0))
const handleSubmit = async (e: Event) => {
e.preventDefault()
const result = orderSchema.safeParse(form)
if (!result.success) {
setForm('errors', result.error.flatten().fieldErrors)
return
}
await api.createOrder(result.data)
}
return (
<form
<input value={form.customer.name} => setForm('customer', 'name', e.currentTarget.value)} />
<For each={form.items}>{(item, i) => (
<div>
<input value={item.productId} => updateItem(i(), 'productId', e.currentTarget.value)} />
<input type="number" value={item.quantity} => updateItem(i(), 'quantity', Number(e.currentTarget.value))} />
<button type="button" => removeItem(i())}>Remove</button>
</div>
)}</For>
<button type="button" Item</button>
<p>Total: {total()}</p>
<button type="submit">Submit Order</button>
</form>
)
}
Render Props Composition
function DataList<T>(props: {
each: T[]
children: (item: T, index: () => number) => JSX.Element
}) {
return (
<ul>
<For each={props.each}>
{(item, i) => <li>{props.children(item, i)}</li>}
</For>
</ul>
)
}
// Usage
<DataList each={users()}>
{(user, i) => (
<div>
<span>{i() + 1}.</span>
<span>{user.name}</span>
</div>
)}
</DataList>
Slot Pattern
function Card(props: {
header?: JSX.Element
footer?: JSX.Element
children: JSX.Element
}) {
return (
<div class="card">
{props.header && <div class="card-header">{props.header}</div>}
<div class="card-body">{props.children}</div>
{props.footer && <div class="card-footer">{props.footer}</div>}
</div>
)
}
Debounced Search
function Search() {
const [query, setQuery] = createSignal('')
const [debouncedQuery, setDebounced] = createSignal('')
createEffect(() => {
const timer = setTimeout(() => setDebounced(query()), 300)
onCleanup(() => clearTimeout(timer))
})
const [results] = createResource(debouncedQuery, searchApi)
return (
<div>
<input value={query()} => setQuery(e.currentTarget.value)} placeholder="Search..." />
<Suspense fallback={<div>Searching...</div>}>
<For each={results()}>{(item) => <div>{item.title}</div>}</For>
</Suspense>
</div>
)
}
State Management Patterns
Signal-Based Local State
const [isOpen, setIsOpen] = createSignal(false)
const [selectedTab, setSelectedTab] = createSignal('info')
Store-Based Complex State
const [filters, setFilters] = createStore({
search: '',
category: null as string | null,
priceRange: { min: 0, max: 1000 },
sortBy: 'date' as 'date' | 'price' | 'name',
})
setFilters('search', 'widget')
setFilters('priceRange', 'max', 500)
Resource-Based Server State
const [data, { mutate, refetch }] = createResource(source, fetcher)
mutate(optimisticData) // update locally without refetch
refetch() // force server re-fetch
Context-Based Shared State
const AppContext = createContext<{ theme: () => string; user: () => User | null }>()
// Provider wraps app, consumer uses useContext(AppContext)
Performance Optimization
Granular Reactivity
SolidJS updates only the DOM nodes that depend on changed signals. A list with 10,000 items updating one item updates exactly one DOM text node — not the list, not the parent.
createMemo Caching
Memos only recompute when their dependencies change. Use createMemo for expensive computations:
const processed = createMemo(() => heavyComputation(items()))
Avoiding Unnecessary Tracking
Use untrack to read a signal without creating a dependency:
createEffect(() => {
console.log(untrack(() => count())) // logs but doesn't subscribe
})
Lazy Loading Routes
const Dashboard = lazy(() => import('./pages/Dashboard'))
// Dashboard code only loads when navigated to
Batch Updates
SolidJS batches updates automatically within event handlers and effects. No manual batching needed.
Build & Bundle Considerations
Production Build
npm run build
# Output in dist/assets/ — JS chunks, CSS, HTML
Bundle Analysis
npx vite build --analyze
# or add rollup-plugin-visualizer to vite.config.ts
Tree Shaking
- SolidJS runtime treeshakes unused reactive primitives
- Use named imports from solid-js (import { createSignal } not import * as Solid)
- Unused components in lazy imports are not included
TypeScript Config
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"target": "ESNext",
"module": "ESNext"
}
}
Testing Strategies
Unit Tests for Signals
import { createSignal, createMemo, createEffect } from 'solid-js'
import { describe, it, expect } from 'vitest'
describe('signal logic', () => {
it('computed values update correctly', () => {
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
expect(doubled()).toBe(0)
setCount(3)
expect(doubled()).toBe(6)
})
})
Component Tests
import { render, screen, fireEvent } from 'solid-testing-library'
import { describe, it, expect } from 'vitest'
import Counter from './Counter'
describe('Counter', () => {
it('renders and increments', () => {
render(() => <Counter />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent('0')
fireEvent.click(button)
expect(button).toHaveTextContent('1')
})
})
E2E Tests
import { test, expect } from '@playwright/test'
test('search flow', async ({ page }) => {
await page.goto('/products')
await page.fill('[placeholder="Search..."]', 'widget')
await page.waitForResponse(/api\/search/)
await expect(page.locator('.product-card')).toHaveCount(3)
})
Integration Tests with MSW
import { server } from '../mocks/server'
import { render, screen } from 'solid-testing-library'
import ProductList from './ProductList'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('shows products from API', async () => {
render(() => <ProductList />)
await screen.findByText('Widget')
expect(screen.getByText('$10')).toBeDefined()
})
Migration Patterns
React to SolidJS
// React useState + useEffect + useMemo
const [user, setUser] = useState(null)
const [posts, setPosts] = useState([])
useEffect(() => { fetchUser(id).then(setUser) }, [id])
useEffect(() => { if (user) fetchPosts(user.id).then(setPosts) }, [user])
const displayName = useMemo(() => user?.name.toUpperCase(), [user])
// SolidJS
const [user] = createResource(() => id, fetchUser)
const [posts] = createResource(user, (u) => u ? fetchPosts(u.id) : [])
const displayName = createMemo(() => user()?.name.toUpperCase())
Vue Options API to SolidJS
// Vue: data() + computed + watch
export default {
data: () => ({ count: 0 }),
computed: { doubled: () => this.count * 2 },
watch: { count(val) { console.log(val) } },
}
// Solid: createSignal + createMemo + createEffect
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => console.log(count()))
Svelte to SolidJS
// Svelte
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
// Solid
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => console.log(count()))
Anti-Patterns
Using createEffect for Data Fetching
// Anti-pattern
createEffect(async () => {
const data = await fetch(`/api/users/${id()}`).then(r => r.json())
setUsers(data)
})
// Correct
const [users] = createResource(() => id(), fetchUsers)
Mutating createStore Directly
// Anti-pattern: direct mutation on store ref
const state = createStore({ count: 0 }) // returns [state, setState]
state.count = 5 // doesn't trigger update
// Correct
const [state, setState] = createStore({ count: 0 })
setState('count', 5)
Overusing createEffect
// Anti-pattern: effect for derived display logic
createEffect(() => {
document.title = `Count: ${count()}`
})
// If this is the only thing that needs count, it's fine.
// But consider: can you inline the expression?
Conditional Signal Creation
Components run once — signals at top level only.
Not Providing Error Boundaries
Resources can throw. Wrap resource consumers in ErrorBoundary for production robustness.
Common Pitfalls
- Destructuring signals — always call
count()notcount - createEffect for computation — use createMemo
- Conditional createSignal — top level only
- Missing onCleanup — always clean up subscriptions
- createStore for single values — use createSignal
- Not using Suspense — resources need Suspense boundaries
- Mutating createStore directly — use setState path syntax
- Forgetting ErrorBoundary — resources can throw
Best Practices
createSignalfor primitives,createStorefor objectscreateResourcefor all async data — never fetch in effectscreateMemofor derived values — never createEffect- Always onCleanup in effects with subscriptions
- Signals at top level only — never conditional
- Use
untrack()to read without subscribing - Prefer path syntax for createStore updates
- Wrap resource consumers in Suspense + ErrorBoundary
Compared With
| Aspect | SolidJS | React | Vue |
|---|---|---|---|
| Reactivity | Fine-grained signals | VDOM diff | Proxy-based |
| Component | Once, not re-run | Every render | Once (setup), re-run (template) |
| Async | createResource | TanStack Query | useAsyncData |
| Forms | Manual signals | React Hook Form | v-model |
| Bundle | ~8KB | ~45KB | ~30KB |
| SSR | SolidStart | Next.js | Nuxt |
Tooling
npm run dev— HMR dev servernpm run build— production buildnpx solid-devtools— CLI reactivity inspector- Solid DevTools browser extension
npm create solid— project scaffolding
Rules
- createResource for all async data — never fetch in effects.
- Wrap resource consumers in Suspense.
- Validate forms with Zod on submit — both client and server.
- Children as functions for render-props pattern.
- CSS transitions are preferred — Solid Flip for list reorder.
- Avoid createEffect for data fetching — use createResource.
- Signals for local state, stores for shared state, createResource for server state.
- Use islands architecture for SSR — minimize JavaScript sent to client.
- Choose state management based on scope — don't use global store for local state.
References
- references/solid-data.md — SolidJS Data — createResource, Suspense, Error Boundaries, Lazy Loading
- references/solid-forms.md — SolidJS Forms — Controlled Inputs, Validation, Field Arrays, Custom Form State
- references/solidjs-routing.md — SolidJS Routing Patterns
- references/solidjs-state.md — SolidJS State Management Patterns
- references/solidjs-testing.md — SolidJS Testing Reference
- references/solidjs-ui-patterns.md — SolidJS Patterns
Handoff
No artifact produced. Next skill: frontend-universal-testing for unit/integration tests in SolidJS. Carry forward: resource patterns, form validation approach, composition patterns.