Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-memo-utility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

Add `memo` utility — a `$derived` wrapper that lets you specify exactly which sources should trigger recomputation, the same way `watch` does for `$effect`.
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from "./is-idle/index.js";
export * from "./is-in-viewport/index.js";
export * from "./is-mounted/index.js";
export * from "./is-document-visible/index.js";
export * from "./memo/index.js";
export * from "./on-click-outside/index.js";
export * from "./persisted-state/index.js";
export * from "./pressed-keys/index.js";
Expand Down
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/memo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./memo.svelte.js";
36 changes: 36 additions & 0 deletions packages/runed/src/lib/utilities/memo/memo.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { untrack } from "svelte";
import type { Getter } from "$lib/internal/types.js";

/**
* The reactive value produced by `memo`. Read it via `.current`.
*/
export interface Memo<T> {
readonly current: T;
}

class MemoImpl<T> implements Memo<T> {
#value: T;

constructor(compute: () => T) {
this.#value = $derived.by(compute);
}

get current(): T {
return this.#value;
}
}

export function memo<R>(
sources: Getter<unknown> | Array<Getter<unknown>>,
compute: () => R
): Memo<R> {
return new MemoImpl(() => {
// Touch the sources so they register as dependencies.
if (Array.isArray(sources)) {
for (const source of sources) source();
} else {
sources();
}
return untrack(compute);
});
}
99 changes: 99 additions & 0 deletions packages/runed/src/lib/utilities/memo/memo.test.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect } from "vitest";
import { memo } from "./memo.svelte.js";
import { testWithEffect } from "$lib/test/util.svelte.js";
import { sleep } from "$lib/internal/utils/sleep.js";

describe("memo", () => {
testWithEffect("recomputes when a tracked source changes", async () => {
let count = $state(2);
const doubled = memo(
() => count,
() => count * 2
);

expect(doubled.current).toBe(4);

count = 5;
await sleep(0);
expect(doubled.current).toBe(10);
});

testWithEffect("does not recompute when an untracked source changes", async () => {
let tracked = $state(1);
let untracked = $state(100);
let runs = 0;

const value = memo(
() => tracked,
() => {
runs++;
// reading `untracked` here must NOT register as a dependency
return tracked + untracked;
}
);

expect(value.current).toBe(101);
expect(runs).toBe(1);

untracked = 200;
await sleep(0);
expect(value.current).toBe(101);
expect(runs).toBe(1);

tracked = 2;
await sleep(0);
expect(value.current).toBe(202);
expect(runs).toBe(2);
});

testWithEffect("supports an array of sources", async () => {
let a = $state(1);
let b = $state(2);

const sum = memo(
[() => a, () => b],
() => a + b
);

expect(sum.current).toBe(3);

a = 10;
await sleep(0);
expect(sum.current).toBe(12);

b = 20;
await sleep(0);
expect(sum.current).toBe(30);
});

testWithEffect("only the listed sources trigger recomputation", async () => {
let tracked = $state(1);
let alsoRead = $state(10);
let runs = 0;

const value = memo(
() => tracked,
() => {
runs++;
return tracked + alsoRead;
}
);

expect(value.current).toBe(11);
expect(runs).toBe(1);

// Changing a value that's read inside the compute body but not declared
// as a source should not cause a recomputation.
alsoRead = 20;
await sleep(0);
expect(value.current).toBe(11);
expect(runs).toBe(1);

// Changing the declared source should cause a recomputation, and it will
// pick up the latest value of the un-tracked read as well.
tracked = 2;
await sleep(0);
expect(value.current).toBe(22);
expect(runs).toBe(2);
});
});
55 changes: 55 additions & 0 deletions sites/docs/src/content/utilities/memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: memo
description: Compute a derived value from explicit dependencies
category: Reactivity
---

Runes provide a handy way of computing a value from reactive sources:
[`$derived`](https://svelte.dev/docs/svelte/$derived). It automatically detects which inner
values are read, and re-computes when they change.

`$derived` is great, but sometimes you want to manually specify which values should trigger
recomputation. Svelte provides an `untrack` function, allowing you to specify that a dependency
_shouldn't_ be tracked, but it doesn't provide a way to say that _only certain values_ should be
tracked.

`memo` does exactly that. It accepts a getter function, which returns the dependencies of the
computation, and a compute function that produces the value.

## Usage

### memo

Computes a value whenever one of the sources changes. The result is exposed through `current`.

<!-- prettier-ignore -->
```ts
import { memo } from "runed";

let count = $state(2);
const doubled = memo(() => count, () => count * 2);

doubled.current; // 4
```

You can also send in an array of sources.

<!-- prettier-ignore -->
```ts
let a = $state(1);
let b = $state(2);
const sum = memo([() => a, () => b], () => a + b);
```

Reads inside the compute function are not tracked, so you can freely access other reactive state
without it triggering a recomputation.

<!-- prettier-ignore -->
```ts
let count = $state(0);
let multiplier = $state(2);

// only `count` is tracked — changing `multiplier` will not recompute the value,
// but the next recomputation (triggered by `count`) will read its latest value.
const value = memo(() => count, () => count * multiplier);
```