Skip to content

Memos

createMemo compute returns a Memo<'T>, a derived value that recomputes when something it read has changed. A memo is lazy: it runs on its first read, and its Status is Uninitialized until then. compute receives the memo's previous value; the examples before this section ignore it with fun _ ->.

Peek returns the last computed value untracked, as stored, even when the memo is stale. Use untrack (fun () -> memo.Value) for a current value without recording a dependency; see Untrack.

Writes leave this unobserved memo stale. Peek prints its stored value; Read refreshes it. Watch the memo's run count as you step through the replay.

Glitch-free

Propagation is glitch-free. In a diamond, where two memos read one signal and an effect reads both memos, the effect runs once per write and always sees both memos at the same write.

Test your understanding
let a = createSignal 1
let plusOne = createMemo (fun _ -> a.Value + 1)
let timesTen = createMemo (fun _ -> a.Value * 10)
let seen = ResizeArray ()
createEffect (fun () -> seen.Add (plusOne.Value, timesTen.Value))
// How many values does seen have? What are the values?
a.Value <- 2
// How about now?
a.Value <- 3
// And now?
Answers
  1. [(2, 10)]
  2. [(2, 10); (3, 20)]
  3. [(2, 10); (3, 20); (4, 30)]

A memo read by several effects recomputes once per write.

Test your understanding
let a = createSignal 1

let shared = createMemo (fun _ -> a.Value * 100)
// 1. How many times has shared run?
createEffect (fun () -> shared.Value |> ignore)
// 2. What about now?
createEffect (fun () -> shared.Value |> ignore)
// 3. and now?
a.Value <- 2
// 4. and now?
a.Value <- 3
// 5. and now?
Answers
  1. 0
  2. 1
  3. 1
  4. 2
  5. 3

Pure and owning memos

Test your understanding

How many times does a string with "release" print?

let owningLog =
    use graph = new Graph ()
    use _ = graph.Activate ()
    let user = createSignal "ada"
    let log = ResizeArray ()

    let greeting =
        createMemoWith (fun _ ->
            let name = user.Value
            onCleanup (fun () -> log.Add $"release {name}")
            $"hello {name}")

    log.Add greeting.Value
    user.Value <- "grace"
    log.Add greeting.Value
    greeting.Dispose ()
    List.ofSeq log

owningLog |> List.iter (printfn "%s")
Answer

2

hello ada
release ada
hello grace
release grace

Construct Memo (graph, compute) directly for a pure memo, and Memo (graph, compute, true) for an owning one. createAsync and createAsyncWith split the same way, and a boundary always owns the nodes its body creates.

An owning computation replaces the nodes its body creates on every re-run. An async value created and read in the same body restarts its flight each time it settles, and never settles. Create it outside and read it inside; see Troubleshooting.

An owning memo's cleanups run untracked, whichever computation read the memo: a signal read by a cleanup never becomes a dependency of the reader. A node created by a cleanup belongs to the memo and is disposed before its next run.

The body runs once per discharge. A cleanup that writes one of the memo's sources and then reads the memo runs the body at that read, and that run is the re-run: the memo holds one run's nodes.

The previous value

compute receives the value the memo last published. Use it to accumulate a total, keep a running maximum, or reuse part of a previous result.

Its type is 'T voption -> 'T:

  • ValueNone on the first run.
  • ValueSome previous after a value has been published. This is the same value Peek returns.

A total advances once per run, using the inputs read in that run. Several writes in a batch, or before an unobserved memo is read, contribute only their final values.

Compare a single write with two writes in a batch. The total adds 10, then adds only the batch's final 2.

Test your understanding

What totals does the effect see? How many times does the memo run?

let totals, totalRuns =
    use graph = new Graph ()
    use _ = graph.Activate ()
    let amount = createSignal 5
    let total = createMemo (fun prev -> ValueOption.defaultValue 0 prev + amount.Value)
    let seen = ResizeArray ()
    createEffect (fun () -> seen.Add total.Value)

    amount.Value <- 10

    batch (fun () ->
        amount.Value <- 1
        amount.Value <- 2)

    List.ofSeq seen, total.Runs

printfn "totals seen: %A, runs: %d" totals totalRuns
Answer
totals seen: [5; 15; 17], runs: 3

The first run adds 5. The next adds 10. The batch contributes 2, so the total becomes 17; the intermediate 1 is never added.

Returning the previous value unchanged triggers an equality cutoff: readers do not re-run just because the memo ran.

Test your understanding

The memo below keeps the highest reading so far. Which values does the effect see? Does the memo run for readings that leave the maximum unchanged?

