React

pulsify-react exports one hook, usePulse. A component re-renders only when a value it read changes, and objects keep their identity so memo children skip.

Install

terminal
bun add pulsify-react

It needs React 18 or 19 and pulsify-core, nothing else.

The definition

note.ts
import {
  array, createWebSocketConnector, definePulse, object,
  type ModelFromDefinition, type Pulse,
} from "pulsify-core";

export const Note = definePulse("Note", object({
  title: "string",
  tags: array("string"),
  author: object({ name: "string" }),
})).materialize();
export type NotePulse = Pulse<ModelFromDefinition<typeof Note>>;

export const remote = createWebSocketConnector("wss://sync.example.com/sync");

Render and edit

Editor.tsx
import { memo } from "react";
import { usePulse } from "pulsify-react";
import type { NotePulse } from "./note";

export function Editor({ pulse }: { pulse: NotePulse }) {
  const note = usePulse(pulse);
  return (
    <>
      <input value={note.title} onChange={(e) => (note.title = e.target.value)} />
      <Author author={note.author} />
      <ul>{note.tags.map((tag, i) => <li key={i}>{tag}</li>)}</ul>
      <button onClick={() => note.tags.push("new")}>Add tag</button>
    </>
  );
}

// Skips while nothing it read changed: typing in the title leaves it alone.
const Author = memo(({ author }: { author: NotePulse["author"] }) => (
  <p>by {author.name}</p>
));

usePulse returns a proxy of the live pulse that records what the component, and the children it passes values to, read during render. Local and remote changes re-render it only when one of those values changed. Writes go straight to the pulse.

  • Objects, arrays, rich text and trees keep their identity while nothing read below them changes, including after an array move.
  • The pulse itself is renewed on every render. Pass memo children the part they show, or let them call usePulse on what they received.
  • Rich text and trees are compared as a whole. Bind editors with the ProseMirror and Lexical bindings.
  • Never write to the pulse during render.

Load on the client

NotePage.tsx
"use client";
import { useEffect, useState } from "react";
import { disconnectPulse, loadPulse } from "pulsify-core";
import { Editor } from "./Editor";
import { Note, remote, type NotePulse } from "./note";

export function NotePage({ id }: { id: string }) {
  const [pulse, setPulse] = useState<NotePulse>();
  // Effects never run during server rendering.
  useEffect(() => {
    let loaded: NotePulse | undefined;
    let active = true;
    loadPulse(Note, id, { connectors: [remote] }).then((note) => {
      loaded = note;
      if (active) setPulse(note);
    });
    return () => {
      active = false;
      if (loaded) disconnectPulse(loaded);
    };
  }, [id]);
  return pulse ? <Editor key={id} pulse={pulse} /> : <p>Loading…</p>;
}

usePulse refuses to render on the server. When a component using it can be reached during server rendering, put it under a <Suspense> boundary:

Page.tsx
import { Suspense } from "react";

// On the server, usePulse throws and the boundary renders its fallback.
// The browser then renders the editor.
<Suspense fallback={<p>Loading…</p>}>
  <Editor pulse={pulse} />
</Suspense>