Open-Source Wikis

/

Solid

/

Packages

/

Reactivity primitives

solidjs/solid

Reactivity primitives

Active contributors: Ryan Carniato, Damian Tarnawski

This page documents the reactive runtime — the core of solid-js. The runtime exposes signals, derivations (memo, effect, render-effect, computed, reaction, deferred, selector), resources, transitions, error boundaries, contexts, and a small set of interop helpers (observable, from, mapArray, indexArray, requestCallback).

Purpose

The reactive runtime is the dependency graph that backs every Solid app. A read of a signal inside a tracking scope registers the active Listener as an observer; a write notifies observers and queues them for re-execution. Memos cache derivations; effects perform side effects; resources wrap async work and integrate with <Suspense>.

Directory layout

packages/solid/src/reactive/
├── signal.ts        # 1,821 lines — the entire reactive graph algorithm
├── scheduler.ts     # MessageChannel-based time-slicer (port of React's scheduler)
├── observable.ts    # observable(...) / from(...) for RxJS-style interop
└── array.ts         # mapArray (keyed) and indexArray (positional) for <For> / <Index>

packages/solid/src/index.ts re-exports the public surface from these files.

Key abstractions

Symbol File Description
createSignal<T>(value?, options?) signal.ts Returns [Accessor<T>, Setter<T>]. Reading is a tracking dependency; writing notifies observers.
createMemo<T>(fn, value?, options?) signal.ts Cached derivation. Re-runs when sources change; only propagates when the result fails the equality check.
createEffect<T>(fn, value?, options?) signal.ts User-level side effect. Runs after the next microtask flush.
createRenderEffect<T>(fn, value?, options?) signal.ts Like createEffect but runs synchronously during the update pass. The compiler emits these for DOM bindings.
createComputed<T>(fn, value?, options?) signal.ts Like createRenderEffect but pure (no Suspense interaction). Used internally.
createReaction(onInvalidate, options?) signal.ts Returns a track(fn) that runs fn once and calls onInvalidate when any tracked source changes.
createDeferred<T>(source, options?) signal.ts Returns a delayed accessor that updates only when the runtime is idle (uses requestCallback).
createSelector<T, U>(source, equals?) signal.ts Returns a function (key) => boolean that subscribes only the keys that actually match. Used to keep <For> selection updates O(1).
createResource<T, S>(source?, fetcher, options?) signal.ts Async accessor with loading / error / latest / state properties. Integrates with <Suspense>.
createRoot<T>(fn, detachedOwner?) signal.ts Allocates a top-level Owner. Required at the entry of a render tree.
createContext<T>(default?) signal.ts Returns { id, defaultValue, Provider }.
useContext(ctx) signal.ts Reads the nearest provided value walking up Owner.context.
getOwner() / runWithOwner(o, fn) / getListener() signal.ts Manual access to the active Owner / Listener.
untrack(fn) signal.ts Run fn with Listener = null.
batch(fn) signal.ts Defer observer notifications until fn returns.
on(deps, fn, options?) signal.ts Helper for explicit dependency tracking inside an effect/memo.
onMount(fn) / onCleanup(fn) / onError(fn) signal.ts Lifecycle helpers. onMount is createEffect(() => untrack(fn)).
catchError(fn, onError) signal.ts Programmatic equivalent of <ErrorBoundary>.
startTransition(fn) / useTransition() signal.ts Concurrent rendering: stages updates against a shadow tValue and commits when ready.
enableScheduling(scheduler?) signal.ts Activates the time-slicing scheduler (scheduler.ts).
enableExternalSource(factory, untrack) signal.ts Adapter so 3rd-party reactive sources (RxJS, MobX, …) can interop with Solid.
equalFn(a, b) signal.ts Default Object.is-style equality check.
requestCallback(fn, options?) / cancelCallback(task) scheduler.ts MessageChannel-based deferred callbacks.
observable(accessor) / from(producer, init?) observable.ts Bridge to TC39 / RxJS observables.
mapArray(list, fn, options?) array.ts Keyed reconciliation; underlying helper for <For>.
indexArray(list, fn, options?) array.ts Positional reconciliation; underlying helper for <Index>.

