diff --git a/profiles/opencode/skill/solidjs/SKILL.md b/profiles/opencode/skill/solidjs/SKILL.md new file mode 100644 index 0000000..8e00ae7 --- /dev/null +++ b/profiles/opencode/skill/solidjs/SKILL.md @@ -0,0 +1,464 @@ +--- +name: solidjs +description: | + SolidJS framework development skill for building reactive web applications with fine-grained reactivity. + Use when working with SolidJS projects including: (1) Creating components with signals, stores, and effects, + (2) Implementing reactive state management, (3) Using control flow components (Show, For, Switch/Match), + (4) Setting up routing with Solid Router, (5) Building full-stack apps with SolidStart, + (6) Data fetching with createResource, (7) Context API for shared state, (8) SSR/SSG configuration. + Triggers: solid, solidjs, solid-js, solid start, solidstart, createSignal, createStore, createEffect. +--- + +# SolidJS Development + +SolidJS is a declarative JavaScript library for building user interfaces with fine-grained reactivity. Unlike virtual DOM frameworks, Solid compiles templates to real DOM nodes and updates them with fine-grained reactions. + +## Core Principles + +1. **Components run once** — Component functions execute only during initialization, not on every update +2. **Fine-grained reactivity** — Only the specific DOM nodes that depend on changed data update +3. **No virtual DOM** — Direct DOM manipulation via compiled templates +4. **Signals are functions** — Access values by calling: `count()` not `count` + +## Reactivity Primitives + +### Signals — Basic State + +```tsx +import { createSignal } from "solid-js"; + +const [count, setCount] = createSignal(0); + +// Read value (getter) +console.log(count()); // 0 + +// Update value (setter) +setCount(1); +setCount(prev => prev + 1); // Functional update +``` + +**Options:** +```tsx +const [value, setValue] = createSignal(initialValue, { + equals: false, // Always trigger updates, even if value unchanged + name: "debugName" // For devtools +}); +``` + +### Effects — Side Effects + +```tsx +import { createEffect } from "solid-js"; + +createEffect(() => { + console.log("Count changed:", count()); + // Runs after render, re-runs when dependencies change +}); +``` + +**Key behaviors:** +- Initial run: after render, before browser paint +- Subsequent runs: when tracked dependencies change +- Never runs during SSR or hydration +- Use `onCleanup` for cleanup logic + +### Memos — Derived/Cached Values + +```tsx +import { createMemo } from "solid-js"; + +const doubled = createMemo(() => count() * 2); + +// Access like signal +console.log(doubled()); // Cached, only recalculates when count changes +``` + +Use memos when: +- Derived value is expensive to compute +- Derived value is accessed multiple times +- You want to prevent downstream updates when result unchanged + +### Resources — Async Data + +```tsx +import { createResource } from "solid-js"; + +const [user, { mutate, refetch }] = createResource(userId, fetchUser); + +// In JSX +}> +
{user()?.name}
+
+ +// Resource properties +user.loading // boolean +user.error // error if failed +user.state // "unresolved" | "pending" | "ready" | "refreshing" | "errored" +user.latest // last successful value +``` + +## Stores — Complex State + +For nested objects/arrays with fine-grained updates: + +```tsx +import { createStore } from "solid-js/store"; + +const [state, setState] = createStore({ + user: { name: "John", age: 30 }, + todos: [] +}); + +// Path syntax updates +setState("user", "name", "Jane"); +setState("todos", todos => [...todos, newTodo]); +setState("todos", 0, "completed", true); + +// Produce for immer-like updates +import { produce } from "solid-js/store"; +setState(produce(s => { + s.user.age++; + s.todos.push(newTodo); +})); +``` + +**Store utilities:** +- `produce` — Immer-like mutations +- `reconcile` — Diff and patch data (for API responses) +- `unwrap` — Get raw non-reactive object + +## Components + +### Basic Component + +```tsx +import { Component } from "solid-js"; + +const MyComponent: Component<{ name: string }> = (props) => { + return
Hello, {props.name}
; +}; +``` + +### Props Handling + +```tsx +import { splitProps, mergeProps } from "solid-js"; + +// Default props +const merged = mergeProps({ size: "medium" }, props); + +// Split props (for spreading) +const [local, others] = splitProps(props, ["class", "onClick"]); +return + +)}> + + +``` + +### Suspense — Async Loading + +```tsx +import { Suspense } from "solid-js"; + +}> + + +``` + +## Context API + +```tsx +import { createContext, useContext } from "solid-js"; + +// Create context +const CounterContext = createContext<{ + count: () => number; + increment: () => void; +}>(); + +// Provider component +export function CounterProvider(props) { + const [count, setCount] = createSignal(0); + + return ( + setCount(c => c + 1) + }}> + {props.children} + + ); +} + +// Consumer hook +export function useCounter() { + const ctx = useContext(CounterContext); + if (!ctx) throw new Error("useCounter must be used within CounterProvider"); + return ctx; +} +``` + +## Lifecycle + +```tsx +import { onMount, onCleanup } from "solid-js"; + +function MyComponent() { + onMount(() => { + console.log("Mounted"); + const handler = () => {}; + window.addEventListener("resize", handler); + + onCleanup(() => { + window.removeEventListener("resize", handler); + }); + }); + + return
Content
; +} +``` + +## Refs + +```tsx +let inputRef: HTMLInputElement; + + + { /* el is the DOM element */ }} /> +``` + +## Event Handling + +```tsx +// Standard events (lowercase) + + + +// Delegated events (on:) + + +// Native events (on:) - not delegated +
+``` + +## Common Patterns + +### Conditional Classes + +```tsx +import { clsx } from "clsx"; // or classList + +
+
+``` + +### Batch Updates + +```tsx +import { batch } from "solid-js"; + +batch(() => { + setName("John"); + setAge(30); + // Effects run once after batch completes +}); +``` + +### Untrack + +```tsx +import { untrack } from "solid-js"; + +createEffect(() => { + console.log(count()); // tracked + console.log(untrack(() => other())); // not tracked +}); +``` + +## TypeScript + +```tsx +import type { Component, ParentComponent, JSX } from "solid-js"; + +// Basic component +const Button: Component<{ label: string }> = (props) => ( + +); + +// With children +const Layout: ParentComponent<{ title: string }> = (props) => ( +
+

{props.title}

+ {props.children} +
+); + +// Event handler types +const handleClick: JSX.EventHandler = (e) => { + console.log(e.currentTarget); +}; +``` + +## Project Setup + +```bash +# Create new project +npm create solid@latest my-app + +# With template +npx degit solidjs/templates/ts my-app + +# SolidStart +npm create solid@latest my-app -- --template solidstart +``` + +**vite.config.ts:** +```ts +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; + +export default defineConfig({ + plugins: [solid()] +}); +``` + +## Anti-Patterns to Avoid + +1. **Destructuring props** — Breaks reactivity + ```tsx + // ❌ Bad + const { name } = props; + + // ✅ Good + props.name + ``` + +2. **Accessing signals outside tracking scope** + ```tsx + // ❌ Won't update + console.log(count()); + + // ✅ Will update + createEffect(() => console.log(count())); + ``` + +3. **Forgetting to call signal getters** + ```tsx + // ❌ Passes the function +
{count}
+ + // ✅ Passes the value +
{count()}
+ ``` + +4. **Using array index as key** — Use `` for reference-keyed, `` for index-keyed + +5. **Side effects during render** — Use `createEffect` or `onMount` diff --git a/profiles/opencode/skill/solidjs/references/api_reference.md b/profiles/opencode/skill/solidjs/references/api_reference.md new file mode 100644 index 0000000..60b3420 --- /dev/null +++ b/profiles/opencode/skill/solidjs/references/api_reference.md @@ -0,0 +1,777 @@ +# SolidJS API Reference + +Complete reference for all SolidJS primitives, utilities, and component APIs. + +## Basic Reactivity + +### createSignal + +```tsx +import { createSignal } from "solid-js"; + +const [getter, setter] = createSignal(initialValue, options?); + +// Options +interface SignalOptions { + equals?: false | ((prev: T, next: T) => boolean); + name?: string; + internal?: boolean; +} +``` + +**Examples:** +```tsx +const [count, setCount] = createSignal(0); +const [user, setUser] = createSignal(null); + +// Always update +const [data, setData] = createSignal(obj, { equals: false }); + +// Custom equality +const [items, setItems] = createSignal([], { + equals: (a, b) => a.length === b.length +}); + +// Setter forms +setCount(5); // Direct value +setCount(prev => prev + 1); // Functional update +``` + +### createEffect + +```tsx +import { createEffect } from "solid-js"; + +createEffect(fn: (prev: T) => T, initialValue?: T, options?); + +// Options +interface EffectOptions { + name?: string; +} +``` + +**Examples:** +```tsx +// Basic +createEffect(() => { + console.log("Count:", count()); +}); + +// With previous value +createEffect((prev) => { + console.log("Changed from", prev, "to", count()); + return count(); +}, count()); + +// With cleanup +createEffect(() => { + const handler = () => {}; + window.addEventListener("resize", handler); + onCleanup(() => window.removeEventListener("resize", handler)); +}); +``` + +### createMemo + +```tsx +import { createMemo } from "solid-js"; + +const getter = createMemo(fn: (prev: T) => T, initialValue?: T, options?); + +// Options +interface MemoOptions { + equals?: false | ((prev: T, next: T) => boolean); + name?: string; +} +``` + +**Examples:** +```tsx +const doubled = createMemo(() => count() * 2); +const filtered = createMemo(() => items().filter(i => i.active)); + +// Previous value +const delta = createMemo((prev) => count() - prev, 0); +``` + +### createResource + +```tsx +import { createResource } from "solid-js"; + +const [resource, { mutate, refetch }] = createResource( + source?, // Optional reactive source + fetcher, // (source, info) => Promise + options? +); + +// Resource properties +resource() // T | undefined +resource.loading // boolean +resource.error // any +resource.state // "unresolved" | "pending" | "ready" | "refreshing" | "errored" +resource.latest // T | undefined (last successful value) + +// Options +interface ResourceOptions { + initialValue?: T; + name?: string; + deferStream?: boolean; + ssrLoadFrom?: "initial" | "server"; + storage?: (init: T) => [Accessor, Setter]; + onHydrated?: (key, info: { value: T }) => void; +} +``` + +**Examples:** +```tsx +// Without source +const [users] = createResource(fetchUsers); + +// With source +const [user] = createResource(userId, fetchUser); + +// With options +const [data] = createResource(id, fetchData, { + initialValue: [], + deferStream: true +}); + +// Actions +mutate(newValue); // Update locally +refetch(); // Re-fetch +refetch(customInfo); // Pass to fetcher's info.refetching +``` + +## Stores + +### createStore + +```tsx +import { createStore } from "solid-js/store"; + +const [store, setStore] = createStore(initialValue); +``` + +**Update patterns:** +```tsx +const [state, setState] = createStore({ + user: { name: "John", age: 30 }, + todos: [{ id: 1, text: "Learn Solid", done: false }] +}); + +// Path syntax +setState("user", "name", "Jane"); +setState("user", "age", a => a + 1); +setState("todos", 0, "done", true); + +// Array operations +setState("todos", t => [...t, newTodo]); +setState("todos", todos.length, newTodo); + +// Multiple paths +setState("todos", { from: 0, to: 2 }, "done", true); +setState("todos", [0, 2, 4], "done", true); +setState("todos", i => i.done, "done", false); + +// Object merge (shallow) +setState("user", { age: 31 }); // Keeps other properties +``` + +### produce + +```tsx +import { produce } from "solid-js/store"; + +setState(produce(draft => { + draft.user.age++; + draft.todos.push({ id: 2, text: "New", done: false }); + draft.todos[0].done = true; +})); +``` + +### reconcile + +```tsx +import { reconcile } from "solid-js/store"; + +// Replace with diff (minimal updates) +setState("todos", reconcile(newTodosFromAPI)); + +// Options +reconcile(data, { key: "id", merge: true }); +``` + +### unwrap + +```tsx +import { unwrap } from "solid-js/store"; + +const raw = unwrap(store); // Non-reactive plain object +``` + +### createMutable + +```tsx +import { createMutable } from "solid-js/store"; + +const state = createMutable({ + count: 0, + user: { name: "John" } +}); + +// Direct mutation (like MobX) +state.count++; +state.user.name = "Jane"; +``` + +### modifyMutable + +```tsx +import { modifyMutable, reconcile, produce } from "solid-js/store"; + +modifyMutable(state, reconcile(newData)); +modifyMutable(state, produce(s => { s.count++ })); +``` + +## Component APIs + +### children + +```tsx +import { children } from "solid-js"; + +const resolved = children(() => props.children); + +// Access +resolved(); // JSX.Element | JSX.Element[] +resolved.toArray(); // Always array +``` + +### createContext / useContext + +```tsx +import { createContext, useContext } from "solid-js"; + +const MyContext = createContext(defaultValue?); + +// Provider + + {children} + + +// Consumer +const value = useContext(MyContext); +``` + +### createUniqueId + +```tsx +import { createUniqueId } from "solid-js"; + +const id = createUniqueId(); // "0", "1", etc. +``` + +### lazy + +```tsx +import { lazy } from "solid-js"; + +const LazyComponent = lazy(() => import("./Component")); + +// Use with Suspense +}> + + +``` + +## Lifecycle + +### onMount + +```tsx +import { onMount } from "solid-js"; + +onMount(() => { + // Runs once after initial render + console.log("Mounted"); +}); +``` + +### onCleanup + +```tsx +import { onCleanup } from "solid-js"; + +// In component +onCleanup(() => { + console.log("Cleaning up"); +}); + +// In effect +createEffect(() => { + const sub = subscribe(); + onCleanup(() => sub.unsubscribe()); +}); +``` + +## Reactive Utilities + +### batch + +```tsx +import { batch } from "solid-js"; + +batch(() => { + setA(1); + setB(2); + setC(3); + // Effects run once after batch +}); +``` + +### untrack + +```tsx +import { untrack } from "solid-js"; + +createEffect(() => { + console.log(a()); // Tracked + console.log(untrack(() => b())); // Not tracked +}); +``` + +### on + +```tsx +import { on } from "solid-js"; + +// Explicit dependencies +createEffect(on(count, (value, prev) => { + console.log("Count changed:", prev, "->", value); +})); + +// Multiple dependencies +createEffect(on([a, b], ([a, b], [prevA, prevB]) => { + console.log("Changed"); +})); + +// Defer first run +createEffect(on(count, (v) => console.log(v), { defer: true })); +``` + +### mergeProps + +```tsx +import { mergeProps } from "solid-js"; + +const merged = mergeProps( + { size: "medium", color: "blue" }, // Defaults + props // Overrides +); +``` + +### splitProps + +```tsx +import { splitProps } from "solid-js"; + +const [local, others] = splitProps(props, ["class", "onClick"]); +// local.class, local.onClick +// others contains everything else + +const [a, b, rest] = splitProps(props, ["foo"], ["bar"]); +``` + +### createRoot + +```tsx +import { createRoot } from "solid-js"; + +const dispose = createRoot((dispose) => { + const [count, setCount] = createSignal(0); + // Use signals... + return dispose; +}); + +// Later +dispose(); +``` + +### getOwner / runWithOwner + +```tsx +import { getOwner, runWithOwner } from "solid-js"; + +const owner = getOwner(); + +// Later, in async code +runWithOwner(owner, () => { + createEffect(() => { + // This effect has proper ownership + }); +}); +``` + +### mapArray + +```tsx +import { mapArray } from "solid-js"; + +const mapped = mapArray( + () => items(), + (item, index) => ({ ...item, doubled: item.value * 2 }) +); +``` + +### indexArray + +```tsx +import { indexArray } from "solid-js"; + +const mapped = indexArray( + () => items(), + (item, index) =>
{index}: {item().name}
+); +``` + +### observable + +```tsx +import { observable } from "solid-js"; + +const obs = observable(signal); +obs.subscribe((value) => console.log(value)); +``` + +### from + +```tsx +import { from } from "solid-js"; + +// Convert observable/subscribable to signal +const signal = from(rxObservable); +const signal = from((set) => { + const unsub = subscribe(set); + return unsub; +}); +``` + +### catchError + +```tsx +import { catchError } from "solid-js"; + +catchError( + () => riskyOperation(), + (err) => console.error("Error:", err) +); +``` + +## Secondary Primitives + +### createComputed + +```tsx +import { createComputed } from "solid-js"; + +// Like createEffect but runs during render phase +createComputed(() => { + setDerived(source() * 2); +}); +``` + +### createRenderEffect + +```tsx +import { createRenderEffect } from "solid-js"; + +// Runs before paint (for DOM measurements) +createRenderEffect(() => { + const height = element.offsetHeight; +}); +``` + +### createDeferred + +```tsx +import { createDeferred } from "solid-js"; + +// Returns value after idle time +const deferred = createDeferred(() => expensiveComputation(), { + timeoutMs: 1000 +}); +``` + +### createReaction + +```tsx +import { createReaction } from "solid-js"; + +const track = createReaction(() => { + console.log("Something changed"); +}); + +track(() => count()); // Start tracking +``` + +### createSelector + +```tsx +import { createSelector } from "solid-js"; + +const isSelected = createSelector(selectedId); + + + {(item) => ( +
+ {item.name} +
+ )} +
+``` + +## Components + +### Show + +```tsx +}> + + + +// With callback (narrowed type) + + {(user) =>
{user().name}
} +
+``` + +### For + +```tsx +}> + {(item, index) =>
{index()}: {item.name}
} +
+``` + +### Index + +```tsx +}> + {(item, index) => } + +``` + +### Switch / Match + +```tsx +}> + + + + + + + +``` + +### Dynamic + +```tsx +import { Dynamic } from "solid-js/web"; + + +Content +``` + +### Portal + +```tsx +import { Portal } from "solid-js/web"; + + + + +``` + +### ErrorBoundary + +```tsx + ( +
+

Error: {err.message}

+ +
+)}> + +
+``` + +### Suspense + +```tsx +}> + + +``` + +### SuspenseList + +```tsx + + }> + }> + }> + +``` + +## Rendering + +### render + +```tsx +import { render } from "solid-js/web"; + +const dispose = render(() => , document.getElementById("root")!); + +// Cleanup +dispose(); +``` + +### hydrate + +```tsx +import { hydrate } from "solid-js/web"; + +hydrate(() => , document.getElementById("root")!); +``` + +### renderToString + +```tsx +import { renderToString } from "solid-js/web"; + +const html = renderToString(() => ); +``` + +### renderToStringAsync + +```tsx +import { renderToStringAsync } from "solid-js/web"; + +const html = await renderToStringAsync(() => ); +``` + +### renderToStream + +```tsx +import { renderToStream } from "solid-js/web"; + +const stream = renderToStream(() => ); +stream.pipe(res); +``` + +### isServer + +```tsx +import { isServer } from "solid-js/web"; + +if (isServer) { + // Server-only code +} +``` + +## JSX Attributes + +### ref + +```tsx +let el: HTMLDivElement; +
+
console.log(e)} /> +``` + +### classList + +```tsx +
+``` + +### style + +```tsx +
+
+``` + +### on:event (native) + +```tsx +
+
+``` + +### use:directive + +```tsx +function clickOutside(el: HTMLElement, accessor: () => () => void) { + const handler = (e: MouseEvent) => { + if (!el.contains(e.target as Node)) accessor()(); + }; + document.addEventListener("click", handler); + onCleanup(() => document.removeEventListener("click", handler)); +} + +
setOpen(false)} /> +``` + +### prop:property + +```tsx + // Set as property, not attribute +``` + +### attr:attribute + +```tsx +
// Force attribute +``` + +### bool:attribute + +```tsx + +``` + +### @once + +```tsx +
// Never updates +``` + +## Types + +```tsx +import type { + Component, + ParentComponent, + FlowComponent, + VoidComponent, + JSX, + Accessor, + Setter, + Signal, + Resource, + Owner +} from "solid-js"; + +// Component types +const MyComponent: Component = (props) =>
; +const Parent: ParentComponent = (props) =>
{props.children}
; +const Flow: FlowComponent = (props) => props.children(item); +const Void: VoidComponent = (props) => ; + +// Event types +type Handler = JSX.EventHandler; +type ChangeHandler = JSX.ChangeEventHandler; +``` diff --git a/profiles/opencode/skill/solidjs/references/patterns.md b/profiles/opencode/skill/solidjs/references/patterns.md new file mode 100644 index 0000000..07c54e9 --- /dev/null +++ b/profiles/opencode/skill/solidjs/references/patterns.md @@ -0,0 +1,720 @@ +# SolidJS Patterns & Best Practices + +Common patterns, recipes, and best practices for SolidJS development. + +## Component Patterns + +### Controlled vs Uncontrolled Inputs + +**Controlled:** +```tsx +function ControlledInput() { + const [value, setValue] = createSignal(""); + + return ( + setValue(e.currentTarget.value)} + /> + ); +} +``` + +**Uncontrolled with ref:** +```tsx +function UncontrolledInput() { + let inputRef: HTMLInputElement; + + const handleSubmit = () => { + console.log(inputRef.value); + }; + + return ( + <> + + + + ); +} +``` + +### Compound Components + +```tsx +const Tabs = { + Root: (props: ParentProps<{ defaultTab?: string }>) => { + const [activeTab, setActiveTab] = createSignal(props.defaultTab ?? ""); + + return ( + +
{props.children}
+
+ ); + }, + + List: (props: ParentProps) => ( +
{props.children}
+ ), + + Tab: (props: ParentProps<{ value: string }>) => { + const ctx = useTabsContext(); + return ( + + ); + }, + + Panel: (props: ParentProps<{ value: string }>) => { + const ctx = useTabsContext(); + return ( + +
{props.children}
+
+ ); + } +}; + +// Usage + + + First + Second + + First Content + Second Content + +``` + +### Render Props + +```tsx +function MouseTracker(props: { + children: (pos: { x: number; y: number }) => JSX.Element; +}) { + const [pos, setPos] = createSignal({ x: 0, y: 0 }); + + onMount(() => { + const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY }); + window.addEventListener("mousemove", handler); + onCleanup(() => window.removeEventListener("mousemove", handler)); + }); + + return <>{props.children(pos())}; +} + +// Usage + + {(pos) =>
Mouse: {pos.x}, {pos.y}
} +
+``` + +### Higher-Order Components + +```tsx +function withAuth

