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
bun add pulsify-react
It needs React 18 or 19 and pulsify-core, nothing else.
The definition
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
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
memochildren the part they show, or let them callusePulseon 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
"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:
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>