How it works

The reactive graph

Every reactive primitive contributes a node to the graph:

  • A SignalState<T> has value, observers[], observerSlots[], optional comparator, and (in dev) a name.
  • A Computation<Init, Next> has the function to run (fn), state (STALE / PENDING / 0), sources[], sourceSlots[], value, updatedAt, and the standard Owner fields (owned, cleanups, owner, context).
  • A memo is both a SignalState (it has observers) and a Computation (it has sources). The Memo<Prev, Next> interface intersects both shapes.
graph TD
    Listener["Active Listener<br>(current Computation)"]
    Owner["Active Owner"]

    subgraph "Read path"
      ReadSignal["readSignal()<br>register Listener as observer"]
      ReadSignal -->|push to observers[]| Signal[SignalState]
      ReadSignal -->|push to sources[]| Computation
    end

    subgraph "Write path"
      WriteSignal["writeSignal()<br>compare via equals; if changed:"]
      WriteSignal -->|enqueue observers| Updates[Updates queue]
    end

    subgraph "Update pass"
      RunUpdates["runUpdates()<br>drains Updates"]
      RunEffects["runEffects()<br>drains Effects (user effects)"]
      Updates --> RunUpdates
      RunUpdates --> Effects
      Effects --> RunEffects
    end

    Listener -.-> ReadSignal
    Owner -.->|owns| Computation

runUpdates (in signal.ts) drains the Updates queue iteratively: it pops a Computation, runs it, which produces new writes that push more computations, and repeats until the queue is empty. User effects (createEffect) are deferred into a separate Effects array and run after the update pass to avoid stalling the synchronous reactive pipeline.

Owners and disposal

Owner forms a tree mirroring the lexical structure of the program. createRoot(fn) pushes a fresh root; createComponent (in packages/solid/src/render/component.ts) implicitly creates a child owner for each component's reactive scope. When an owner disposes:

  1. All cleanups[] registered via onCleanup run in LIFO order.
  2. All owned[] child computations are disposed recursively.
  3. The owner's slot is removed from its parent's owned[].

runWithOwner(o, fn) lets you escape the lexical owner — useful for libraries that need to register cleanups against a long-lived parent.

Signal updates and equality

writeSignal uses comparator || equalFn to decide whether to notify. equalFn is (a, b) => a === b. To force a notification on every write, pass { equals: false }. To use a custom comparator, pass { equals: (a, b) => deepEqual(a, b) }.

Memos

createMemo is the deduplication primitive. Internally it is a Computation whose value is read by other tracking scopes through readSignal. Because the computation has both observers[] (downstream subscribers) and sources[] (upstream signals), it stops a memo from re-emitting downstream when its result is unchanged.

Effects vs render effects vs computed

Primitive When it runs Used by
createComputed Synchronously when sources change (no defer) Internal infrastructure
createRenderEffect Synchronously during the update pass The compiler (DOM bindings, Portal, Dynamic)
createEffect After the update pass, in a deferred Effects queue User code

createEffect sets c.user = true and is collected by the active <Suspense> if any. createRenderEffect and createComputed do not interact with Suspense.

Resources

createResource is built on top of signals. The ResourceSource is either a value, a falsy sentinel, or a function returning one of those. The ResourceFetcher is (key, info) => T | Promise<T>. The result is a tuple [Resource<T>, { mutate, refetch }].

The Resource<T> shape is a discriminated union (Unresolved | Pending | Ready<T> | Refreshing<T> | Errored). Reading resource() while pending throws to a <Suspense> ancestor (via the SuspenseContext), so the boundary can render its fallback.

Resources also participate in SSR streaming — seroval serializes the resolved value into the streamed HTML and the client's sharedConfig.load(id) rehydrates without re-fetching. See solid-js/web server entry for the streaming side.

Transitions

startTransition(fn) and useTransition() open a TransitionState:

interface TransitionState {
  sources: Set<SignalState<any>>;
  effects: Computation<any>[];
  promises: Set<Promise<any>>;
  disposed: Set<Computation<any>>;
  queue: Set<Computation<any>>;
  scheduler?: (fn: () => void) => unknown;
  running: boolean;
  done?: Promise<void>;
  resolve?: () => void;
}