(Component: Component

) { + return (props: P) => { + const { user } = useAuth(); + + return ( + }> + + + ); + }; +} + +const ProtectedDashboard = withAuth(Dashboard); +``` + +### Polymorphic Components + +```tsx +type PolymorphicProps = { + as?: E; +} & JSX.IntrinsicElements[E]; + +function Box( + props: PolymorphicProps +) { + const [local, others] = splitProps(props as PolymorphicProps<"div">, ["as"]); + + return ; +} + +// Usage +Default div +Section element +Button +``` + +## State Patterns + +### Derived State with Multiple Sources + +```tsx +function SearchResults() { + const [query, setQuery] = createSignal(""); + const [filters, setFilters] = createSignal({ category: "all" }); + + const results = createMemo(() => { + const q = query().toLowerCase(); + const f = filters(); + + return allItems() + .filter(item => item.name.toLowerCase().includes(q)) + .filter(item => f.category === "all" || item.category === f.category); + }); + + return {item => }; +} +``` + +### State Machine Pattern + +```tsx +type State = "idle" | "loading" | "success" | "error"; +type Event = { type: "FETCH" } | { type: "SUCCESS"; data: any } | { type: "ERROR"; error: Error }; + +function createMachine(initial: State) { + const [state, setState] = createSignal(initial); + const [data, setData] = createSignal(null); + const [error, setError] = createSignal(null); + + const send = (event: Event) => { + const current = state(); + + switch (current) { + case "idle": + if (event.type === "FETCH") setState("loading"); + break; + case "loading": + if (event.type === "SUCCESS") { + setData(event.data); + setState("success"); + } else if (event.type === "ERROR") { + setError(event.error); + setState("error"); + } + break; + } + }; + + return { state, data, error, send }; +} +``` + +### Optimistic Updates + +```tsx +const [todos, setTodos] = createStore([]); + +async function deleteTodo(id: string) { + const original = [...unwrap(todos)]; + + // Optimistic remove + setTodos(todos => todos.filter(t => t.id !== id)); + + try { + await api.deleteTodo(id); + } catch { + // Rollback on error + setTodos(reconcile(original)); + } +} +``` + +### Undo/Redo + +```tsx +function createHistory(initial: T) { + const [past, setPast] = createSignal([]); + const [present, setPresent] = createSignal(initial); + const [future, setFuture] = createSignal([]); + + const canUndo = () => past().length > 0; + const canRedo = () => future().length > 0; + + const set = (value: T | ((prev: T) => T)) => { + const newValue = typeof value === "function" + ? (value as (prev: T) => T)(present()) + : value; + + setPast(p => [...p, present()]); + setPresent(newValue); + setFuture([]); + }; + + const undo = () => { + if (!canUndo()) return; + + const previous = past()[past().length - 1]; + setPast(p => p.slice(0, -1)); + setFuture(f => [present(), ...f]); + setPresent(previous); + }; + + const redo = () => { + if (!canRedo()) return; + + const next = future()[0]; + setPast(p => [...p, present()]); + setFuture(f => f.slice(1)); + setPresent(next); + }; + + return { value: present, set, undo, redo, canUndo, canRedo }; +} +``` + +## Custom Hooks/Primitives + +### useLocalStorage + +```tsx +function createLocalStorage(key: string, initialValue: T) { + const stored = localStorage.getItem(key); + const initial = stored ? JSON.parse(stored) : initialValue; + + const [value, setValue] = createSignal(initial); + + createEffect(() => { + localStorage.setItem(key, JSON.stringify(value())); + }); + + return [value, setValue] as const; +} +``` + +### useDebounce + +```tsx +function createDebounce(source: () => T, delay: number) { + const [debounced, setDebounced] = createSignal(source()); + + createEffect(() => { + const value = source(); + const timer = setTimeout(() => setDebounced(() => value), delay); + onCleanup(() => clearTimeout(timer)); + }); + + return debounced; +} + +// Usage +const debouncedQuery = createDebounce(query, 300); +``` + +### useThrottle + +```tsx +function createThrottle(source: () => T, delay: number) { + const [throttled, setThrottled] = createSignal(source()); + let lastRun = 0; + + createEffect(() => { + const value = source(); + const now = Date.now(); + + if (now - lastRun >= delay) { + lastRun = now; + setThrottled(() => value); + } else { + const timer = setTimeout(() => { + lastRun = Date.now(); + setThrottled(() => value); + }, delay - (now - lastRun)); + onCleanup(() => clearTimeout(timer)); + } + }); + + return throttled; +} +``` + +### useMediaQuery + +```tsx +function createMediaQuery(query: string) { + const mql = window.matchMedia(query); + const [matches, setMatches] = createSignal(mql.matches); + + onMount(() => { + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mql.addEventListener("change", handler); + onCleanup(() => mql.removeEventListener("change", handler)); + }); + + return matches; +} + +// Usage +const isMobile = createMediaQuery("(max-width: 768px)"); +``` + +### useClickOutside + +```tsx +function createClickOutside( + ref: () => HTMLElement | undefined, + callback: () => void +) { + onMount(() => { + const handler = (e: MouseEvent) => { + const el = ref(); + if (el && !el.contains(e.target as Node)) { + callback(); + } + }; + document.addEventListener("click", handler); + onCleanup(() => document.removeEventListener("click", handler)); + }); +} + +// Usage +let dropdownRef: HTMLDivElement; +createClickOutside(() => dropdownRef, () => setOpen(false)); +``` + +### useIntersectionObserver + +```tsx +function createIntersectionObserver( + ref: () => HTMLElement | undefined, + options?: IntersectionObserverInit +) { + const [isIntersecting, setIsIntersecting] = createSignal(false); + + onMount(() => { + const el = ref(); + if (!el) return; + + const observer = new IntersectionObserver(([entry]) => { + setIsIntersecting(entry.isIntersecting); + }, options); + + observer.observe(el); + onCleanup(() => observer.disconnect()); + }); + + return isIntersecting; +} +``` + +## Form Patterns + +### Form Validation + +```tsx +function createForm>(initial: T) { + const [values, setValues] = createStore(initial); + const [errors, setErrors] = createStore>>({}); + const [touched, setTouched] = createStore>>({}); + + const handleChange = (field: keyof T) => (e: Event) => { + const target = e.target as HTMLInputElement; + setValues(field as any, target.value as any); + }; + + const handleBlur = (field: keyof T) => () => { + setTouched(field as any, true); + }; + + const validate = (validators: Partial string | undefined>>) => { + let isValid = true; + + for (const [field, validator] of Object.entries(validators)) { + if (validator) { + const error = validator(values[field as keyof T]); + setErrors(field as any, error as any); + if (error) isValid = false; + } + } + + return isValid; + }; + + return { values, errors, touched, handleChange, handleBlur, validate, setValues }; +} + +// Usage +const form = createForm({ email: "", password: "" }); + + + + {form.errors.email} + +``` + +### Field Array + +```tsx +function createFieldArray(initial: T[] = []) { + const [fields, setFields] = createStore(initial); + + const append = (value: T) => setFields(f => [...f, value]); + const remove = (index: number) => setFields(f => f.filter((_, i) => i !== index)); + const update = (index: number, value: Partial) => setFields(index, v => ({ ...v, ...value })); + const move = (from: number, to: number) => { + setFields(produce(f => { + const [item] = f.splice(from, 1); + f.splice(to, 0, item); + })); + }; + + return { fields, append, remove, update, move }; +} +``` + +## Performance Patterns + +### Virtualized List + +```tsx +function VirtualList(props: { + items: T[]; + itemHeight: number; + height: number; + renderItem: (item: T, index: number) => JSX.Element; +}) { + const [scrollTop, setScrollTop] = createSignal(0); + + const startIndex = createMemo(() => + Math.floor(scrollTop() / props.itemHeight) + ); + + const visibleCount = createMemo(() => + Math.ceil(props.height / props.itemHeight) + 1 + ); + + const visibleItems = createMemo(() => + props.items.slice(startIndex(), startIndex() + visibleCount()) + ); + + return ( +

