[]previous and next page

A hook with storage, errors and hydration: useStoredSignal

Hooks that touch the outside world have three more concerns: the server, errors, and hydration.

For agentsUpdated

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. isServer is true in 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 catch either handles the error, rethrows, or hands it to handleError(error, owner), which finds the nearest onError/catchError/ErrorBoundary. Capture currentOwner() while the hook runs: it is null inside a later callback. Never write an empty catch for an error you did not expect.