Skip to content

Projections

A projection gives each collection row its own reactive value. Key by identity to keep rows when items move, or by position to keep slots when their contents change.

The examples run inside an active graph.

Choosing a form

  • Use a keyed projection when items have stable identities, such as IDs.
  • Use an index projection when rows should stay at their positions as items change.
  • Use a factory form when each row needs its own nodes or cleanup.
  • Use createProjectionOnce for a factory result that stays fixed for the lifetime of its key, such as a view object that manages its own bindings.
  • Use a lookup for a value per requested key, or a selector for selection membership.
Creator signatures and row lifetimes
CreatorKeyed byRow valuePer-key nodes
createProjection keyOf map sourcekeyOf itemmap item, re-run when the key's item or a value map read changesnone
createProjectionWith keyOf factory sourcekeyOf itemthe reader returned by factory; factory runs once per key, untrackedcreated in factory, disposed when the key is removed
createIndexProjection map sourcepositionas createProjection; the row at a slot survives its item changingnone
createIndexProjectionWith factory sourcepositionas createProjectionWith; the factory runs once per slotcreated in factory, disposed when the slot is removed
createLookup f affected sourceany key read through Getf state key, recomputed for the keys affected namesnone
createSelector sourceany key read through Gettrue for the selected key, false otherwisenone

The index forms are Solid's indexArray. A projection is a key set plus one row per key, and each of those is observed separately. A lookup has no key set: it holds a cell for each key that has been read.

Reading a projection

The source returns a sequence, usually from a signal. Keys reads its ordered key set; Get key reads one row. This projection exposes each todo's title under its ID.

open Ranvier

type Todo = { Id: int; Title: string }

let todos =
    createSignal
        [
            { Id = 1; Title = "Write the guide" }
            { Id = 2; Title = "Review it" }
            { Id = 3; Title = "Publish" }
        ]

let titles = createProjection _.Id _.Title (fun () -> todos.Value)

titles.Keys
[|1; 2; 3|]
titles.Get 2
"Review it"

Snapshot returns the settled rows in key order, read untracked.

titles.Snapshot
|> Seq.map (fun row -> row.Key, row.Value)
|> List.ofSeq
[(1, "Write the guide"); (2, "Review it"); (3, "Publish")]
  • Keys is the key set in source order. A reader of Keys wakes only when membership or order changes.
  • Count is the number of keys, read through Keys.
  • Get key is a tracked read of that row alone. It raises KeyNotFoundException for an absent key (The projection has no key 99.).
  • TryGet key returns None for an absent key.
  • Snapshot is an untracked read of every row.

A source write recomputes rows whose items changed. Their readers run only when the row values change. Unchanged items keep their previous row results.

Equal records and row recomputation

The default policy compares records by reference. A fresh record with equal contents recomputes its row, but the row's readers run only if its result changes.

Test your understanding: does changing todo 2's title change Keys? Which row value changes?

todos.Value <-
    todos.Value
    |> List.map (fun t -> if t.Id = 2 then { t with Title = "Review it twice" } else t)

titles.Get 2
Answer
"Review it twice"

The key set stays the same. Only row 2's value changes.

Tests covering this behaviour

Pinned by editing one row wakes that row and no other, reordering the collection wakes Keys and no row (Projections.fs) and an unchanged survivor is skipped (MapSemantics.fs).

Rename one item, then reverse the collection. The row reader responds to its title change; the key reader responds to the new order.

Example code
let items = createSignal [ 1, "Write"; 2, "Review"; 3, "Publish" ]
let titles = createProjection fst snd (fun () -> items.Value)
createEffect (fun () -> printfn "row 2 = %s" (titles.Get 2))
createEffect (fun () -> printfn "keys = %A" titles.Keys)

controls [
    button "Rename row 2" (fun () -> items.Value <- items.Value |> List.map (fun (key, title) -> key, (if key = 2 then "Review twice" else title)))
    |> describe "The row value changes while the key order stays the same."
    |> expect "The row value changes while the key order stays the same." (fun () -> titles.Keys = [| 1; 2; 3 |])
    button "Reverse order" (fun () -> items.Value <- List.rev items.Value)
    |> describe "The existing keys move into reverse order."
    |> expect "The existing keys move into reverse order." (fun () -> titles.Keys = [| 3; 2; 1 |])
]

