@rethunk/react-load-hooks 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rethunk.Tech, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">react-load-hooks</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <div align="center">
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+ [![npm](https://img.shields.io/npm/v/@rethunk/react-load-hooks.svg)](https://www.npmjs.com/package/@rethunk/react-load-hooks)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-typed-3178C6?logo=typescript&logoColor=white)](tsconfig.json)
8
+
9
+ </div>
10
+
11
+ ---
12
+
13
+ React hooks for async loads that never land out of order, plus the small state hooks an app builds screens from. Package `@rethunk/react-load-hooks`. First consumer: Mortar.
14
+
15
+ Only the newest load lands: a slower, older answer or error never overwrites a newer one, and an
16
+ unmounted component hears nothing.
17
+
18
+ ```tsx
19
+ const { data, loading, error, reload } = useLoaded(() => fetchRepos(owner), [owner], [])
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ bun add @rethunk/react-load-hooks
26
+ ```
27
+
28
+ A worked example and the contracts: [HUMANS.md](HUMANS.md).
29
+
30
+ ## Features
31
+
32
+ - `useLoaded` and `useDebouncedLoad` fetch on dependency changes and drop superseded answers.
33
+ - `loader` is the same logic without React, for a store or a class.
34
+ - `useStoredState` persists UI state in `localStorage`.
35
+ - `useWidth` tracks an element's width before paint; `useDebouncedValue` settles a fast-changing value.
36
+ - `useNowValue` and `useSecondValue` share one timer per period and re-render only on a changed answer.
37
+
38
+ ## Documentation
39
+
40
+ | Doc | Audience |
41
+ | --- | --- |
42
+ | [HUMANS.md](HUMANS.md) | Running and using the package |
43
+ | [AGENTS.md](AGENTS.md) | Internals, invariants, and why |
44
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Process — commits, tests, docs |
45
+ | [SECURITY.md](SECURITY.md) | Vulnerability disclosure |
46
+ | [CHANGELOG.md](CHANGELOG.md) | Release notes |
47
+
48
+ ## License
49
+
50
+ MIT — see [LICENSE](LICENSE). © 2026 Rethunk.Tech, LLC.
@@ -0,0 +1,8 @@
1
+ export { type LoadSink, loader } from './loader.ts';
2
+ export { useDebouncedLoad } from './useDebouncedLoad.ts';
3
+ export { useDebouncedValue } from './useDebouncedValue.ts';
4
+ export { useLoaded } from './useLoaded.ts';
5
+ export { useNowValue, useSecondValue } from './useNow.ts';
6
+ export { readStored, useStoredState, writeStored } from './useStoredState.ts';
7
+ export { useWidth } from './useWidth.ts';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAC7E,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { loader } from './loader.js';
2
+ export { useDebouncedLoad } from './useDebouncedLoad.js';
3
+ export { useDebouncedValue } from './useDebouncedValue.js';
4
+ export { useLoaded } from './useLoaded.js';
5
+ export { useNowValue, useSecondValue } from './useNow.js';
6
+ export { readStored, useStoredState, writeStored } from './useStoredState.js';
7
+ export { useWidth } from './useWidth.js';
@@ -0,0 +1,23 @@
1
+ import type { DependencyList } from 'react';
2
+ export interface LoadSink<T> {
3
+ value: (value: T) => void;
4
+ error: (error: unknown) => void;
5
+ loading: (on: boolean) => void;
6
+ /** Shows the hook's initial value again. */
7
+ reset: () => void;
8
+ }
9
+ /**
10
+ * useLoaded's and useDebouncedLoad's logic without React. Only the newest load lands: a later load, new deps or stop
11
+ * drops the answer, error and end of every one before it. New deps reset the value unless keepAcrossKeys, and a null
12
+ * load always does. `defer` starts a load, now or later, and returns what cancels the start.
13
+ */
14
+ export declare function loader<T>(sink: LoadSink<T>, { keepAcrossKeys, defer, }?: {
15
+ keepAcrossKeys?: boolean;
16
+ defer?: (go: () => void) => () => void;
17
+ }): {
18
+ deps(deps: DependencyList, load: (() => Promise<T>) | null): void;
19
+ /** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
20
+ run(load: (() => Promise<T>) | null): Promise<void>;
21
+ stop(): void;
22
+ };
23
+ //# sourceMappingURL=loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAA;AAK3C,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,KAAK,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAA;IACzB,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAC/B,OAAO,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,IAAI,CAAA;IAC9B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAOD;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,CAAC,EACtB,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,EACjB,EACE,cAAsB,EACtB,KAAW,GACZ,GAAE;IAAE,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;CAAO;IAuC1E,IAAI,OAAO,cAAc,QAAQ,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;IAoB1D,8GAA8G;IAC9G,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAOnD,IAAI;EAKP"}
package/dist/loader.js ADDED
@@ -0,0 +1,79 @@
1
+ const sameDeps = (a, b) => a !== null && a.length === b.length && b.every((dep, i) => Object.is(dep, a[i]));
2
+ const now = (go) => {
3
+ go();
4
+ return () => undefined;
5
+ };
6
+ /**
7
+ * useLoaded's and useDebouncedLoad's logic without React. Only the newest load lands: a later load, new deps or stop
8
+ * drops the answer, error and end of every one before it. New deps reset the value unless keepAcrossKeys, and a null
9
+ * load always does. `defer` starts a load, now or later, and returns what cancels the start.
10
+ */
11
+ export function loader(sink, { keepAcrossKeys = false, defer = now, } = {}) {
12
+ let generation = 0;
13
+ let seen = null;
14
+ let cancel = () => undefined;
15
+ const drop = () => {
16
+ generation += 1;
17
+ cancel();
18
+ };
19
+ const start = (load) => {
20
+ generation += 1;
21
+ const mine = generation;
22
+ const current = () => generation === mine;
23
+ sink.loading(true);
24
+ return load()
25
+ .then((value) => {
26
+ if (current()) {
27
+ sink.value(value);
28
+ }
29
+ }, (error) => {
30
+ if (current()) {
31
+ sink.error(error);
32
+ }
33
+ })
34
+ .finally(() => {
35
+ if (current()) {
36
+ sink.loading(false);
37
+ }
38
+ });
39
+ };
40
+ const skip = () => {
41
+ drop();
42
+ sink.reset();
43
+ sink.loading(false);
44
+ };
45
+ return {
46
+ deps(deps, load) {
47
+ if (sameDeps(seen, deps)) {
48
+ return;
49
+ }
50
+ const fresh = seen !== null;
51
+ seen = deps;
52
+ if (load === null) {
53
+ skip();
54
+ return;
55
+ }
56
+ drop();
57
+ if (fresh && !keepAcrossKeys) {
58
+ sink.reset();
59
+ }
60
+ sink.loading(true);
61
+ // start settles through the sink, so only a throwing sink handler reaches this catch.
62
+ cancel = defer(() => {
63
+ start(load).catch(sink.error);
64
+ });
65
+ },
66
+ /** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
67
+ run(load) {
68
+ if (load === null) {
69
+ skip();
70
+ return Promise.resolve();
71
+ }
72
+ return start(load);
73
+ },
74
+ stop() {
75
+ drop();
76
+ seen = null;
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,18 @@
1
+ import { type DependencyList } from 'react';
2
+ /**
3
+ * useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
4
+ * after `deps` last changed, and `data` keeps the last answer meanwhile. Only the newest run lands, so `onError` hears
5
+ * only the newest failure. A null `load` resets `data` to `initial`. `run` starts another load at once, such as the
6
+ * next page, under the same rule.
7
+ */
8
+ export declare function useDebouncedLoad<T>(load: (() => Promise<T>) | null, deps: DependencyList, { initial, delayMs, onError, }: {
9
+ initial: T;
10
+ delayMs: number;
11
+ onError?: (error: unknown) => void;
12
+ }): {
13
+ data: T;
14
+ loading: boolean;
15
+ error: unknown;
16
+ run: (next: () => Promise<T>) => Promise<void>;
17
+ };
18
+ //# sourceMappingURL=useDebouncedLoad.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDebouncedLoad.d.ts","sourceRoot":"","sources":["../src/useDebouncedLoad.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAA+B,MAAM,OAAO,CAAA;AAGxE;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,IAAI,EAAE,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,EAC/B,IAAI,EAAE,cAAc,EACpB,EACE,OAAO,EACP,OAAO,EACP,OAAO,GACR,EAAE;IAAE,OAAO,EAAE,CAAC,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CAAE;;;;gBAwCxD,MAAM,OAAO,CAAC,CAAC,CAAC;EAE/B"}
@@ -0,0 +1,45 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { loader } from './loader.js';
3
+ /**
4
+ * useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
5
+ * after `deps` last changed, and `data` keeps the last answer meanwhile. Only the newest run lands, so `onError` hears
6
+ * only the newest failure. A null `load` resets `data` to `initial`. `run` starts another load at once, such as the
7
+ * next page, under the same rule.
8
+ */
9
+ export function useDebouncedLoad(load, deps, { initial, delayMs, onError, }) {
10
+ const [data, setData] = useState(initial);
11
+ const [loading, setLoading] = useState(load !== null);
12
+ const [error, setError] = useState(null);
13
+ const latest = useRef({ initial, onError, delayMs });
14
+ latest.current = { initial, onError, delayMs };
15
+ const [runs] = useState(() => loader({
16
+ value: (value) => {
17
+ setData(() => value);
18
+ setError(null);
19
+ },
20
+ error: (e) => {
21
+ setError(e);
22
+ latest.current.onError?.(e);
23
+ },
24
+ loading: setLoading,
25
+ reset: () => setData(() => latest.current.initial),
26
+ }, {
27
+ keepAcrossKeys: true,
28
+ defer: (go) => {
29
+ const timer = globalThis.setTimeout(go, latest.current.delayMs);
30
+ return () => globalThis.clearTimeout(timer);
31
+ },
32
+ }));
33
+ // Runs after every render and acts only when the caller's deps changed: an effect cannot take a caller-supplied
34
+ // dependency list.
35
+ useEffect(() => {
36
+ runs.deps(deps, load);
37
+ });
38
+ useEffect(() => () => runs.stop(), [runs]);
39
+ return {
40
+ data,
41
+ loading,
42
+ error,
43
+ run: (next) => runs.run(next),
44
+ };
45
+ }
@@ -0,0 +1,3 @@
1
+ /** `value` once it has held still for `ms`; the first render returns it at once. */
2
+ export declare function useDebouncedValue<T>(value: T, ms: number): T;
3
+ //# sourceMappingURL=useDebouncedValue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDebouncedValue.d.ts","sourceRoot":"","sources":["../src/useDebouncedValue.ts"],"names":[],"mappings":"AAEA,oFAAoF;AACpF,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,CAO5D"}
@@ -0,0 +1,10 @@
1
+ import { useEffect, useState } from 'react';
2
+ /** `value` once it has held still for `ms`; the first render returns it at once. */
3
+ export function useDebouncedValue(value, ms) {
4
+ const [settled, setSettled] = useState(value);
5
+ useEffect(() => {
6
+ const timer = setTimeout(() => setSettled(value), ms);
7
+ return () => clearTimeout(timer);
8
+ }, [value, ms]);
9
+ return settled;
10
+ }
@@ -0,0 +1,15 @@
1
+ import { type DependencyList } from 'react';
2
+ /**
3
+ * Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
4
+ * superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
5
+ * and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
6
+ * `setData` edits the loaded value in place.
7
+ */
8
+ export declare function useLoaded<T>(load: (() => Promise<T>) | null, deps: DependencyList, initial: T, onError?: (error: unknown) => void): {
9
+ data: T;
10
+ setData: import("react").Dispatch<import("react").SetStateAction<T>>;
11
+ loading: boolean;
12
+ error: unknown;
13
+ reload: () => Promise<void>;
14
+ };
15
+ //# sourceMappingURL=useLoaded.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLoaded.d.ts","sourceRoot":"","sources":["../src/useLoaded.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAA4C,MAAM,OAAO,CAAA;AAGrF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,CAAC,EACzB,IAAI,EAAE,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,EAC/B,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,CAAC,EACV,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI;;;;;;EA+BnC"}
@@ -0,0 +1,37 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { loader } from './loader.js';
3
+ /**
4
+ * Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
5
+ * superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
6
+ * and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
7
+ * `setData` edits the loaded value in place.
8
+ */
9
+ export function useLoaded(load, deps, initial, onError) {
10
+ const [data, setData] = useState(initial);
11
+ const [loading, setLoading] = useState(load !== null);
12
+ const [error, setError] = useState(null);
13
+ const latest = useRef({ load, initial, onError });
14
+ latest.current = { load, initial, onError };
15
+ const [runs] = useState(() => loader({
16
+ value: (value) => setData(() => value),
17
+ error: (e) => {
18
+ setError(e);
19
+ latest.current.onError?.(e);
20
+ },
21
+ loading: (on) => {
22
+ setLoading(on);
23
+ if (on) {
24
+ setError(null);
25
+ }
26
+ },
27
+ reset: () => setData(() => latest.current.initial),
28
+ }));
29
+ // Runs after every render and starts a load only when the caller's deps changed: an effect cannot take a
30
+ // caller-supplied dependency list.
31
+ useEffect(() => {
32
+ runs.deps(deps, load);
33
+ });
34
+ useEffect(() => () => runs.stop(), [runs]);
35
+ const reload = useCallback(() => runs.run(latest.current.load), [runs]);
36
+ return { data, setData, loading, error, reload };
37
+ }
@@ -0,0 +1,6 @@
1
+ /** compute's answer from the minute clock; the component re-renders only when the answer changes, so compute returns a
2
+ * primitive (a label rather than the time). */
3
+ export declare function useNowValue<T extends string | number | boolean>(compute: (now: number) => T): T;
4
+ /** useNowValue on a clock that ticks every second while `on`; compute should return a constant while off. */
5
+ export declare function useSecondValue<T extends string | number | boolean>(compute: (now: number) => T, on: boolean): T;
6
+ //# sourceMappingURL=useNow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useNow.d.ts","sourceRoot":"","sources":["../src/useNow.ts"],"names":[],"mappings":"AAiDA;+CAC+C;AAC/C,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,CAAC,GAAG,CAAC,CAE/F;AAED,6GAA6G;AAC7G,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,OAAO,EAChE,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,CAAC,EAC3B,EAAE,EAAE,OAAO,GACV,CAAC,CAEH"}
package/dist/useNow.js ADDED
@@ -0,0 +1,46 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ const second = 1000;
3
+ const minute = 60_000;
4
+ // One shared timer per period, running only while some component watches it.
5
+ function tickingClock(period) {
6
+ const listeners = new Set();
7
+ let now = Date.now();
8
+ let timer;
9
+ function tick() {
10
+ now = Date.now();
11
+ for (const listener of listeners) {
12
+ listener();
13
+ }
14
+ }
15
+ function subscribe(listener) {
16
+ listeners.add(listener);
17
+ if (listeners.size === 1) {
18
+ // The clock stood still while nobody watched it.
19
+ now = Date.now();
20
+ timer = setInterval(tick, period);
21
+ }
22
+ return () => {
23
+ listeners.delete(listener);
24
+ if (listeners.size === 0 && timer !== undefined) {
25
+ clearInterval(timer);
26
+ timer = undefined;
27
+ }
28
+ };
29
+ }
30
+ return { subscribe, read: () => (listeners.size === 0 ? Date.now() : now) };
31
+ }
32
+ const minuteClock = tickingClock(minute);
33
+ const secondClock = tickingClock(second);
34
+ const unwatched = () => () => undefined;
35
+ function useClockValue(clock, compute, on) {
36
+ return useSyncExternalStore(on ? clock.subscribe : unwatched, () => compute(clock.read()));
37
+ }
38
+ /** compute's answer from the minute clock; the component re-renders only when the answer changes, so compute returns a
39
+ * primitive (a label rather than the time). */
40
+ export function useNowValue(compute) {
41
+ return useClockValue(minuteClock, compute, true);
42
+ }
43
+ /** useNowValue on a clock that ticks every second while `on`; compute should return a constant while off. */
44
+ export function useSecondValue(compute, on) {
45
+ return useClockValue(secondClock, compute, on);
46
+ }
@@ -0,0 +1,6 @@
1
+ declare function read<T>(key: string, fallback: T, valid: (value: unknown) => value is T): T;
2
+ declare function write(key: string, value: unknown): void;
3
+ /** State that survives a restart in localStorage. A stored value that fails `valid` is ignored. */
4
+ export declare function useStoredState<T>(key: string, fallback: T, valid: (value: unknown) => value is T): [T, (value: T) => void];
5
+ export { read as readStored, write as writeStored };
6
+ //# sourceMappingURL=useStoredState.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useStoredState.d.ts","sourceRoot":"","sources":["../src/useStoredState.ts"],"names":[],"mappings":"AAEA,iBAAS,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,GAAG,CAAC,CAWnF;AAWD,iBAAS,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,QAMzC;AAED,mGAAmG;AACnG,wBAAgB,cAAc,CAAC,CAAC,EAC9B,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,CAAC,EACX,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,GACpC,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC,CAczB;AAED,OAAO,EAAE,IAAI,IAAI,UAAU,EAAE,KAAK,IAAI,WAAW,EAAE,CAAA"}
@@ -0,0 +1,48 @@
1
+ import { useState } from 'react';
2
+ function read(key, fallback, valid) {
3
+ try {
4
+ const raw = localStorage.getItem(key);
5
+ if (raw === null) {
6
+ return fallback;
7
+ }
8
+ const parsed = parseOrRaw(raw);
9
+ return valid(parsed) ? parsed : fallback;
10
+ }
11
+ catch {
12
+ return fallback;
13
+ }
14
+ }
15
+ // Other code may have written a bare string, which is not JSON.
16
+ function parseOrRaw(raw) {
17
+ try {
18
+ return JSON.parse(raw);
19
+ }
20
+ catch {
21
+ return raw;
22
+ }
23
+ }
24
+ function write(key, value) {
25
+ try {
26
+ localStorage.setItem(key, JSON.stringify(value));
27
+ }
28
+ catch {
29
+ // Storage can be blocked or full; the choice then lasts until the app closes.
30
+ }
31
+ }
32
+ /** State that survives a restart in localStorage. A stored value that fails `valid` is ignored. */
33
+ export function useStoredState(key, fallback, valid) {
34
+ const [stored, setStored] = useState(() => ({ key, value: read(key, fallback, valid) }));
35
+ let value = stored.value;
36
+ if (stored.key !== key) {
37
+ value = read(key, fallback, valid);
38
+ setStored({ key, value });
39
+ }
40
+ return [
41
+ value,
42
+ (next) => {
43
+ setStored({ key, value: next });
44
+ write(key, next);
45
+ },
46
+ ];
47
+ }
48
+ export { read as readStored, write as writeStored };
@@ -0,0 +1,5 @@
1
+ import { type RefObject } from 'react';
2
+ /** The client width of the element the ref is put on, kept current as it resizes; 0 until measured. */
3
+ declare function useWidth<T extends HTMLElement>(): [RefObject<T | null>, number];
4
+ export { useWidth };
5
+ //# sourceMappingURL=useWidth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useWidth.d.ts","sourceRoot":"","sources":["../src/useWidth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAqC,MAAM,OAAO,CAAA;AAGzE,uGAAuG;AACvG,iBAAS,QAAQ,CAAC,CAAC,SAAS,WAAW,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,MAAM,CAAC,CAiBxE;AAED,OAAO,EAAE,QAAQ,EAAE,CAAA"}
@@ -0,0 +1,22 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react';
2
+ import { flushSync } from 'react-dom';
3
+ /** The client width of the element the ref is put on, kept current as it resizes; 0 until measured. */
4
+ function useWidth() {
5
+ const ref = useRef(null);
6
+ const [width, setWidth] = useState(0);
7
+ // Measured before paint, so the first frame is laid out for the real width rather than 0.
8
+ useLayoutEffect(() => {
9
+ const el = ref.current;
10
+ if (!el) {
11
+ return;
12
+ }
13
+ // Rendered before the browser paints the resize: a default-priority update would paint one frame laid out for the
14
+ // old width, with whatever columns or scrollbar that width implies.
15
+ const ro = new ResizeObserver(() => flushSync(() => setWidth(el.clientWidth)));
16
+ ro.observe(el);
17
+ setWidth(el.clientWidth);
18
+ return () => ro.disconnect();
19
+ }, []);
20
+ return [ref, width];
21
+ }
22
+ export { useWidth };
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@rethunk/react-load-hooks",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "React hooks for async loads that never land out of order, plus the small state hooks an app builds screens from.",
5
+ "license": "MIT",
6
+ "author": "Rethunk.Tech, LLC",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Rethunk-Tech/react-load-hooks.git"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "packageManager": "bun@1.4.4",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "lint": "biome check --error-on-warnings .",
26
+ "typecheck": "tsc -p tsconfig.test.json",
27
+ "test": "bun test",
28
+ "build": "rm -rf dist && tsc -p tsconfig.json",
29
+ "pack:check": "bun scripts/check-pack.ts"
30
+ },
31
+ "devDependencies": {
32
+ "@biomejs/biome": "^2.6.4",
33
+ "@happy-dom/global-registrator": "^21.0.4",
34
+ "@testing-library/dom": "^10.4.2",
35
+ "@testing-library/react": "^16.3.3",
36
+ "@types/bun": "^1.4.4",
37
+ "@types/react": "^19.3.0",
38
+ "@types/react-dom": "^19.3.0",
39
+ "react": "^19.3.1",
40
+ "react-dom": "^19.3.1",
41
+ "typescript": "^7.0.2"
42
+ },
43
+ "peerDependencies": {
44
+ "react": "^19.0.0",
45
+ "react-dom": "^19.0.0"
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { type LoadSink, loader } from './loader.ts'
2
+ export { useDebouncedLoad } from './useDebouncedLoad.ts'
3
+ export { useDebouncedValue } from './useDebouncedValue.ts'
4
+ export { useLoaded } from './useLoaded.ts'
5
+ export { useNowValue, useSecondValue } from './useNow.ts'
6
+ export { readStored, useStoredState, writeStored } from './useStoredState.ts'
7
+ export { useWidth } from './useWidth.ts'
@@ -0,0 +1,78 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { loader } from './loader.ts'
3
+
4
+ const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
5
+
6
+ function harness(keepAcrossKeys: boolean) {
7
+ const log: string[] = []
8
+ const pending: (() => void)[] = []
9
+ const runs = loader<string>(
10
+ {
11
+ value: (v) => log.push(`value ${v}`),
12
+ error: (e) => log.push(`error ${String(e)}`),
13
+ loading: (on) => log.push(on ? 'loading' : 'idle'),
14
+ reset: () => log.push('reset'),
15
+ },
16
+ {
17
+ keepAcrossKeys,
18
+ // A deferred start waits in `pending` until the test runs it; cancelling empties its slot.
19
+ defer: (go) => {
20
+ const i = pending.push(go) - 1
21
+ return () => {
22
+ pending[i] = () => undefined
23
+ }
24
+ },
25
+ },
26
+ )
27
+ return { log, pending, runs }
28
+ }
29
+
30
+ // The branches useLoaded and useDebouncedLoad share: a superseded answer and error are dropped, new deps reset or keep
31
+ // the value, a null load resets, a start cancelled by new deps never runs, and stop drops what is in flight.
32
+ test('only the newest load lands', async () => {
33
+ for (const keep of [false, true]) {
34
+ const { log, pending, runs } = harness(keep)
35
+ const a = Promise.withResolvers<string>()
36
+ const b = Promise.withResolvers<string>()
37
+ runs.deps(['a'], () => a.promise)
38
+ runs.deps(['a'], () => b.promise)
39
+ expect(pending).toHaveLength(1)
40
+ pending[0]?.()
41
+ runs.deps(['b'], () => b.promise)
42
+ pending[1]?.()
43
+ b.resolve('b')
44
+ a.reject(new Error('a failed'))
45
+ await flush()
46
+ expect(log).toEqual([
47
+ 'loading',
48
+ 'loading',
49
+ ...(keep ? [] : ['reset']),
50
+ 'loading',
51
+ 'loading',
52
+ 'value b',
53
+ 'idle',
54
+ ])
55
+
56
+ log.length = 0
57
+ const c = Promise.withResolvers<string>()
58
+ const older = runs.run(() => c.promise)
59
+ const newest = runs.run(() => Promise.reject(new Error('newest')))
60
+ c.resolve('c')
61
+ await Promise.all([older, newest])
62
+ expect(log).toEqual(['loading', 'loading', 'error Error: newest', 'idle'])
63
+
64
+ log.length = 0
65
+ runs.deps(['c'], () => c.promise)
66
+ runs.deps(['d'], null)
67
+ pending.at(-1)?.()
68
+ await flush()
69
+ expect(log).toEqual([...(keep ? [] : ['reset']), 'loading', 'reset', 'idle'])
70
+
71
+ log.length = 0
72
+ await runs.run(null)
73
+ const late = runs.run(() => Promise.resolve('late'))
74
+ runs.stop()
75
+ await late
76
+ expect(log).toEqual(['reset', 'idle', 'loading'])
77
+ }
78
+ })
package/src/loader.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { DependencyList } from 'react'
2
+
3
+ const sameDeps = (a: DependencyList | null, b: DependencyList) =>
4
+ a !== null && a.length === b.length && b.every((dep, i) => Object.is(dep, a[i]))
5
+
6
+ export interface LoadSink<T> {
7
+ value: (value: T) => void
8
+ error: (error: unknown) => void
9
+ loading: (on: boolean) => void
10
+ /** Shows the hook's initial value again. */
11
+ reset: () => void
12
+ }
13
+
14
+ const now = (go: () => void) => {
15
+ go()
16
+ return () => undefined
17
+ }
18
+
19
+ /**
20
+ * useLoaded's and useDebouncedLoad's logic without React. Only the newest load lands: a later load, new deps or stop
21
+ * drops the answer, error and end of every one before it. New deps reset the value unless keepAcrossKeys, and a null
22
+ * load always does. `defer` starts a load, now or later, and returns what cancels the start.
23
+ */
24
+ export function loader<T>(
25
+ sink: LoadSink<T>,
26
+ {
27
+ keepAcrossKeys = false,
28
+ defer = now,
29
+ }: { keepAcrossKeys?: boolean; defer?: (go: () => void) => () => void } = {},
30
+ ) {
31
+ let generation = 0
32
+ let seen: DependencyList | null = null
33
+ let cancel: () => void = () => undefined
34
+ const drop = () => {
35
+ generation += 1
36
+ cancel()
37
+ }
38
+ const start = (load: () => Promise<T>): Promise<void> => {
39
+ generation += 1
40
+ const mine = generation
41
+ const current = () => generation === mine
42
+ sink.loading(true)
43
+ return load()
44
+ .then(
45
+ (value) => {
46
+ if (current()) {
47
+ sink.value(value)
48
+ }
49
+ },
50
+ (error: unknown) => {
51
+ if (current()) {
52
+ sink.error(error)
53
+ }
54
+ },
55
+ )
56
+ .finally(() => {
57
+ if (current()) {
58
+ sink.loading(false)
59
+ }
60
+ })
61
+ }
62
+ const skip = () => {
63
+ drop()
64
+ sink.reset()
65
+ sink.loading(false)
66
+ }
67
+ return {
68
+ deps(deps: DependencyList, load: (() => Promise<T>) | null) {
69
+ if (sameDeps(seen, deps)) {
70
+ return
71
+ }
72
+ const fresh = seen !== null
73
+ seen = deps
74
+ if (load === null) {
75
+ skip()
76
+ return
77
+ }
78
+ drop()
79
+ if (fresh && !keepAcrossKeys) {
80
+ sink.reset()
81
+ }
82
+ sink.loading(true)
83
+ // start settles through the sink, so only a throwing sink handler reaches this catch.
84
+ cancel = defer(() => {
85
+ start(load).catch(sink.error)
86
+ })
87
+ },
88
+ /** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
89
+ run(load: (() => Promise<T>) | null): Promise<void> {
90
+ if (load === null) {
91
+ skip()
92
+ return Promise.resolve()
93
+ }
94
+ return start(load)
95
+ },
96
+ stop() {
97
+ drop()
98
+ seen = null
99
+ },
100
+ }
101
+ }
@@ -0,0 +1,26 @@
1
+ import { afterAll, expect, test } from 'bun:test'
2
+
3
+ const testing = await import('@testing-library/react')
4
+ const { useDebouncedLoad } = await import('./useDebouncedLoad.ts')
5
+
6
+ afterAll(() => {
7
+ testing.cleanup()
8
+ })
9
+
10
+ const sleep = (ms: number) => testing.act(async () => new Promise<void>((r) => setTimeout(r, ms)))
11
+
12
+ test('a delayMs that changes between renders applies to the next scheduled load', async () => {
13
+ const view = testing.renderHook(
14
+ ({ key, delayMs }) =>
15
+ useDebouncedLoad(() => Promise.resolve(key), [key], { initial: '', delayMs }),
16
+ { initialProps: { key: 'a', delayMs: 1 } },
17
+ )
18
+ await sleep(20)
19
+ expect(view.result.current.data).toBe('a')
20
+
21
+ view.rerender({ key: 'b', delayMs: 150 })
22
+ await sleep(40)
23
+ expect(view.result.current.data).toBe('a')
24
+ await sleep(200)
25
+ expect(view.result.current.data).toBe('b')
26
+ })
@@ -0,0 +1,59 @@
1
+ import { type DependencyList, useEffect, useRef, useState } from 'react'
2
+ import { loader } from './loader.ts'
3
+
4
+ /**
5
+ * useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
6
+ * after `deps` last changed, and `data` keeps the last answer meanwhile. Only the newest run lands, so `onError` hears
7
+ * only the newest failure. A null `load` resets `data` to `initial`. `run` starts another load at once, such as the
8
+ * next page, under the same rule.
9
+ */
10
+ export function useDebouncedLoad<T>(
11
+ load: (() => Promise<T>) | null,
12
+ deps: DependencyList,
13
+ {
14
+ initial,
15
+ delayMs,
16
+ onError,
17
+ }: { initial: T; delayMs: number; onError?: (error: unknown) => void },
18
+ ) {
19
+ const [data, setData] = useState(initial)
20
+ const [loading, setLoading] = useState(load !== null)
21
+ const [error, setError] = useState<unknown>(null)
22
+ const latest = useRef({ initial, onError, delayMs })
23
+ latest.current = { initial, onError, delayMs }
24
+ const [runs] = useState(() =>
25
+ loader<T>(
26
+ {
27
+ value: (value) => {
28
+ setData(() => value)
29
+ setError(null)
30
+ },
31
+ error: (e) => {
32
+ setError(e)
33
+ latest.current.onError?.(e)
34
+ },
35
+ loading: setLoading,
36
+ reset: () => setData(() => latest.current.initial),
37
+ },
38
+ {
39
+ keepAcrossKeys: true,
40
+ defer: (go) => {
41
+ const timer = globalThis.setTimeout(go, latest.current.delayMs)
42
+ return () => globalThis.clearTimeout(timer)
43
+ },
44
+ },
45
+ ),
46
+ )
47
+ // Runs after every render and acts only when the caller's deps changed: an effect cannot take a caller-supplied
48
+ // dependency list.
49
+ useEffect(() => {
50
+ runs.deps(deps, load)
51
+ })
52
+ useEffect(() => () => runs.stop(), [runs])
53
+ return {
54
+ data,
55
+ loading,
56
+ error,
57
+ run: (next: () => Promise<T>) => runs.run(next),
58
+ }
59
+ }
@@ -0,0 +1,11 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /** `value` once it has held still for `ms`; the first render returns it at once. */
4
+ export function useDebouncedValue<T>(value: T, ms: number): T {
5
+ const [settled, setSettled] = useState(value)
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setSettled(value), ms)
8
+ return () => clearTimeout(timer)
9
+ }, [value, ms])
10
+ return settled
11
+ }
@@ -0,0 +1,125 @@
1
+ import { afterAll, expect, test } from 'bun:test'
2
+
3
+ const testing = await import('@testing-library/react')
4
+ const { useLoaded } = await import('./useLoaded.ts')
5
+
6
+ afterAll(() => {
7
+ testing.cleanup()
8
+ })
9
+
10
+ // Flushes pending promises inside act, so the state update they make renders before the test reads it.
11
+ const settle = () => testing.act(async () => new Promise<void>((resolve) => setTimeout(resolve, 0)))
12
+
13
+ function deferred<T>() {
14
+ let resolve: (v: T) => void = () => undefined
15
+ let reject: (e: unknown) => void = () => undefined
16
+ const promise = new Promise<T>((res, rej) => {
17
+ resolve = res
18
+ reject = rej
19
+ })
20
+ return { promise, resolve, reject }
21
+ }
22
+
23
+ test('loads, then reloads with the same deps', async () => {
24
+ let n = 0
25
+ const next = () => {
26
+ n += 1
27
+ return Promise.resolve(n)
28
+ }
29
+ const { result } = testing.renderHook(() => useLoaded(next, [], 0))
30
+ expect(result.current.loading).toBe(true)
31
+ await settle()
32
+ expect(result.current.data).toBe(1)
33
+ expect(result.current.loading).toBe(false)
34
+ await testing.act(() => result.current.reload())
35
+ await settle()
36
+ expect(result.current.data).toBe(2)
37
+ })
38
+
39
+ test('a run superseded by new deps is dropped', async () => {
40
+ const first = deferred<string>()
41
+ const second = deferred<string>()
42
+ const { result, rerender } = testing.renderHook(
43
+ ({ key }) => useLoaded(() => (key === 'a' ? first.promise : second.promise), [key], ''),
44
+ { initialProps: { key: 'a' } },
45
+ )
46
+ rerender({ key: 'b' })
47
+ second.resolve('b')
48
+ await settle()
49
+ expect(result.current.data).toBe('b')
50
+ first.resolve('a')
51
+ await testing.act(() => first.promise)
52
+ expect(result.current.data).toBe('b')
53
+ })
54
+
55
+ test('an answer after unmount is ignored and errors reach onError once', async () => {
56
+ const late = deferred<string>()
57
+ const seen: unknown[] = []
58
+ const gone = testing.renderHook(() =>
59
+ useLoaded(
60
+ () => late.promise,
61
+ [],
62
+ '',
63
+ (e) => seen.push(e),
64
+ ),
65
+ )
66
+ gone.unmount()
67
+ late.reject(new Error('late'))
68
+ await late.promise.catch(() => undefined)
69
+ expect(seen).toEqual([])
70
+
71
+ const boom = new Error('boom')
72
+ const { result } = testing.renderHook(() =>
73
+ useLoaded(
74
+ () => Promise.reject(boom),
75
+ [],
76
+ 'fallback',
77
+ (e) => seen.push(e),
78
+ ),
79
+ )
80
+ await settle()
81
+ expect(result.current.error).toBe(boom)
82
+ expect(result.current.data).toBe('fallback')
83
+ expect(result.current.loading).toBe(false)
84
+ expect(seen).toEqual([boom])
85
+ })
86
+
87
+ test('new deps clear the old answer until the new one arrives', async () => {
88
+ const second = deferred<string>()
89
+ const { result, rerender } = testing.renderHook(
90
+ ({ key }) => useLoaded(() => (key === 'a' ? Promise.resolve('a') : second.promise), [key], ''),
91
+ { initialProps: { key: 'a' } },
92
+ )
93
+ await settle()
94
+ expect(result.current.data).toBe('a')
95
+ rerender({ key: 'b' })
96
+ await settle()
97
+ expect(result.current.data).toBe('')
98
+ second.resolve('b')
99
+ await settle()
100
+ expect(result.current.data).toBe('b')
101
+ })
102
+
103
+ test('a null loader never runs', () => {
104
+ const { result } = testing.renderHook(() => useLoaded<number>(null, [], 7))
105
+ expect(result.current).toMatchObject({ data: 7, loading: false })
106
+ })
107
+
108
+ test('a failed reload keeps the last good data', async () => {
109
+ let fail: boolean
110
+ fail = false
111
+ const { result } = testing.renderHook(() =>
112
+ useLoaded(
113
+ () => (fail ? Promise.reject(new Error('offline')) : Promise.resolve('good')),
114
+ [],
115
+ '',
116
+ ),
117
+ )
118
+ await settle()
119
+ expect(result.current.data).toBe('good')
120
+ fail = true
121
+ await testing.act(() => result.current.reload())
122
+ await settle()
123
+ expect(result.current.data).toBe('good')
124
+ expect(result.current.error).toBeInstanceOf(Error)
125
+ })
@@ -0,0 +1,45 @@
1
+ import { type DependencyList, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { loader } from './loader.ts'
3
+
4
+ /**
5
+ * Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
6
+ * superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
7
+ * and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
8
+ * `setData` edits the loaded value in place.
9
+ */
10
+ export function useLoaded<T>(
11
+ load: (() => Promise<T>) | null,
12
+ deps: DependencyList,
13
+ initial: T,
14
+ onError?: (error: unknown) => void,
15
+ ) {
16
+ const [data, setData] = useState(initial)
17
+ const [loading, setLoading] = useState(load !== null)
18
+ const [error, setError] = useState<unknown>(null)
19
+ const latest = useRef({ load, initial, onError })
20
+ latest.current = { load, initial, onError }
21
+ const [runs] = useState(() =>
22
+ loader<T>({
23
+ value: (value) => setData(() => value),
24
+ error: (e) => {
25
+ setError(e)
26
+ latest.current.onError?.(e)
27
+ },
28
+ loading: (on) => {
29
+ setLoading(on)
30
+ if (on) {
31
+ setError(null)
32
+ }
33
+ },
34
+ reset: () => setData(() => latest.current.initial),
35
+ }),
36
+ )
37
+ // Runs after every render and starts a load only when the caller's deps changed: an effect cannot take a
38
+ // caller-supplied dependency list.
39
+ useEffect(() => {
40
+ runs.deps(deps, load)
41
+ })
42
+ useEffect(() => () => runs.stop(), [runs])
43
+ const reload = useCallback(() => runs.run(latest.current.load), [runs])
44
+ return { data, setData, loading, error, reload }
45
+ }
@@ -0,0 +1,58 @@
1
+ import { afterAll, afterEach, beforeEach, expect, jest, setSystemTime, test } from 'bun:test'
2
+
3
+ const testing = await import('@testing-library/react')
4
+ const { useNowValue, useSecondValue } = await import('./useNow.ts')
5
+
6
+ beforeEach(() => {
7
+ jest.useFakeTimers()
8
+ setSystemTime(new Date('2026-01-01T00:00:00Z'))
9
+ })
10
+ afterEach(() => {
11
+ testing.cleanup()
12
+ jest.useRealTimers()
13
+ setSystemTime()
14
+ })
15
+ afterAll(() => {
16
+ testing.cleanup()
17
+ })
18
+
19
+ test('a watcher re-renders only when its answer changes, and the clock stops with the last one', () => {
20
+ let renders = 0
21
+ const view = testing.renderHook(() => {
22
+ renders += 1
23
+ return useSecondValue((now) => Math.floor(now / 10_000), true)
24
+ })
25
+ const first = view.result.current
26
+ const afterMount = renders
27
+
28
+ testing.act(() => {
29
+ setSystemTime(new Date('2026-01-01T00:00:03Z'))
30
+ jest.advanceTimersByTime(3000)
31
+ })
32
+ expect(view.result.current).toBe(first)
33
+ expect(renders).toBe(afterMount)
34
+
35
+ testing.act(() => {
36
+ setSystemTime(new Date('2026-01-01T00:00:10Z'))
37
+ jest.advanceTimersByTime(7000)
38
+ })
39
+ expect(view.result.current).toBe(first + 1)
40
+
41
+ view.unmount()
42
+ expect(jest.getTimerCount()).toBe(0)
43
+ })
44
+
45
+ test('the minute clock ticks once for every watcher and is idle when none is left', () => {
46
+ const a = testing.renderHook(() => useNowValue((now) => Math.floor(now / 60_000)))
47
+ const b = testing.renderHook(() => useNowValue((now) => Math.floor(now / 60_000)))
48
+ expect(jest.getTimerCount()).toBe(1)
49
+ a.unmount()
50
+ expect(jest.getTimerCount()).toBe(1)
51
+ b.unmount()
52
+ expect(jest.getTimerCount()).toBe(0)
53
+ })
54
+
55
+ test('a clock switched off never ticks', () => {
56
+ testing.renderHook(() => useSecondValue(() => 'off', false))
57
+ expect(jest.getTimerCount()).toBe(0)
58
+ })
package/src/useNow.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ const second = 1000
4
+ const minute = 60_000
5
+
6
+ // One shared timer per period, running only while some component watches it.
7
+ function tickingClock(period: number) {
8
+ const listeners = new Set<() => void>()
9
+ let now = Date.now()
10
+ let timer: ReturnType<typeof setInterval> | undefined
11
+
12
+ function tick() {
13
+ now = Date.now()
14
+ for (const listener of listeners) {
15
+ listener()
16
+ }
17
+ }
18
+
19
+ function subscribe(listener: () => void) {
20
+ listeners.add(listener)
21
+ if (listeners.size === 1) {
22
+ // The clock stood still while nobody watched it.
23
+ now = Date.now()
24
+ timer = setInterval(tick, period)
25
+ }
26
+ return () => {
27
+ listeners.delete(listener)
28
+ if (listeners.size === 0 && timer !== undefined) {
29
+ clearInterval(timer)
30
+ timer = undefined
31
+ }
32
+ }
33
+ }
34
+
35
+ return { subscribe, read: () => (listeners.size === 0 ? Date.now() : now) }
36
+ }
37
+
38
+ const minuteClock = tickingClock(minute)
39
+ const secondClock = tickingClock(second)
40
+ const unwatched = () => () => undefined
41
+
42
+ function useClockValue<T extends string | number | boolean>(
43
+ clock: ReturnType<typeof tickingClock>,
44
+ compute: (now: number) => T,
45
+ on: boolean,
46
+ ): T {
47
+ return useSyncExternalStore(on ? clock.subscribe : unwatched, () => compute(clock.read()))
48
+ }
49
+
50
+ /** compute's answer from the minute clock; the component re-renders only when the answer changes, so compute returns a
51
+ * primitive (a label rather than the time). */
52
+ export function useNowValue<T extends string | number | boolean>(compute: (now: number) => T): T {
53
+ return useClockValue(minuteClock, compute, true)
54
+ }
55
+
56
+ /** useNowValue on a clock that ticks every second while `on`; compute should return a constant while off. */
57
+ export function useSecondValue<T extends string | number | boolean>(
58
+ compute: (now: number) => T,
59
+ on: boolean,
60
+ ): T {
61
+ return useClockValue(secondClock, compute, on)
62
+ }
@@ -0,0 +1,43 @@
1
+ import { afterAll, expect, test } from 'bun:test'
2
+
3
+ const testing = await import('@testing-library/react')
4
+ const { readStored, useStoredState, writeStored } = await import('./useStoredState.ts')
5
+
6
+ afterAll(() => {
7
+ testing.cleanup()
8
+ })
9
+
10
+ const isText = (v: unknown): v is string => typeof v === 'string'
11
+
12
+ test('a stored string reads back whether it was written as JSON or bare', () => {
13
+ localStorage.setItem('stored-test.json', JSON.stringify('run-1'))
14
+ localStorage.setItem('stored-test.bare', 'run-2')
15
+ expect(readStored('stored-test.json', '', isText)).toBe('run-1')
16
+ expect(readStored('stored-test.bare', '', isText)).toBe('run-2')
17
+ expect(readStored('stored-test.missing', 'none', isText)).toBe('none')
18
+ localStorage.removeItem('stored-test.json')
19
+ localStorage.removeItem('stored-test.bare')
20
+ })
21
+
22
+ test('the value follows the key when the key changes', () => {
23
+ localStorage.setItem('stored-test.a', JSON.stringify('one'))
24
+ localStorage.setItem('stored-test.b', JSON.stringify('two'))
25
+ const view = testing.renderHook(({ key }) => useStoredState(key, '', isText), {
26
+ initialProps: { key: 'stored-test.a' },
27
+ })
28
+ expect(view.result.current[0]).toBe('one')
29
+ view.rerender({ key: 'stored-test.b' })
30
+ expect(view.result.current[0]).toBe('two')
31
+ testing.act(() => view.result.current[1]('three'))
32
+ expect(localStorage.getItem('stored-test.b')).toBe('"three"')
33
+ expect(localStorage.getItem('stored-test.a')).toBe('"one"')
34
+ localStorage.removeItem('stored-test.a')
35
+ localStorage.removeItem('stored-test.b')
36
+ })
37
+
38
+ test('the caller key reaches localStorage verbatim, with no prefix', () => {
39
+ writeStored('stored-test.verbatim', { a: 1 })
40
+ expect(localStorage.getItem('stored-test.verbatim')).toBe(JSON.stringify({ a: 1 }))
41
+ expect(localStorage.length).toBe(1)
42
+ localStorage.removeItem('stored-test.verbatim')
43
+ })
@@ -0,0 +1,54 @@
1
+ import { useState } from 'react'
2
+
3
+ function read<T>(key: string, fallback: T, valid: (value: unknown) => value is T): T {
4
+ try {
5
+ const raw = localStorage.getItem(key)
6
+ if (raw === null) {
7
+ return fallback
8
+ }
9
+ const parsed = parseOrRaw(raw)
10
+ return valid(parsed) ? parsed : fallback
11
+ } catch {
12
+ return fallback
13
+ }
14
+ }
15
+
16
+ // Other code may have written a bare string, which is not JSON.
17
+ function parseOrRaw(raw: string): unknown {
18
+ try {
19
+ return JSON.parse(raw)
20
+ } catch {
21
+ return raw
22
+ }
23
+ }
24
+
25
+ function write(key: string, value: unknown) {
26
+ try {
27
+ localStorage.setItem(key, JSON.stringify(value))
28
+ } catch {
29
+ // Storage can be blocked or full; the choice then lasts until the app closes.
30
+ }
31
+ }
32
+
33
+ /** State that survives a restart in localStorage. A stored value that fails `valid` is ignored. */
34
+ export function useStoredState<T>(
35
+ key: string,
36
+ fallback: T,
37
+ valid: (value: unknown) => value is T,
38
+ ): [T, (value: T) => void] {
39
+ const [stored, setStored] = useState(() => ({ key, value: read(key, fallback, valid) }))
40
+ let value = stored.value
41
+ if (stored.key !== key) {
42
+ value = read(key, fallback, valid)
43
+ setStored({ key, value })
44
+ }
45
+ return [
46
+ value,
47
+ (next) => {
48
+ setStored({ key, value: next })
49
+ write(key, next)
50
+ },
51
+ ]
52
+ }
53
+
54
+ export { read as readStored, write as writeStored }
@@ -0,0 +1,24 @@
1
+ import { type RefObject, useLayoutEffect, useRef, useState } from 'react'
2
+ import { flushSync } from 'react-dom'
3
+
4
+ /** The client width of the element the ref is put on, kept current as it resizes; 0 until measured. */
5
+ function useWidth<T extends HTMLElement>(): [RefObject<T | null>, number] {
6
+ const ref = useRef<T | null>(null)
7
+ const [width, setWidth] = useState(0)
8
+ // Measured before paint, so the first frame is laid out for the real width rather than 0.
9
+ useLayoutEffect(() => {
10
+ const el = ref.current
11
+ if (!el) {
12
+ return
13
+ }
14
+ // Rendered before the browser paints the resize: a default-priority update would paint one frame laid out for the
15
+ // old width, with whatever columns or scrollbar that width implies.
16
+ const ro = new ResizeObserver(() => flushSync(() => setWidth(el.clientWidth)))
17
+ ro.observe(el)
18
+ setWidth(el.clientWidth)
19
+ return () => ro.disconnect()
20
+ }, [])
21
+ return [ref, width]
22
+ }
23
+
24
+ export { useWidth }