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
| Creator | Keyed by | Row value | Per-key nodes |
|---|---|---|---|
createProjection keyOf map source | keyOf item | map item, re-run when the key's item or a value map read changes | none |
createProjectionWith keyOf factory source | keyOf item | the reader returned by factory; factory runs once per key, untracked | created in factory, disposed when the key is removed |
createIndexProjection map source | position | as createProjection; the row at a slot survives its item changing | none |
createIndexProjectionWith factory source | position | as createProjectionWith; the factory runs once per slot | created in factory, disposed when the slot is removed |
createLookup f affected source | any key read through Get | f state key, recomputed for the keys affected names | none |
createSelector source | any key read through Get | true for the selected key, false otherwise | none |
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")]
Keysis the key set in source order. A reader ofKeyswakes only when membership or order changes.Countis the number of keys, read throughKeys.Get keyis a tracked read of that row alone. It raisesKeyNotFoundExceptionfor an absent key (The projection has no key 99.).TryGet keyreturnsNonefor an absent key.Snapshotis 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:
- The factory runs once per key, untracked. It receives an accessor for the latest item.
- The factory returns a reader, which computes the row's value from tracked reads.
- 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
AnyPendingwakes 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.PendingKeyslists 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.
SnapshotandAsObservableCollectionshow 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 oneAddper value. - A departed row raises
Remove, and a new row raisesAddat its position in key order. - A reorder raises
Moveonly for the rows outside the longest run that kept its order. Swapping two neighbours raises oneMove. - 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
Changesholds oneKeyChangeper key:Added,Removed, orReplacedfor a key removed and re-added between two reads. A key added and then removed between two reads cancels out. The order is unspecified;Keysgives the order.KeysandPreviousKeysare the key arrays at this read and at the previous one.OrderChangedis false when both are the same array.Positionallists theRemoveAt,InsertAtandMoveedits that turnPreviousKeysintoKeys, computed on first use in O(N log N).IsResetis true on the first read, after the projection is disposed, and once more thanmax(64, N)changes are unread.Changesis then empty: rebuild fromKeys.Readis a tracked read ofKeys, so an effect that reads the reader wakes on every change it reports. While the pass is pending or failed,Readraises whatKeysraises 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.