Identity

keyOf determines whether an edited item keeps its row:

  • Key by an ID to keep the existing row when that item changes.
  • Key by the whole item (id) to create a new row for an edited item. This follows Solid's unkeyed semantics.

keyOf must return equal keys for equal items, where items compare under the graph's equality policy: by default, records and classes compare by reference. A pass may skip keyOf for an item equal to the one at the same position in the previous pass, counted from the start or from the end.

The source is enumerated completely before the first keyOf call. Keeping unchanged item references lets a pass skip key work in unchanged regions. Under the default identity policy, rebuilding every record removes that opportunity: the pass visits all items. This does not make immutable collection updates proportional only to the number of edited rows; the input still has to be read.

A keyOf that reads a tracked source, creates a node or registers cleanup disables that skipping for every later pass. Keep identity selection a pure function of the item when possible.

The order in which a pass disposes the rows of removed keys is unspecified, and a duplicate-key failure reports one of the duplicated keys.

Key equality is separate from value equality

Keys compare structurally (HashIdentity.Structural) regardless of GraphOptions.Equality. The equality policy applies to items and row values only. A key built fresh on every pass, such as a tuple or an array, matches the previous pass's key when the contents are equal.

A pass that produces the same key twice fails. Every read of the projection raises the failure:

let duplicated =
    createProjection _.Id _.Title (fun () -> [ { Id = 1; Title = "a" }; { Id = 1; Title = "b" } ])

The read raises InvalidOperationException with the message The projection produced the key 1 twice in one pass. Keys must be unique; check the keyOf function. Projection.Error holds the same exception for a pass that the scheduler ran with no reader to raise to. Projection.ensureUniqueKeys keyOf items raises the same exception for the first repeated key, so a collection can be checked before it is published.

The factory form

createProjectionWith separates row setup from row computation:

  1. The factory runs once per key, untracked. It receives an accessor for the latest item.
  2. The factory returns a reader, which computes the row's value from tracked reads.
  3. The reader refreshes when stale and read, following changes to the item or its dependencies.

The factory runs inside a scope that lives until the key is removed. Nodes created in the factory body belong to that scope, and cleanups registered there run when the key is removed.

let removed = ResizeArray<int>()

let rows =
    createProjectionWith
        _.Id
        (fun item ->
            let id = (item ()).Id
            let shout = createMemo (fun _ -> (item ()).Title.ToUpper())
            onCleanup (fun () -> removed.Add id)
            fun () -> shout.Value)
        (fun () -> todos.Value)

rows.Get 1
"WRITE THE GUIDE"
todos.Value <- todos.Value |> List.filter (fun t -> t.Id <> 3)
rows.Keys, List.ofSeq removed
([|1; 2|], [3])
When removal cleanups run

An unobserved projection runs its pass at the next read, so the removal and its cleanup follow the read of rows.Keys.

A cleanup registered in the factory may write the projection's own source; the removal completes and the graph settles (a removed item's cleanup may write the source without hanging).

let misplaced =
    createProjection _.Id (fun t -> (createMemo (fun _ -> t.Title)).Value) (fun () -> todos.Value)

misplaced.Get 1 raises the error above. Moving the memo into createProjectionWith's factory, as rows does, creates it once per key.

Nodes owned by a memo read from a row

A node created inside a memo that a projection row pulls belongs to that memo. A createMemoWith memo keeps it until the memo's next run or disposal, whichever key or projection first read it. A createMemo body that creates a node fails with InvalidOperationException naming createMemoWith.

Create additions before retiring removals

createProjectionWithOptions ProjectionOptions.CreateFirst keyOf factory source changes the order of a keyed factory pass. It creates added rows first, retires removed rows second, then writes surviving items and publishes the keys. Without this option, a pass retires removals before creating additions.

If a create-first factory throws, the pass disposes its new rows in reverse creation order and keeps the previously applied rows, items and keys. Reads raise the held failure until a source change retries the pass. Without CreateFirst, reads of a failed projection retry the pass. This rollback covers the projection's rows; arrange cleanup for external resources in the factory.

Store a factory result once per key