Writes inside the transition are recorded against signal.tValue rather than signal.value. Reads inside affected computations also use tValue. When all tracked promises resolve, the transition commits — tValue becomes value and the queued effects run. This lets you keep the current UI visible while async work completes.

Scheduler

scheduler.ts is a port of React's MessageChannel-based scheduler. enableScheduling() installs it; once enabled, the runtime yields to the host between long update passes via shouldYieldToHost(). The scheduler also detects pending input via navigator.scheduling.isInputPending when available. A recent fix added port1.unref() / port2.unref() so the scheduler does not keep a Node process alive after dispose — see the changeset behind the prevent createDeferred from keeping Node.js process alive commit.

Observable interop

observable(accessor) returns a TC39-Observable-shaped object with a .subscribe(observer) method. RxJS's from() will pick it up via Symbol.observable. from(producer, init?) accepts either a function (setter) => cleanup or any object with .subscribe(...), and turns it into a Solid signal.

mapArray and indexArray

These two are the workhorses behind <For> and <Index>. Both accept an array accessor and a mapping function, and return an accessor that produces a stable, reconciled array of mapped values.

  • mapArray is keyed by value. It diffs the new array against the previous one with a Map of indices, reuses the mapped output for items it can match, disposes computations for items that left, and creates new ones for items that arrived. The algorithm is a port of S-array.
  • indexArray is keyed by position. It updates the underlying signal at each index when the value changes, but never disposes/recreates child computations unless the array length shrinks. Faster but breaks identity when items reorder.

Source / observer registration

sequenceDiagram
    participant User as User code
    participant ReadSig as readSignal()
    participant Listener as active Listener
    participant Signal as SignalState

    User->>ReadSig: count() inside createEffect
    ReadSig->>Listener: read globals
    alt Listener present
        ReadSig->>Signal: push Listener into observers[]
        ReadSig->>Listener: push Signal into sources[]
    end
    ReadSig->>User: value

    User->>WriteSignal: setCount(2)
    WriteSignal->>Signal: compare with comparator
    alt changed
        WriteSignal->>Updates: push every observer
        Updates-->>RunUpdates: drained on next tick
    end

Key source files

File Purpose
packages/solid/src/reactive/signal.ts Signals, memos, effects, resources, transitions, contexts, error handling, dev hooks. The whole reactive graph.
packages/solid/src/reactive/scheduler.ts MessageChannel time-slicer; opt-in via enableScheduling().
packages/solid/src/reactive/observable.ts observable() / from() interop.
packages/solid/src/reactive/array.ts mapArray / indexArray reconcilers.
packages/solid/src/index.ts Re-exports the public surface.

Integration points

  • The reactive runtime is consumed directly by components and control flow (packages/solid/src/render/); see Components and control flow.
  • solid-js/web (browser) and the SSR entry build their renderer-specific behaviour on top of createRenderEffect, createMemo, createSignal, and getOwner — see solid-js/web and SSR entry.
  • solid-js/store (packages/solid/store/) builds proxy-based reactive trees from createSignal, getListener, and batch — see solid-js/store.
  • Server primitives in packages/solid/src/server/reactive.ts re-implement the same surface as no-op getters/setters for SSR.

Entry points for modification

  • Adding a new primitive (e.g. createObservable2): add the export to packages/solid/src/reactive/signal.ts (or a new file), re-export from the corresponding export {...} block in signal.ts and from packages/solid/src/index.ts. Add behaviour tests in packages/solid/test/signals.spec.ts and a type test in packages/solid/test/signals.type-tests.ts.
  • Tuning the update queue / scheduler integration: the runUpdates, runComputation, and runEffects functions in signal.ts are the hot path. Be ready to back any change with a benchmark from packages/solid/test/component.bench.ts or packages/solid/bench/.
  • Changing transition semantics: runTransition and the TransitionState machinery in signal.ts. Validate against packages/solid/test/external-source.spec.ts and packages/solid/web/test/transition.spec.tsx (the most-touched test in the last 90 days).

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Reactivity primitives – Solid wiki | Factory