let highs, highestRuns =
    use graph = new Graph ()
    use _ = graph.Activate ()
    let reading = createSignal 20

    let highest =
        createMemo (fun prev ->
            match prev with
            | ValueSome best when best >= reading.Value -> best
            | _ -> reading.Value)

    let seen = ResizeArray ()
    createEffect (fun () -> seen.Add highest.Value)

    for r in [ 18; 25; 22; 19 ] do
        reading.Value <- r

    List.ofSeq seen, highest.Runs

printfn "highs seen: %A, memo runs: %d" highs highestRuns
Answer
highs seen: [20; 25], memo runs: 5

The memo runs initially and for all four writes. The effect sees only 20 and 25, because the other runs return the previous maximum.

Reusing part of a previous result

A new value that reuses parts of the previous one wakes the memo's readers. A memo that selects a reused part can cut off propagation there.

Pending sources, failures and owned nodes

A run that suspends on a pending source, or fails, publishes nothing. The next run receives the same previous value. Writes made while a source is pending are folded together once, by the run that completes.

With createMemoWith, the previous run's nodes are disposed before compute runs, including any nodes held in the previous value.

Cost of passing the previous value

Passing the previous value allocates nothing. In the counter bench it adds about 10 instructions to a memo run under .NET and about 40 under Node.js: 2 % and 4.5 % of a write through a chain of four memos.

Equality cutoff

A write equal to the current value stops there: it does not schedule its readers.

A memo that recomputes to an equal value stops downstream bodies from running. Unlike an equal signal write, the upstream change has already marked its readers for check. Those checks bring their dependencies current and resolve clean when no dependency publishes a change. This can happen at any level of a chain. Equality describes the graph-wide policy.

Test your understanding

The count starts at 2. We write 2, then 4. How many times do the effect and isEven run?

let cutoffRuns, parityRuns =
    use graph = new Graph ()
    use _ = graph.Activate ()
    let count = createSignal 2
    let isEven = createMemo (fun _ -> count.Value % 2 = 0)
    let label = createMemo (fun _ -> if isEven.Value then "even" else "odd")
    let mutable effectRuns = 0
    createEffect (fun () -> label.Value |> ignore; effectRuns <- effectRuns + 1)

    count.Value <- 2 // equal write
    count.Value <- 4 // isEven recomputes to true again
    effectRuns, isEven.Runs

printfn "effect runs: %d, isEven runs: %d" cutoffRuns parityRuns
Answer
effect runs: 1, isEven runs: 2

The equal write of 2 stops at count. Writing 4 runs isEven, but its result is still true, so label and the effect do not run again.

In the map, an equal write stops at count. A write that keeps the parity re-runs isEven, which recomputes to the same value and stops there. A write that flips the parity reaches the effect.

Dynamic dependencies

A memo or effect tracks its reads afresh on every run. If a branch stops reading a source, that source stops triggering the computation. Taking the branch again restores the dependency.

Reading the same source twice in one run records one dependency.

Test your understanding

The effect initially reads first. Which writes add an entry to the log after it switches to second? What happens when it switches back?

let branchLog =
    use graph = new Graph ()
    use _ = graph.Activate ()
    let useFirst = createSignal true
    let first = createSignal "a"
    let second = createSignal "x"
    let log = ResizeArray ()
    createEffect (fun () -> log.Add (if useFirst.Value then first.Value else second.Value))

    useFirst.Value <- false // now reads `second`
    first.Value <- "b" // not read by the last run: no re-run
    second.Value <- "y"
    useFirst.Value <- true // reads `first` again
    first.Value <- "c"
    List.ofSeq log

printfn "%A" branchLog
Answer
["a"; "x"; "y"; "b"; "c"]

Writing "b" to first adds nothing while the effect reads second. Switching back reads that stored "b" and restores the dependency on first.

Toggle the branch to move the effect's edge between first and second. A write to the source off the branch wakes nothing.

Read a disposed memo

A disposed memo keeps its last computed value. It stops recomputing and stops waking its readers, even if it was stale when disposed.

Test your understanding

The source changes from 1 to 2, but the memo is disposed before another read. What value does it keep? How many times has it run?

let disposedValue, disposedRuns =
    use graph = new Graph ()
    use _ = graph.Activate ()
    let source = createSignal 1
    let tenfold = createMemo (fun _ -> source.Value * 10)
    tenfold.Value |> ignore
    source.Value <- 2
    tenfold.Dispose ()
    tenfold.Value, tenfold.Runs

printfn "value after disposal: %d, runs: %d" disposedValue disposedRuns
Answer
value after disposal: 10, runs: 1

Disposal keeps the stored 10; it does not refresh the memo to 20.

Edit this page