createProjectionOnce options keyOf factory source stores the factory's result directly as the row value, without a row memo. The factory receives the same latest-item accessor as createProjectionWith and runs once, untracked, in the key's scope. Use ProjectionOptions.None for the default order or ProjectionOptions.CreateFirst for the order and rollback above.

The returned object stays the row value while the key survives. To reflect item changes in a view object, create memos and bindings in its factory using the latest-item accessor. A tracked Get observes the key set, so it wakes when that key leaves; it does not track changes inside the stored object.

Without CreateFirst, a factory failure is stored on the row and its reads raise that failure. With CreateFirst, a factory failure fails and rolls back the pass.

These contracts are covered by the create-first and memo-free tests in Projections.fs. The pass's reuse and fallback cases are covered in TrimmedPass.fs.

Index projections

An index projection keys rows by position. The row at a slot survives its item changing, and shortening the source drops the trailing slots.

let letters = createSignal [ "a"; "b"; "c" ]

let slots =
    createIndexProjection (fun (s: string) -> s.ToUpper ()) (fun () -> letters.Value)
letters.Value <- [ "z" ]
slots.Keys, slots.Get 0
([|0|], "Z")

Pending and failed rows

Keys can be available while individual rows are still pending. Handle loading at the row or collection level with a boundary.

A row whose value is pending raises NotReadyException from Get and TryGet, which a suspense boundary catches. The key set resolves independently of the rows' values, so Keys is available while rows are still in flight. A failed row re-raises its reader's exception from Get.

Pending summaries, snapshots and read costs
  • AnyPending wakes its reader only when the answer changes. A read is O(1) while no row is pending, and costs a pass over the pending rows otherwise.
  • PendingKeys lists the pending rows in key order, and costs a pass over the keys on every read while any row is pending.
  • Both count only rows that have been read: an unread row is absent from the summary.
  • A read of either brings the pending rows current first, so a reader of the summary and of a row sees them agree, and runs once when a row settles. A row the reader's own run makes pending shows only in the run's later reads of the summary.
  • Snapshot and AsObservableCollection show a pending or failed row's last settled value, and leave out a row that has never settled.

The pending channel itself is described in Async and pending.

Laziness

A projection with no observed row, key set or pending summary is lazy: writes to its source run no pass, and the next read runs one pass. Once an effect or memo observes it, a write to the source schedules the pass eagerly.

A flush runs every scheduled pass before the effects the write woke, so an effect that reads a memo over the keys or a row, and the source, sees both from the same write. A pass that publishes new keys schedules the passes of the projections that read them, and those run before the effects as well. Inside a batch, or in a body after the write, a memo over the keys or a row holds the previous pass's value until the flush or a direct read of Keys or Get. That read runs the pass, and the memo is current from then on.

Binding to a UI list

AsObservableCollection () returns an ObservableCollection holding the row values in key order, kept current by an effect owned by the calling scope.

let view = titles.AsObservableCollection ()
todos.Value <- todos.Value @ [ { Id = 4; Title = "Celebrate" } ]
List.ofSeq view
["Write the guide"; "Review it twice"; "Celebrate"]
UI notifications, cost and lifetime

The collection reconciles membership and order with individual item events, so a bound list control keeps its unchanged items:

  • The first population raises one Reset, followed by one Add per value.
  • A departed row raises Remove, and a new row raises Add at its position in key order.
  • A reorder raises Move only for the rows outside the longest run that kept its order. Swapping two neighbours raises one Move.
  • A row whose value changed, under the graph's equality policy, raises one Replace.

After initial population, value-only updates consume settled-value reader deltas and replace only changed accepted values; they do not poll every row. Membership/order reconciliation scans the visible keys and uses an O(N log N) positional diff. Initial population, reset recovery and recovery after a notification failure rebuild with Reset followed by Add events. A row settling for the first time can also require membership reconciliation. Pending or failed rows keep their last accepted value; rows that have never settled are omitted.

The updates stop when the calling scope is disposed or re-runs, or when the projection is disposed.

Reading changes

NewKeyReader () returns a reader of the projection's membership and order, owned by the calling scope. Each Read () reports the keys added, removed or replaced since the reader's previous read, in time proportional to the changes.

