react-snapshot-bridge

react-snapshot-bridge

Use React's getSnapshotBeforeUpdate lifecycle from function components via a tiny wrapper.

react-snapshot-bridge is a library that lets React function components use the class-only getSnapshotBeforeUpdate lifecycle through a single drop-in wrapper.

Why

useLayoutEffect runs after the DOM is mutated, so by the time it fires the previous DOM state — old scrollTop, prior selection, prior dimensions — is already gone.

getSnapshotBeforeUpdate is the React lifecycle that runs right before the mutation, returns a snapshot value, and forwards it to componentDidUpdate. It exists only on class components, and there is no built-in hook equivalent.

This library bridges that gap. Reach for it when you need to:

  • Keep visible content anchored when items are inserted above a scroll container (chat lists, feeds)
  • Preserve text selection or caret position across re-renders
  • Capture element dimensions for FLIP-style animations
  • Read any "before commit" DOM state and apply it after the commit

Install

pnpm add react-snapshot-bridge
# or
npm i react-snapshot-bridge
# or
yarn add react-snapshot-bridge

Peer dependency: react >= 16.3 (when getSnapshotBeforeUpdate was introduced).

Quick start

import { useRef, useState } from "react";
import { SnapshotBeforeUpdate } from "react-snapshot-bridge";

function ChatList() {
  const scrollerRef = useRef<HTMLDivElement>(null);
  const [messages, setMessages] = useState<string[]>(["hi"]);

  return (
    <>
      <button onClick={() => setMessages((m) => ["new message", ...m])}>
        Prepend
      </button>

      <SnapshotBeforeUpdate<number>
        capture={() => {
          const el = scrollerRef.current!;
          return el.scrollHeight - el.scrollTop;
        }}
        apply={(prevDistanceFromBottom) => {
          const el = scrollerRef.current!;
          el.scrollTop = el.scrollHeight - prevDistanceFromBottom;
        }}
      >
        <div ref={scrollerRef} style={{ height: 200, overflow: "auto" }}>
          {messages.map((msg, i) => (
            <div key={i}>{msg}</div>
          ))}
        </div>
      </SnapshotBeforeUpdate>
    </>
  );
}

Capturing multiple values

capture returns a single value, but that value can be any shape — including an object. This is the recommended way to forward multiple pieces of state in one commit, and it keeps the API surface minimal and type-safe.

The example below combines two ideas at once:

  1. Capture two DOM-derived values (distanceFromBottom, wasAtBottom) into one snapshot.
  2. Track the previous message count with a ref (synced at the end of apply), read the current messages in apply via closure, and read DOM in capture before the commit mutates it.
import { useRef } from "react";
import { SnapshotBeforeUpdate } from "react-snapshot-bridge";

type Snap = {
  distanceFromBottom: number;
  wasAtBottom: boolean;
  previousMessageCount: number;
};

function ChatList({ messages }: { messages: string[] }) {
  const scrollerRef = useRef<HTMLDivElement>(null);
  const prevMessageCountRef = useRef(messages.length);

  return (
    <SnapshotBeforeUpdate<Snap>
      capture={() => {
        const el = scrollerRef.current!;
        const distanceFromBottom =
          el.scrollHeight - el.scrollTop - el.clientHeight;
        return {
          distanceFromBottom,
          wasAtBottom: distanceFromBottom < 16,
          // Still the count from the end of the last `apply` (or initial render).
          previousMessageCount: prevMessageCountRef.current,
        };
      }}
      apply={({
        distanceFromBottom,
        wasAtBottom,
        previousMessageCount,
      }) => {
        const el = scrollerRef.current!;
        const grewAtTop = messages.length > previousMessageCount;

        if (wasAtBottom) {
          el.scrollTop = el.scrollHeight; // stick to bottom for new arrivals
        } else if (grewAtTop) {
          el.scrollTop = el.scrollHeight - distanceFromBottom; // anchor existing content
        }

        // Next `capture` reads this as the "previous" count for that commit.
        prevMessageCountRef.current = messages.length;
      }}
    >
      <div ref={scrollerRef} style={{ height: 200, overflow: "auto" }}>
        {messages.map((msg, i) => (
          <div key={i}>{msg}</div>
        ))}
      </div>
    </SnapshotBeforeUpdate>
  );
}

