diff --git a/app/docs/api-reference.recho.js b/app/docs/api-reference.recho.js index 7ab48f8..99fd076 100644 --- a/app/docs/api-reference.recho.js +++ b/app/docs/api-reference.recho.js @@ -20,6 +20,7 @@ * - echo(...values) - Echo values inline with your code as comments (https://recho.dev/notebook/docs/api-echo) * - echo.clear() - Clear the output of the current block (https://recho.dev/notebook/docs/api-echo-clear) * - invalidation() - Promise that resolves before re-running the current block (https://recho.dev/notebook/docs/api-invalidation) + * - recho.state(value) - Create reactive state variables for mutable values (https://recho.dev/notebook/docs/api-state) * - recho.inspect(value[, options]) - Format values for inspection (https://recho.dev/notebook/docs/api-inspect) * * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/app/docs/api-state.recho.js b/app/docs/api-state.recho.js new file mode 100644 index 0000000..818b31d --- /dev/null +++ b/app/docs/api-state.recho.js @@ -0,0 +1,72 @@ +/** + * @title recho.state(value) + */ + +/** + * ============================================================================ + * = recho.state(value) = + * ============================================================================ + * + * Creates a reactive state variable that can be mutated over time. This is + * similar to React's useState hook and enables mutable reactive values that + * automatically trigger re-evaluation of dependent blocks when changed. + * + * @param {any} value - The initial state value. + * @returns {[any, Function, Function]} A tuple containing: + * - state: The reactive state value that can be read directly + * - setState: Function to update the state (accepts value or updater function) + * - getState: Function to get the current state value + */ + +// Basic counter that increments after 1 second +const [count1, setCount1] = recho.state(0); + +setTimeout(() => { + setCount1(count1 => count1 + 1); +}, 1000); + +//➜ 1 +echo(count1); + +// Timer that counts down from 10 +const [timer, setTimer] = recho.state(10); + +{ + const interval = setInterval(() => { + setTimer(t => { + if (t <= 0) { + clearInterval(interval); + return 0; + } + return t - 1; + }); + }, 1000); + + invalidation.then(() => clearInterval(interval)); +} + +//➜ 8 +echo(`Time remaining: ${timer}s`); + +// State can be updated with a direct value +const [message, setMessage] = recho.state("Hello"); + +setTimeout(() => { + setMessage("Hello, World!"); +}, 2000); + +//➜ "Hello, World!" +echo(message); + +// Multiple states can be used together +const [firstName, setFirstName] = recho.state("John"); +const [lastName, setLastName] = recho.state("Doe"); + +setTimeout(() => { + setFirstName("Jane"); + setLastName("Smith"); +}, 1500); + +//➜ "Jane Smith" +echo(`${firstName} ${lastName}`); + diff --git a/app/docs/nav.config.js b/app/docs/nav.config.js index df37883..b8cc091 100644 --- a/app/docs/nav.config.js +++ b/app/docs/nav.config.js @@ -59,6 +59,10 @@ export const docsNavConfig = [ type: "page", slug: "api-invalidation", }, + { + type: "page", + slug: "api-state", + }, { type: "page", slug: "api-inspect", diff --git a/runtime/stdlib/index.js b/runtime/stdlib/index.js index 2bcdea0..c11d150 100644 --- a/runtime/stdlib/index.js +++ b/runtime/stdlib/index.js @@ -3,3 +3,4 @@ export {now} from "./now.js"; export {interval} from "./interval.js"; export {inspect, Inspector} from "./inspect.js"; export * from "../controls/index.js"; +export {state} from "./state.js"; diff --git a/runtime/stdlib/observe.js b/runtime/stdlib/observe.js new file mode 100644 index 0000000..57d37fc --- /dev/null +++ b/runtime/stdlib/observe.js @@ -0,0 +1,37 @@ +// Derived from Observable Notebook Kit's observe. +// https://github.com/observablehq/notebook-kit/blob/main/src/runtime/stdlib/generators/observe.ts + +export async function* observe(initialize) { + let resolve = undefined; + let value = undefined; + let stale = false; + + const dispose = initialize((x) => { + value = x; + if (resolve) { + resolve(x); + resolve = undefined; + } else { + stale = true; + } + return x; + }); + + if (dispose != null && typeof dispose !== "function") { + throw new Error( + typeof dispose === "object" && "then" in dispose && typeof dispose.then === "function" + ? "async initializers are not supported" + : "initializer returned something, but not a dispose function", + ); + } + + try { + while (true) { + yield stale ? ((stale = false), value) : new Promise((_) => (resolve = _)); + } + } finally { + if (dispose != null) { + dispose(); + } + } +} diff --git a/runtime/stdlib/state.js b/runtime/stdlib/state.js new file mode 100644 index 0000000..b9e0177 --- /dev/null +++ b/runtime/stdlib/state.js @@ -0,0 +1,26 @@ +// Derived from Observable Notebook Kit's mutable and mutator. +// https://github.com/observablehq/notebook-kit/blob/main/src/runtime/stdlib/mutable.ts +import {observe} from "./observe.js"; + +// Mutable returns a generator with a value getter/setting that allows the +// generated value to be mutated. Therefore, direct mutation is only allowed +// within the defining cell, but the cell can also export functions that allows +// other cells to mutate the value as desired. +function Mutable(value) { + let change = undefined; + const mutable = observe((_) => { + change = _; + if (value !== undefined) change(value); + }); + return Object.defineProperty(mutable, "value", { + get: () => value, + set: (x) => ((value = x), void change?.(value)), + }); +} + +export function state(value) { + const state = Mutable(value); + const setState = (x) => (typeof x === "function" ? (state.value = x(state.value)) : (state.value = x)); + const getState = () => state.value; + return [state, setState, getState]; +} diff --git a/test/js/index.js b/test/js/index.js index 314df13..2be6f2b 100644 --- a/test/js/index.js +++ b/test/js/index.js @@ -14,6 +14,7 @@ export {mandelbrotSet} from "./mandelbrot-set.js"; export {matrixRain} from "./matrix-rain.js"; export {jsDocString} from "./js-doc-string.js"; export {commentLink} from "./comment-link.js"; +export {mutable} from "./mutable.js"; export {syntaxError3} from "./syntax-error3.js"; export {syntaxError4} from "./syntax-error4.js"; export {nonCallEcho} from "./non-call-echo.js"; diff --git a/test/js/mutable.js b/test/js/mutable.js new file mode 100644 index 0000000..640cf0f --- /dev/null +++ b/test/js/mutable.js @@ -0,0 +1,8 @@ +export const mutable = `const [a, setA] = recho.state(0); + +setTimeout(() => { + setA((a) => a + 1); +}, 1000); + +echo(a); +`; diff --git a/test/stdlib.spec.js b/test/stdlib.spec.js index 48d5916..af1abff 100644 --- a/test/stdlib.spec.js +++ b/test/stdlib.spec.js @@ -8,4 +8,5 @@ it("should export expected functions from stdlib", () => { expect(stdlib.toggle).toBeDefined(); expect(stdlib.number).toBeDefined(); expect(stdlib.radio).toBeDefined(); + expect(stdlib.state).toBeDefined(); });