A hook with storage, errors and hydration: useStoredSignal
Hooks that touch the outside world have three more concerns: the server, errors, and hydration.
Hooks that touch the outside world have three more concerns: the server, errors, and hydration.
src/use-stored-signal.tsts
import { isServer } from "@shadow-js/core";
import {
type Accessor,
createComputation,
createSignalNode,
currentOwner,
handleError,
readSignal,
runInScope,
writeSignal,
} from "@shadow-js/core/extend";
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
/** The browser's `localStorage` when it exists and may be used. Reading it can throw. */
export function browserStorage(): StorageLike | undefined {
try {
return typeof localStorage === "undefined" ? undefined : localStorage;
} catch {
return undefined; // blocked storage is a normal condition, not an error
}
}
/**
* A signal persisted as JSON. The first value is always `initial`, so the first client render
* matches the server's; the stored value is applied right after mount.
*/
export function useStoredSignal<T>(
key: string,
initial: T,
storage: StorageLike | undefined = browserStorage(),
): [Accessor<T>, (next: T) => void] {
const signal = createSignalNode<T>(initial, Object.is, key);
const read: Accessor<T> = () => readSignal(signal);
const write = (next: T): void => {
writeSignal(signal, next);
};
if (isServer || storage === undefined) return [read, write];
const owner = currentOwner();
// Runs once after the first flush (it reads no signal, so it never reruns).
createComputation({
kind: "effect",
name: "useStoredSignal:load",
fn: () => {
const text = storage.getItem(key);
if (text === null) return;
try {
writeSignal(signal, JSON.parse(text) as T);
} catch {
// Corrupt stored text: keep `initial`; the next write replaces it.
}
},
});
// Persists every change after the first run. A storage failure goes to the owner's error handler.
createComputation({
kind: "effect",
name: "useStoredSignal:save",
defer: true,
fn: () => readSignal(signal),
apply: (value: T) => {
try {
storage.setItem(key, JSON.stringify(value));
} catch (error) {
handleError(error, owner);
}
},
});
void runInScope;
return [read, write];
}
What the example shows, which applies to every hook:
- The server.
isServeristruein the server builds; effects never run there, and neither should yours. Return before creating a computation. - Hydration. If the first client value differed from the server's, the hydrated tree would not match. Start from the deterministic value and apply the real one in an effect.
- Errors. A
catcheither handles the error, rethrows, or hands it tohandleError(error, owner), which finds the nearestonError/catchError/ErrorBoundary. CapturecurrentOwner()while the hook runs: it isnullinside a later callback. Never write an emptycatchfor an error you did not expect.