setScrollTop(e.currentTarget.scrollTop)} + > +
+ + {(item, i) => ( +
+ {props.renderItem(item, startIndex() + i())} +
+ )} +
+
+
+ ); +} +``` + +### Lazy Loading with Intersection Observer + +```tsx +function LazyLoad(props: ParentProps<{ placeholder?: JSX.Element }>) { + let ref: HTMLDivElement; + const [isVisible, setIsVisible] = createSignal(false); + + onMount(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "100px" } + ); + observer.observe(ref); + onCleanup(() => observer.disconnect()); + }); + + return ( +
+ + {props.children} + +
+ ); +} +``` + +### Memoized Component + +```tsx +// For expensive components that shouldn't re-render on parent updates +function MemoizedExpensiveList(props: { items: Item[] }) { + // Component only re-renders when items actually change + return ( + + {(item) => } + + ); +} +``` + +## Testing Patterns + +### Component Testing + +```tsx +import { render, fireEvent, screen } from "@solidjs/testing-library"; + +test("Counter increments", async () => { + render(() => ); + + const button = screen.getByRole("button", { name: /increment/i }); + expect(screen.getByText("Count: 0")).toBeInTheDocument(); + + fireEvent.click(button); + expect(screen.getByText("Count: 1")).toBeInTheDocument(); +}); +``` + +### Testing with Context + +```tsx +function renderWithContext(component: () => JSX.Element) { + return render(() => ( + + + {component()} + + + )); +} + +test("Dashboard shows user", () => { + renderWithContext(() => ); + // ... +}); +``` + +### Testing Async Components + +```tsx +import { render, waitFor, screen } from "@solidjs/testing-library"; + +test("Loads user data", async () => { + render(() => ); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); +}); +``` + +## Error Handling Patterns + +### Global Error Handler + +```tsx +function App() { + return ( + ( + + )} + > + }> + + {/* Routes */} + + + + ); +} +``` + +### Async Error Handling + +```tsx +function DataComponent() { + const [data] = createResource(fetchData); + + return ( + + + + + + refetch()} /> + + + {(data) => } + + + ); +} +``` + +## Accessibility Patterns + +### Focus Management + +```tsx +function Modal(props: ParentProps<{ isOpen: boolean; onClose: () => void }>) { + let dialogRef: HTMLDivElement; + let previousFocus: HTMLElement | null; + + createEffect(() => { + if (props.isOpen) { + previousFocus = document.activeElement as HTMLElement; + dialogRef.focus(); + } else if (previousFocus) { + previousFocus.focus(); + } + }); + + return ( + + +
e.key === "Escape" && props.onClose()} + > + {props.children} +
+
+
+ ); +} +``` + +### Live Regions + +```tsx +function Notifications() { + const [message, setMessage] = createSignal(""); + + return ( +
+ {message()} +
+ ); +} +```