Props & data

Props Diffing

Only the changed path travels, unless a component opts out

  • data-props-diff
  • diff={false}

Key concepts

By default LiveReact sends only the part of a prop that changed, not the whole value. Both components below share one large payload assign; clicking the button changes a single field inside it. Only the changed path travels to the diffed instance — the other resends the entire payload on every update, because it opted out.

PropsDiffing.jsx
import React, { useEffect, useRef, useState } from "react";

// Reports how many bytes actually crossed the wire on the last update.
//
// Measuring the *size* of `data-props` is misleading: on a diffed component
// that attribute holds the first snapshot and never changes again — which is
// the whole point of diffing — so both a diffed and an undiffed instance show
// roughly the same number while meaning completely different things. What
// distinguishes them is which attribute *changed*, and how big it was.
//
// So this keeps the previous value of both attributes and diffs them itself.
// The numbers below are measured from the real wire payload, never computed
// from the props.
const bytes = (value) => new TextEncoder().encode(value).length;

export function PropsDiffing({ label, payload }) {
  const wrapperRef = useRef(null);
  const previous = useRef(null);
  const [wire, setWire] = useState({
    status: "waiting for the first update",
    last: null,
    total: 0,
    source: null,
  });

  useEffect(() => {
    const wrapper = wrapperRef.current?.closest("[data-props]");
    if (!wrapper) return;

    const current = {
      props: wrapper.getAttribute("data-props") ?? "",
      diff: wrapper.getAttribute("data-props-diff") ?? "",
    };

    // First observation is the initial render, where everything is new by
    // definition. Counting it would flatter whichever instance happened to
    // render first, so it is recorded and excluded.
    if (previous.current === null) {
      previous.current = current;
      setWire((w) => ({ ...w, status: "initial render (not counted)" }));
      return;
    }

    const changed = [];
    let size = 0;

    if (current.props !== previous.current.props) {
      changed.push("data-props");
      size += bytes(current.props);
    }

    if (current.diff !== previous.current.diff) {
      changed.push("data-props-diff");
      size += bytes(current.diff);
    }

    previous.current = current;

    setWire((w) => ({
      status: changed.length ? "updated" : "no attribute changed",
      last: size,
      total: w.total + size,
      source: changed.join(" + ") || null,
    }));
  }, [payload]);

  return (
    <div ref={wrapperRef} className="rounded-md border border-[color:var(--edge)] p-4">
      <h3 className="font-medium">{label}</h3>

      <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-4 text-sm">
        <dt className="text-[color:var(--text-muted)]">counter</dt>
        <dd>{payload.counter}</dd>

        <dt className="text-[color:var(--text-muted)]">last update</dt>
        <dd>{wire.last === null ? wire.status : `${wire.last} bytes`}</dd>

        <dt className="text-[color:var(--text-muted)]">via</dt>
        <dd>{wire.source ?? "—"}</dd>

        <dt className="text-[color:var(--text-muted)]">total received</dt>
        <dd>{wire.total} bytes</dd>
      </dl>
    </div>
  );
}

How it works

LiveReact writes data-use-diff on the component's root element to say which mode it is in. When diffing is on, prop changes are computed as a JSON Patch and written to data-props-diff; the client applies that patch to the props it already has instead of receiving a new snapshot.

Diffing is on by default, controlled globally by config :live_react, :enable_props_diff. Pass diff={false} on a single <.react> call to opt that instance out and always send the full prop value — useful when a prop is small enough that diffing is pure overhead, or when a component needs the complete value on every render.