Blockleaf

Autosave

A dirty indicator that reacts to the keystroke and a write that does not — the two change callbacks doing their separate jobs.

Type in this one and watch the counters. Every transaction moves the first number; the second only moves when a write actually happens.

Saved — 0 transactions, 0 writes

'use client';

import { useCallback, useRef, useState } from 'react';
import { BlockleafEditor, type BlockleafDocument } from '@blockleaf/editor';

type SaveState = 'saved' | 'dirty' | 'saving';

export const Article = ({ initial }: { initial: BlockleafDocument }) => {
	const [state, setState] = useState<SaveState>('saved');
	const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

	const onChangeImmediate = useCallback(() => {
		setState((current) => (current === 'saving' ? current : 'dirty'));
	}, []);

	const onChange = useCallback(async (doc: BlockleafDocument) => {
		setState('saving');
		await fetch('/api/article', {
			method: 'PUT',
			body: JSON.stringify(doc)
		});
		setState('saved');
	}, []);

	return (
		<>
			<p aria-live="polite">{state}</p>
			<BlockleafEditor
				defaultValue={initial}
				onChange={onChange}
				onChangeImmediate={onChangeImmediate}
			/>
		</>
	);
};

Why two callbacks

onChange is debounced — 300 ms by default. onChangeImmediate fires on every transaction. Using one for the other's job is the most common mistake with this editor:

FiresUse it for
onChangeImmediateEvery transactionDirty flags, word counts, live previews
onChange300 ms after the last oneWriting to your database

Saving from onChangeImmediate is a network request per character. Driving a preview from onChange is a preview that lags a third of a second behind the caret.

debounceMs moves the line. Two seconds is reasonable for a long document on a slow connection; do not go below about 150 ms or you are back to writing on the keystroke.

Announce it politely

aria-live="polite" on the status element means a screen reader hears "saving", then "saved", at a natural pause rather than interrupting the sentence being typed. Without it the status changes silently and the only people who know the document is safe are the ones who can see the corner of the screen.

Unsaved changes on navigation

The dirty flag is also what tells you it is unsafe to leave:

useEffect(() => {
	if (state === 'saved') return;

	const warn = (event: BeforeUnloadEvent) => event.preventDefault();
	window.addEventListener('beforeunload', warn);
	return () => window.removeEventListener('beforeunload', warn);
}, [state]);

What arrives

Both callbacks hand you a BlockleafDocument with attrs.schemaVersion already stamped. There is nothing to serialise and nothing to strip before it goes into a jsonb column — see the document.

On this page