NewValueReader () returns the same kind of reader, adding KeyChange.Changed for unequal settled row values. It works on source projections and on collection views, including the Rows of an editable keyed collection.

let reader = titles.NewKeyReader ()
reader.Read () |> ignore // the first read reports a reset

todos.Value <-
    (todos.Value |> List.filter (fun t -> t.Id <> 1))
    @ [ { Id = 5; Title = "Rest" } ]

let delta = reader.Read ()

[ for change in delta.Changes -> change.Key, change.Value ]
|> List.sortBy fst
[(1, Removed); (5, Added)]
Key deltas, resets and read costs
  • Changes holds one KeyChange per key: Added, Removed, or Replaced for a key removed and re-added between two reads. A key added and then removed between two reads cancels out. The order is unspecified; Keys gives the order.
  • Keys and PreviousKeys are the key arrays at this read and at the previous one. OrderChanged is false when both are the same array.
  • Positional lists the RemoveAt, InsertAt and Move edits that turn PreviousKeys into Keys, computed on first use in O(N log N).
  • IsReset is true on the first read, after the projection is disposed, and once more than max(64, N) changes are unread. Changes is then empty: rebuild from Keys.
  • Read is a tracked read of Keys, so an effect that reads the reader wakes on every change it reports. While the pass is pending or failed, Read raises what Keys raises and keeps the changes for the next read.

A key reader reports membership and order; read row values with Get for the keys in Changes. Each reader costs one map update per added or removed key, and a projection without readers pays one null check.

Settled row changes

After the first reset, renaming todo 2 reports its key even though membership and order stay the same.

let valueReader = titles.NewValueReader ()
valueReader.Read () |> ignore

todos.Value <-
    todos.Value
    |> List.map (fun t -> if t.Id = 2 then { t with Title = "Final review" } else t)

let valueDelta = valueReader.Read ()
[ for change in valueDelta.Changes -> change.Key, change.Value ]
[(2, Changed)]

Changes contains keys rather than value snapshots. Read the current row through Get or TryGet and handle its pending/error state there. Multiple writes between reads coalesce: these deltas are refresh hints, not a history of every intermediate value. Membership changes take precedence over Changed for the same key, so an added or replaced row needs one refresh.

Observation, equality and lifetime
  • Settled values compare under GraphOptions.Equality. Status changes alone do not report Changed. Pending or failed rows retain their last accepted settled value; their next unequal settled value, or their first settled value, reports a change.
  • Value readers share row observation and accepted values, while each keeps its own bounded cursor. The first value read evaluates all visible rows. Later value-only pulls evaluate suspect rows; membership processing can still scan keys.
  • Read tracks membership and the value observation, so it can drive an effect on row changes. If the projection's pass is pending or failed, Read raises and preserves its unread changes.
  • First reads, overflow and projection disposal report IsReset with empty Changes; rebuild from delta.Keys. Positional handles membership and order, not row-value refreshes.
  • Readers belong to the scope that created them. Dispose a reader to stop it early; disposing the last value reader releases shared observation. Reading a disposed reader raises ObjectDisposedException.

Changed key windows

KeysVersion identifies the current key array: every new key array has a new version, and a pass that publishes an equal key sequence keeps the version. ChangedSince version returns the positions of Keys that can differ from the keys at version, as ValueSome struct (start, previousEnd, end). The keys before start are unchanged, and the keys after end equal the keys after previousEnd at version, in order. A renderer that stores the version can patch that window of its list.

let steps = createSignal [ "plan"; "write"; "test"; "ship" ]
let stepKeys = createProjection id id (fun () -> steps.Value)
let seen = stepKeys.KeysVersion

steps.Value <- [ "plan"; "write"; "review"; "edit"; "ship" ]
stepKeys.ChangedSince seen
ValueSome struct (2, 2, 3)

Positions 2 to 2 of the previous keys became positions 2 to 3. At the current version the window is empty, with start equal to the key count. After a keyed pass that changed one window of positions, the window is available against the version before that pass. Any other version gives ValueNone: rebuild from Keys. Both members are tracked reads of Keys, and O(1) after it.

Performance

Instruction counts for projection and keyed-row updates, compared with other libraries and followed across commits, are on Benchmarks → Projections.

Edit this page