Skip to content

Commit 2556eb6

Browse files
committed
docs: add Solid 2.0 beta migration guide
Add a beta tester-focused migration guide and topic deep-dives under documentation/solid-2.0. Made-with: Cursor
1 parent 0c84077 commit 2556eb6

10 files changed

Lines changed: 1521 additions & 0 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# RFC: Signals, derived primitives, ownership, and context
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+
This RFC groups the “core runtime ergonomics” changes that are tightly coupled: **ownership defaults**, **context provider ergonomics**, and **derived (function-form) primitives**. The goal is to make reactive lifetime more predictable (fewer unowned graphs), reduce API surface (`Context.Provider`, `createComputed`), and make “derived state” patterns use consistent primitives with clearer semantics.
8+
9+
## Motivation
10+
11+
- **Ownership as the default:** In 1.x it’s easy to accidentally create unowned reactive graphs (especially in library code), which leads to leaks and confusing cleanup. In 2.0 we want ownership to be the default and detaching to be explicit.
12+
- **Context ergonomics:** `Context.Provider` is boilerplate and a special-case surface. Using the context value directly as the provider component reduces ceremony and aligns with how it’s used.
13+
- **Derived primitives:** Patterns that previously relied on `createComputed` (or ad-hoc “write-back” computations) should move to primitives that are composable with async and with split effects. “Function-form” `createSignal` / `createStore` offer a single consistent place for “derived but writable” shapes.
14+
15+
## Detailed design
16+
17+
### Ownership: `createRoot` is owned by the parent by default
18+
19+
In 2.0, a root created inside an existing owned scope is itself owned by that parent (and will be disposed when the parent is disposed). You still get a `dispose` callback for manual cleanup.
20+
21+
```js
22+
function Widget() {
23+
createRoot((dispose) => {
24+
const [count, setCount] = createSignal(0);
25+
const id = setInterval(() => setCount((c) => c + 1), 1000);
26+
onCleanup(() => clearInterval(id));
27+
});
28+
return null;
29+
}
30+
// When Widget is disposed, the nested root is disposed too.
31+
```
32+
33+
#### Detaching is explicit: `runWithOwner(null, ...)`
34+
35+
If you really want “no owner” (module singletons, external integrations), detach explicitly:
36+
37+
```js
38+
// A truly detached singleton
39+
export const singleton = runWithOwner(null, () => {
40+
const [value, setValue] = createSignal(0);
41+
return { value, setValue };
42+
});
43+
```
44+
45+
This makes “global lifetime” an explicit opt-in rather than the accidental default.
46+
47+
### Context: context value is the provider component
48+
49+
Context creation still looks the same, but usage becomes simpler: the context itself is a component that takes a `value` prop and provides it to descendants.
50+
51+
```js
52+
// 2.0
53+
const ThemeContext = createContext("light");
54+
55+
function App() {
56+
return (
57+
<ThemeContext value="dark">
58+
<Page />
59+
</ThemeContext>
60+
);
61+
}
62+
63+
function Page() {
64+
const theme = useContext(ThemeContext);
65+
return <div class={theme}>...</div>;
66+
}
67+
```
68+
69+
### Derived primitives: function-form `createSignal` and `createStore`
70+
71+
2.0 supports function overloads for `createSignal` and `createStore` to represent “derived state” using the same primitives users already know.
72+
73+
#### Function-form `createSignal` (“writable memo”)
74+
75+
`createSignal(fn, initialValue?, options?)` creates a signal whose value is computed by `fn(prev)` and can also be written through its setter. This replaces many `createComputed` “write-back” use cases with an explicit primitive.
76+
77+
```js
78+
// Example: derived signal
79+
const [value, setValue] = createSignal(() => props.something);
80+
// setValue(...) writes like a normal signal; the compute receives prev on recompute.ß
81+
```
82+
83+
#### Function-form `createStore` (derived/projection store)
84+
85+
`createStore(fn, initial?, options?)` creates a derived store driven by mutation in `fn(draft)` (and may also return a value / Promise / async iterable). It’s the store analogue for derived shapes and underpins patterns like “selector-like” updates without notifying everything.
86+
87+
```js
88+
// Example: derived store that only flips the active key
89+
const [selected, setSelected] = createStore((draft) => {
90+
const id = selectedId();
91+
draft[id] = true;
92+
if (draft._prev != null) delete draft[draft._prev];
93+
draft._prev = id;
94+
}, {});
95+
```
96+
97+
## Migration / replacement
98+
99+
### `Context.Provider` → context-as-provider
100+
101+
```jsx
102+
// 1.x
103+
<ThemeContext.Provider value="dark">
104+
<Page />
105+
</ThemeContext.Provider>
106+
107+
// 2.0
108+
<ThemeContext value="dark">
109+
<Page />
110+
</ThemeContext>
111+
```
112+
113+
### Unowned roots → explicit detachment
114+
115+
- If you relied on roots living “forever,” wrap them in `runWithOwner(null, ...)`.
116+
- Otherwise, prefer creating roots under an existing owner so disposal is automatic.
117+
118+
### `createComputed` removal
119+
120+
If you used `createComputed` to “write back”:
121+
122+
- Prefer split `createEffect(compute, effect)` (RFC 01) when the intent is “react to X and do side effects”.
123+
- Prefer function-form `createSignal` / `createStore` when the intent is “derived state with a setter”.
124+
- Prefer `createMemo` for readonly derived values.
125+
126+
## Removals
127+
128+
| Removed | Replacement |
129+
|--------|-------------|
130+
| `createComputed` | `createEffect` (split), function-form `createSignal`/`createStore`, or `createMemo` |
131+
| `Context.Provider` | Use the context directly as the provider component (`<Context value={...}>`) |
132+
133+
## Alternatives considered
134+
135+
- Keeping `createRoot` detached by default: rejected because it makes leaks and “forever lifetime” accidental.
136+
- Keeping `Context.Provider`: rejected as needless boilerplate and special casing.
137+
- Keeping `createComputed`: rejected because “write-back computations” are harder to reason about in an async/split-effects model.
138+
139+
## Open questions
140+
141+
- Should we provide an explicit “detached root” helper (sugar over `runWithOwner(null, ...)`) for readability?

0 commit comments

Comments
 (0)