|
| 1 | +# RFC: Reactivity, batching, and effects |
| 2 | + |
| 3 | +**Start here:** If you’re migrating an app, read the beta tester guide first: [MIGRATION.md](MIGRATION.md) |
| 4 | + |
| 5 | +## Summary |
| 6 | + |
| 7 | +Solid 2.0 tightens the reactivity model: no writes under owned scope (with narrow exceptions), stricter use of `untrack` for top-level reactive reads, and microtask batching via `flush` instead of `batch`. Effects are split into a tracking phase and an effect phase, enabling safer async and resumability. `createTrackedEffect` and `onSettled` (replacing `onMount`) complete the picture. These changes make the execution model predictable and allow Loading/Error boundaries to work correctly with async. |
| 8 | + |
| 9 | +## Motivation |
| 10 | + |
| 11 | +- **Writes under scope:** Writing to a signal inside a tracked context (e.g. inside an effect or component body) can cause subtle bugs and makes the graph harder to reason about. Split effects make it safe to disallow this by default. |
| 12 | +- **Strict top-level access (new):** Top-level reactive reads in component body can accidentally capture dependencies and re-run in async situations; in 2.0 we warn in dev unless the read is inside createMemo/createEffect or explicit `untrack`. |
| 13 | +- **Batching:** Synchronous batching with `batch()` is replaced by default microtask batching; the graph is immediately “askable” after a set, but DOM updates are deferred until the next microtask (or until `flush()`). This aligns with Vue/Svelte and simplifies the model; `batch` is no longer needed. |
| 14 | +- **Split effects:** Running all “tracking” (compute) halves of effects before any “effect” (callback) halves gives a clear dependency picture before side effects run, which is required for async, Loading, and Errored boundaries. |
| 15 | + |
| 16 | +## Detailed design |
| 17 | + |
| 18 | +### No writes under owned scope |
| 19 | + |
| 20 | +Writing to a signal inside a reactive scope (effect, memo, component body) warns in dev. Writes belong in event handlers, `onSettled`, or untracked blocks. When a signal must be written from within scope (e.g. internal state), opt in with `pureWrite: true`: |
| 21 | + |
| 22 | +```js |
| 23 | +// Default: warn if set in effect/component |
| 24 | +const [count, setCount] = createSignal(0); |
| 25 | + |
| 26 | +// Opt-in: allow writes in owned scope (e.g. internal flags) |
| 27 | +const [ref, setRef] = createSignal(null, { pureWrite: true }); |
| 28 | +``` |
| 29 | + |
| 30 | +`pureWrite` is not a general-purpose escape hatch. A common misuse is enabling it to silence warnings for application state while still writing from reactive scope: |
| 31 | + |
| 32 | +```js |
| 33 | +// ❌ BAD: using pureWrite to silence a write-under-scope warning for app state |
| 34 | +const [count, setCount] = createSignal(0, { pureWrite: true }); |
| 35 | +const [doubled, setDoubled] = createSignal(untrack(count) + 1); // force untracked read to get around other warning |
| 36 | +createMemo(() => setDoubled(count() + 1)); // feedback loop |
| 37 | + |
| 38 | +// ✅ GOOD: derive without writing back, or write in an event |
| 39 | +const doubled = createMemo(() => count() * 2); |
| 40 | +button.onclick = () => setCount((c) => c + 1); |
| 41 | +``` |
| 42 | + |
| 43 | +### Strict top-level access (new in 2.0) |
| 44 | + |
| 45 | +**What’s new:** In component body **top level**, reactive reads (signal, signal-backed prop, store property) **warn in dev** unless they are inside a reactive scope (e.g. `createMemo`, `createEffect`) or explicitly wrapped in `untrack`. This steers authors to avoid accidental dependencies that would re-run the component in async or lose reactivity. |
| 46 | + |
| 47 | +```js |
| 48 | +// New: top-level read in component body warns unless wrapped |
| 49 | +function Title(props) { |
| 50 | + const t = untrack(() => props.title); // intentional one-time read — no warn |
| 51 | + return <h1>{t}</h1>; |
| 52 | +} |
| 53 | +function Bad(props) { |
| 54 | + const t = props.title; // warns: Untracked reactive read |
| 55 | + return <h1>{t}</h1>; |
| 56 | +} |
| 57 | + |
| 58 | +// Common pitfall: destructuring reactive props at top level (warns) |
| 59 | +function BadArgs({ title }) { |
| 60 | + return <h1>{title}</h1>; |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +This also applies to the **bodies of control-flow function children** (e.g. Show/Match/For callbacks): those callbacks are structure-building, so reactive reads done directly in the callback body won’t update and will warn in dev. Prefer reading through JSX expressions (which compile to tracked computations), or wrap the read in a reactive scope. |
| 65 | + |
| 66 | +```js |
| 67 | +// ❌ BAD: reactive read in callback body (warns) |
| 68 | +<Show when={user()}> |
| 69 | + {(u) => { |
| 70 | + const name = u().name; |
| 71 | + return <span>{name}</span>; |
| 72 | + }} |
| 73 | +</Show> |
| 74 | + |
| 75 | +// ✅ GOOD: read in JSX expression (tracked) |
| 76 | +<Show when={user()}>{(u) => <span>{u().name}</span>}</Show> |
| 77 | +``` |
| 78 | + |
| 79 | +Tests: `packages/solid/test/component.spec.ts` ("Strict Read Warning") — warns on direct signal read, props backed by signal, store destructuring; no warn inside createMemo, createEffect, or untrack. |
| 80 | + |
| 81 | +Derived signals should not initiate other signals from reactive values except through the derived (initializer) form. |
| 82 | + |
| 83 | +### `flush` and microtask batching |
| 84 | + |
| 85 | +Updates are applied on the next microtask by default: the graph is readable immediately after a set, but DOM and effect callbacks run later. Use **`flush()`** when you need to read the DOM right after a state change (e.g. focus): |
| 86 | + |
| 87 | +```js |
| 88 | +function handleSubmit() { |
| 89 | + setSubmitted(true); |
| 90 | + flush(); // apply updates now |
| 91 | + inputRef.focus(); // DOM is up to date |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +**`batch` is removed;** `flush()` is the way to synchronously apply pending updates. |
| 96 | + |
| 97 | +### Split tracking from effect |
| 98 | + |
| 99 | +Effects have two phases: **compute** (reactive reads only; dependencies recorded) and **effect** (side effects; runs after all compute phases in the batch). This gives a clear dependency picture before any side effects run and is required for async and boundaries. |
| 100 | + |
| 101 | +```js |
| 102 | +createEffect( |
| 103 | + () => count(), // compute: only reads |
| 104 | + (value, prev) => { // effect: runs after flush |
| 105 | + console.log(value); |
| 106 | + return () => { /* cleanup */ }; |
| 107 | + } |
| 108 | +); |
| 109 | + |
| 110 | +// With initial value |
| 111 | +createEffect( |
| 112 | + () => [a(), b()], |
| 113 | + (deps) => { /* ... */ }, |
| 114 | + undefined |
| 115 | +); |
| 116 | +``` |
| 117 | + |
| 118 | +**`createRenderEffect`** is split the same way and tears when dependencies change. **`createEffect`** may accept an options object with `effect` and `error` for handling errors from the reactive graph (e.g. async). |
| 119 | + |
| 120 | +### createTrackedEffect and onSettled |
| 121 | + |
| 122 | +**`createTrackedEffect`** is the single-callback form for special cases; it may re-run in async situations and is not the default. **`onSettled`** replaces `onMount`: run logic when the current activity is settled (e.g. after mount, or from an event handler to defer work until the reactive graph is idle). |
| 123 | + |
| 124 | +```js |
| 125 | +onSettled(() => { |
| 126 | + const value = count(); // reactive read allowed here |
| 127 | + doSomething(value); |
| 128 | + return () => cleanup(); |
| 129 | +}); |
| 130 | +``` |
| 131 | +Unlike other tracked scopes these primitives cannot create nested primitives which is a breaking change from Solid 1.x. They also return a cleanup function instead of their previous value. |
| 132 | + |
| 133 | +**`onCleanup`** remains for reactive lifecycle cleanup inside computations. But is not expected to be used inside side effects. |
| 134 | + |
| 135 | +## Migration / replacement |
| 136 | + |
| 137 | +- **`batch`:** Remove; use `flush()` when you need synchronous application of updates (e.g. before reading DOM). |
| 138 | +- **`onMount`:** Replace with `onSettled`. |
| 139 | +- **Writes under scope:** Move setter calls to event handlers, `onSettled`, or untracked blocks; or create the signal with `{ pureWrite: true }` for the rare valid case (e.g. internal or intentionally in-scope writes). |
| 140 | + |
| 141 | +## Removals |
| 142 | + |
| 143 | +| Removed | Replacement / notes | |
| 144 | +|--------|----------------------| |
| 145 | +| `batch` | `flush()` when you need immediate application | |
| 146 | +| `onError` / `catchError` | Effect `error` callback or ErrorBoundary / Errored | |
| 147 | +| `on` helper | No longer necessary with split effects | |
| 148 | + |
| 149 | +`@solidjs/legacy` can provide approximations for deprecated APIs where feasible. |
| 150 | + |
| 151 | +## Alternatives considered |
| 152 | + |
| 153 | +- Keeping `batch` as an alias for “run updates now” was considered; unifying on `flush` reduces API surface and matches the mental model (drain queue). |
| 154 | +- Keeping a single-callback effect as the default was rejected in favor of split effects for async and boundary semantics. |
| 155 | + |
| 156 | +## Open questions |
| 157 | + |
| 158 | +- Whether to promote the write-under-scope warning to an error in a future release. |
0 commit comments