Live demo

Try it yourself: scroll the list down a bit, then click Prepend. Toggle the wrapper on and off to see the difference.

Scroll the list down a bit, then click Prepend. With the bridge enabled, the message you were reading stays visually anchored. Disable it to see the default behavior where the content jumps down.

  • Original message #1
  • Original message #2
  • Original message #3
  • Original message #4
  • Original message #5
  • Original message #6
  • Original message #7
  • Original message #8
  • Original message #9
  • Original message #10
  • Original message #11
  • Original message #12

API

<SnapshotBeforeUpdate<T> capture apply enabled?>{children?}</SnapshotBeforeUpdate>

PropTypeRequiredDescription
capture() => TyesCalled synchronously before DOM mutation. Return value is forwarded to apply.
apply(snapshot: T) => voidyesCalled synchronously after DOM mutation, before paint. Receives the value from capture.
enabledbooleannoDefaults to true. When false, both capture and apply are skipped for that commit. Missed updates are not replayed.
childrenReactNodenoOptional. Rendered as-is (wrapper pattern). Omit to render nothing (sibling pattern).

The generic T is inferred from capture's return type — you usually don't need to specify it explicitly.

Where to place it

SnapshotBeforeUpdate runs its lifecycle only when its own fiber updates. So if you want to observe the getSnapshotBeforeUpdate timing of a particular component, the bridge has to live inside that component's render output — that way the bridge re-renders alongside the component you care about.

Within the same component, both wrapping the DOM as children and placing the bridge as a sibling of the DOM you care about work correctly. What does not work reliably is putting the bridge as a sibling of another component you want to track: when that component updates on its own (without its parent re-rendering), the bridge will not fire.

The deciding factor is "which component re-renders alongside the bridge", not "wrapper vs sibling".

function ChatList({ messages }: { messages: string[] }) {
  return (
    <SnapshotBeforeUpdate capture={...} apply={...}>
      <div>{/* the DOM you actually care about */}</div>
    </SnapshotBeforeUpdate>
  );
}

Useful when wrapping is awkward (e.g. you already have multiple siblings inside a Fragment).

function ChatList({ messages }: { messages: string[] }) {
  return (
    <>
      <div>{/* the DOM you actually care about */}</div>
      <SnapshotBeforeUpdate capture={...} apply={...} />
    </>
  );
}
function Parent() {
  return (
    <>
      <ChatList messages={messages} />
      {/* Wrong: standalone updates inside ChatList may not be picked up */}
      <SnapshotBeforeUpdate capture={...} apply={...} />
    </>
  );
}

Caveats

  • No call on initial mount. Like the underlying class lifecycle, neither capture nor apply runs the first time the component appears — only on subsequent updates. getSnapshotBeforeUpdate is update-only, so you cannot use this bridge to snapshot the DOM immediately before the component's initial appearance on screen. When someone says "before first paint," clarify whether they mean the first mount paint (not covered by this lifecycle) or a later update commit (see next bullet).
  • Updates run before DOM mutation for that commit. After mount, whenever the bridge commits an update, capture runs in the before mutation phase: before the DOM reflects that commit's output and before the browser paints that update. That is the timing slot this library occupies (see How it works).
  • Requires a re-render in the right place. If a React.memo (or shouldComponentUpdate) ancestor short-circuits the render, or the bridge is placed as a sibling of a different component that updates on its own, neither callback will fire. See Where to place it.
  • enabled={false} does not buffer updates. Updates that occur while disabled are silently dropped. There is no "catch up" call when you re-enable the bridge — the next regular update is what triggers capture/apply again.
  • Concurrent rendering safe. React 18+ may discard render results, but the commit phase itself is synchronous, so capture/apply always run as a paired set.
  • SSR safe. Lifecycle methods don't run during renderToString, so the bridge is a no-op on the server.
  • React 19. Class components and getSnapshotBeforeUpdate are not deprecated in React 19. This library remains compatible.

On this page