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 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.

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.
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.

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.

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 them 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.

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 raises the fewest item events that turn its old contents into the new ones, 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.

Each change costs O(N log N): the effect compares every row against a copy of the previous contents.

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.

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.

Edit this page