How to Autosave Froala Content and Recover Drafts in React
Posted on By Mostafa Yousef | In Tutorials,
Table of contents
- Key Takeaways
- Prerequisites
- Why Autosave Matters for Rich Text Editors
- This Guide Goes Beyond Froala's Built-in Autosave Plugin
- Step 1. Set Up the Froala Editor Component
- Step 2. Use
contentChanged, Notkeyup - Step 3. Debounce Saves With a
useRefTimer - Step 4. Sync to Your API on an Interval, Not on Every Change
- Step 5. Add a Saving and Saved Status Indicator
- Step 6. Offer a Restore Draft Banner on Reload
- Step 7. Warn Before Unload With
beforeunload - When to Use
immediateReactModelUpdate - How to Test React Autosave and Draft Recovery
- Common React Autosave Pitfalls
- Froala React Autosave FAQ
- Conclusion
Losing an hour of writing because a tab crashed is one of the fastest ways to lose a user’s trust in your app. If your React app uses Froala Editor for rich text, you can fix this with a lightweight autosave system that writes drafts to localStorage instantly and syncs to your server every few seconds.
This guide walks you through building that system from scratch, including a “Saving / Saved” status indicator, a “Restore draft” banner on reload, and a warning that stops users from accidentally closing the tab with unsaved work.
Key Takeaways
- Use the
contentChangedevent, notkeyup, to trigger saves. It fires once per meaningful edit instead of on every keystroke. - Debounce your save logic with a
useReftimer so you don’t floodlocalStorageor your API during fast typing. - Save to
localStorageon every debounced change (cheap and instant), but only hit your API every few seconds (expensive and rate-limited). - Leave
immediateReactModelUpdateoff unless another part of your UI needs to read the editor’s content on every keystroke. - Add a
beforeunloadlistener to warn users before they lose unsaved changes. - This custom setup is different from Froala’s built-in autosave plugin (
saveInterval,saveURL). Use the built-in plugin for simple, timer-based server saves. Use this guide’s approach when you also want local draft recovery and change-based saving.
Prerequisites
Before you start, make sure you have:
- Froala Editor installed (
froala-editorandreact-froala-wysiwyg) - React 18 or later
- A basic backend endpoint (or a mock one) that can accept a
POSTrequest with draft content - Familiarity with React hooks (
useState,useEffect,useRef)
Install the packages if you haven’t already:
npm install froala-editor react-froala-wysiwyg Why Autosave Matters for Rich Text Editors
Rich text content is expensive to lose. Unlike a single form field, a Froala document might represent 20 minutes of formatting, image placement, and careful wording. Users don’t expect to lose that to a refresh, a closed laptop lid, or a flaky network connection.
An autosave system solves three problems at once:
- Local safety net.
localStoragewrites are synchronous and don’t depend on the network, so you get instant protection against tab crashes and accidental refreshes. - Server durability.
localStorageis scoped to one browser. If the user switches devices, they need their draft saved to your backend too. - User confidence. A visible “Saving…” or “Saved” indicator tells users their work is safe, so they stop hitting Ctrl+S out of habit.
This Guide Goes Beyond Froala’s Built-in Autosave Plugin
Froala ships its own autosave plugin with options like saveInterval, saveURL, and saveMethod. It’s worth knowing what that plugin covers before you write any custom code, so you can decide which approach fits your app.
The built-in plugin fires an HTTP request to your server on a fixed timer. This guide builds something different, for three reasons:
- A local safety net. The built-in plugin only talks to your server. If the request fails or the tab closes mid-cycle, there’s no local backup. This guide adds a
localStoragelayer that saves instantly and works offline. - A restore experience. The built-in plugin has no concept of “here’s a draft you can recover.” This guide adds a banner that lets users choose between a local draft and their last saved server version.
- Change-based saving instead of a fixed clock. The built-in plugin saves on a timer regardless of whether anything changed. This guide debounces saves based on actual edits, so you get faster local protection and fewer wasted server requests.
If you only need periodic server saves with no local fallback or recovery UI, the built-in plugin is fewer lines of code and worth using instead. The rest of this guide is for cases where you want the fuller setup: instant local saves, controlled server syncs, and a way for users to recover unsaved work.
Step 1. Set Up the Froala Editor Component
Start with a standard Froala setup in a React functional component. Keep the editor’s config minimal at first, you’ll add autosave logic on top of it.
import React, { useState, useRef, useEffect, useCallback } from 'react';
import FroalaEditor from 'react-froala-wysiwyg';
import 'froala-editor/css/froala_style.min.css';
import 'froala-editor/css/froala_editor.pkgd.min.css';
const DRAFT_KEY = 'froala-draft-content';
const SAVE_INTERVAL_MS = 5000; // sync to API every 5 seconds
const DEBOUNCE_MS = 800; // wait this long after typing stops
function AutosaveEditor() {
const [content, setContent] = useState('');
const [saveStatus, setSaveStatus] = useState('idle'); // idle | saving | saved | error
const [showRestoreBanner, setShowRestoreBanner] = useState(false);
return (
<div className="editor-wrapper">
<FroalaEditor
model={content}
onModelChange={setContent}
config={{ placeholderText: 'Start writing your draft...' }}
/>
</div>
);
}
export default AutosaveEditor; This gives you a working editor with two-way binding. Now you’ll layer autosave on top without touching this core setup.
Step 2. Use contentChanged, Not keyup
It’s tempting to hook into keyup because it feels like the most direct signal that “something happened.” Don’t. keyup fires on every single keystroke, including arrow keys, backspace, and shortcut combinations that don’t even change the content. That’s a lot of noise for something that’s supposed to trigger a save.
contentChanged is Froala’s dedicated event for actual content mutations. It fires after the internal editor model updates, so you know the HTML genuinely changed. This is also the event Froala’s own React integration uses internally to keep the React model in sync, so you’re working with the grain of the library rather than against it.
Here’s how you register it through the events config:
const editorConfig = {
placeholderText: 'Start writing your draft...',
events: {
contentChanged: function () {
// 'this' refers to the Froala editor instance here
handleContentChanged(this.html.get());
},
},
}; Why this matters: if you based autosave on keyup, a user who pastes an image or uses the toolbar to bold a selection (no keyboard involved) wouldn’t trigger a save at all. contentChanged catches all of these cases because it listens for actual document mutations, not input device events.
Step 3. Debounce Saves With a useRef Timer
If you save on every contentChanged event without debouncing, you’ll write to localStorage and call your API dozens of times a minute during active typing. That’s wasteful and, for the API call, can trip rate limits or create a backlog of requests.
The fix is a debounce pattern using useRef to hold the timer ID. A ref is the right tool here because you need a mutable value that persists across renders without triggering a re-render itself, which is exactly what a timer ID needs.
function AutosaveEditor() {
const [content, setContent] = useState('');
const [saveStatus, setSaveStatus] = useState('idle');
const [showRestoreBanner, setShowRestoreBanner] = useState(false);
// Holds the debounce timer so it survives across re-renders
const debounceTimerRef = useRef(null);
// Holds the interval timer for periodic API syncs
const apiSyncIntervalRef = useRef(null);
// Tracks whether there's unsaved content since the last API sync
const hasUnsavedChangesRef = useRef(false);
const saveToLocalStorage = useCallback((html) => {
try {
localStorage.setItem(DRAFT_KEY, html);
} catch (err) {
// localStorage can throw if storage is full or disabled
console.error('Failed to save draft locally:', err);
}
}, []);
const saveToApi = useCallback(async (html) => {
setSaveStatus('saving');
try {
await fetch('/api/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: html }),
});
setSaveStatus('saved');
hasUnsavedChangesRef.current = false;
} catch (err) {
console.error('Failed to save draft to API:', err);
setSaveStatus('error');
}
}, []);
const handleContentChanged = useCallback((html) => {
// Clear any pending debounce so rapid typing doesn't stack up saves
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
// localStorage writes are cheap, so this happens on every debounced change
saveToLocalStorage(html);
hasUnsavedChangesRef.current = true;
}, DEBOUNCE_MS);
}, [saveToLocalStorage]);
// ...editor config and JSX go below
} Notice the two different debounce strategies happening here. The localStorage write happens on a short debounce (800ms) because it’s fast and local. The API sync happens on a separate, longer interval, which you’ll wire up next.
Step 4. Sync to Your API on an Interval, Not on Every Change
Debouncing alone isn’t quite enough for the API call. Even with an 800ms debounce, a user who types continuously for two minutes would still fire a save every time they pause, which can still add up to more requests than your backend needs to handle.
Instead, use a separate setInterval that checks a flag and only sends a request if there’s actually unsaved content:
useEffect(() => {
apiSyncIntervalRef.current = setInterval(() => {
if (hasUnsavedChangesRef.current) {
saveToApi(content);
}
}, SAVE_INTERVAL_MS);
return () => {
clearInterval(apiSyncIntervalRef.current);
};
}, [content, saveToApi]); This gives you a clean separation of responsibilities. localStorage acts as your instant, per-change safety net. The API call acts as your durable, every-few-seconds sync. Neither one blocks the other, and the user’s typing never stalls waiting on a network request.
Step 5. Add a Saving and Saved Status Indicator
A status indicator closes the loop for the user. Without it, autosave is invisible, and invisible safety nets don’t build trust. Keep this simple with a small conditional render based on the saveStatus state you’re already tracking.
function SaveIndicator({ status }) {
const statusConfig = {
idle: { text: '', color: '#999' },
saving: { text: 'Saving...', color: '#f0ad4e' },
saved: { text: 'Saved', color: '#5cb85c' },
error: { text: 'Save failed, retrying...', color: '#d9534f' },
};
const current = statusConfig[status] || statusConfig.idle;
if (!current.text) return null;
return (
<span style={{ color: current.color, fontSize: '13px', marginLeft: '8px' }}>
{current.text}
</span>
);
} Drop it right next to your editor’s toolbar area:
<div className="editor-header">
<h3>Draft</h3>
<SaveIndicator status={saveStatus} />
</div> This small piece of UI does a lot of work. Users glance at it, see “Saved,” and move on with confidence.
Step 6. Offer a Restore Draft Banner on Reload
Now handle the recovery side. When the component mounts, check localStorage for an existing draft and, if one exists and differs from what the server has, show a banner offering to restore it.
useEffect(() => {
const savedDraft = localStorage.getItem(DRAFT_KEY);
if (savedDraft && savedDraft.trim().length > 0) {
setShowRestoreBanner(true);
}
}, []);
const handleRestoreDraft = () => {
const savedDraft = localStorage.getItem(DRAFT_KEY);
if (savedDraft) {
setContent(savedDraft);
}
setShowRestoreBanner(false);
};
const handleDiscardDraft = () => {
localStorage.removeItem(DRAFT_KEY);
setShowRestoreBanner(false);
}; And the banner itself:
{showRestoreBanner && (
<div className="restore-banner">
<span>We found an unsaved draft from your last session.</span>
<button onClick={handleRestoreDraft}>Restore draft</button>
<button onClick={handleDiscardDraft}>Discard</button>
</div>
)} This pattern gives users control instead of silently overwriting whatever they last saved to the server. Some users will want their last-known-good server copy, others will want the more recent local draft. Let them choose.
Step 7. Warn Before Unload With beforeunload
Even with autosave running, there’s a small window between the last successful save and the current unsaved keystrokes. Cover that gap by warning the user if they try to close the tab while hasUnsavedChangesRef.current is true.
useEffect(() => {
const handleBeforeUnload = (event) => {
if (hasUnsavedChangesRef.current) {
event.preventDefault();
// Most modern browsers show a generic message regardless of this string
event.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []); Modern browsers ignore custom text in this dialog for security reasons, but calling event.preventDefault() and setting event.returnValue is still what triggers the browser’s built-in warning dialog. This is a small addition that catches the rare case where a user closes the tab in the few seconds before your next scheduled API sync.
When to Use immediateReactModelUpdate
Froala’s React wrapper ships an option called immediateReactModelUpdate. When set to true, it updates your React model on every keyup event instead of waiting for contentChanged.
When it helps: if another part of your UI needs to reflect the editor’s content in real time, like a live word counter that updates mid-keystroke, or a preview pane that mirrors the editor exactly as the user types, this option keeps your React state perfectly in sync with the DOM.
When it costs you: for autosave specifically, you don’t need this. Autosave only cares about the settled state of the content after a pause, not every intermediate keystroke. Turning this on means React re-renders on every key release, which adds unnecessary overhead, especially in documents with a lot of content or complex formatting. Leave it at its default of false and let contentChanged plus your debounce handle everything.
const editorConfig = {
immediateReactModelUpdate: false, // Keep this off for autosave-only use cases
events: {
contentChanged: function () {
handleContentChanged(this.html.get());
},
},
};
Full Code For The React Component
import React, { useState, useRef, useEffect, useCallback } from 'react';
import FroalaEditor from 'react-froala-wysiwyg';
import 'froala-editor/css/froala_style.min.css';
import 'froala-editor/css/froala_editor.pkgd.min.css';
const DRAFT_KEY = 'froala-draft-content';
const SAVE_INTERVAL_MS = 30000; // sync to API every 30 seconds
const DEBOUNCE_MS = 800; // wait this long after typing stops before saving locally
function SaveIndicator({ status }) {
const statusConfig = {
idle: { text: '', color: '#999' },
saving: { text: 'Saving...', color: '#f0ad4e' },
saved: { text: 'Saved', color: '#5cb85c' },
error: { text: 'Save failed, retrying...', color: '#d9534f' },
};
const current = statusConfig[status] || statusConfig.idle;
if (!current.text) return null;
return (
<span style={{ color: current.color, fontSize: '13px', marginLeft: '8px' }}>
{current.text}
</span>
);
}
function AutosaveEditor() {
const [content, setContent] = useState('');
const [saveStatus, setSaveStatus] = useState('idle'); // idle | saving | saved | error
const [showRestoreBanner, setShowRestoreBanner] = useState(false);
// Holds the debounce timer so it survives across re-renders without causing them
const debounceTimerRef = useRef(null);
// Holds the interval timer for periodic API syncs
const apiSyncIntervalRef = useRef(null);
// Tracks whether there's unsaved content since the last successful API sync
const hasUnsavedChangesRef = useRef(false);
// Mirrors the latest content so the interval callback always sees the current value
const latestContentRef = useRef('');
// --- Save to localStorage (fast, local, synchronous) ---
const saveToLocalStorage = useCallback((html) => {
try {
localStorage.setItem(DRAFT_KEY, html);
} catch (err) {
// localStorage can throw if storage is full or disabled (e.g. private browsing)
console.error('Failed to save draft locally:', err);
}
}, []);
// --- Save to your backend API ---
const saveToApi = useCallback(async (html) => {
setSaveStatus('saving');
try {
await fetch('/api/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: html }),
});
setSaveStatus('saved');
hasUnsavedChangesRef.current = false;
} catch (err) {
console.error('Failed to save draft to API:', err);
setSaveStatus('error');
}
}, []);
// --- Debounced handler wired to the editor's contentChanged event ---
const handleContentChanged = useCallback(
(html) => {
latestContentRef.current = html;
// Clear any pending debounce so rapid typing doesn't stack up saves
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
saveToLocalStorage(html);
hasUnsavedChangesRef.current = true;
}, DEBOUNCE_MS);
},
[saveToLocalStorage]
);
// --- Periodic API sync, only fires a request if something actually changed ---
useEffect(() => {
apiSyncIntervalRef.current = setInterval(() => {
if (hasUnsavedChangesRef.current) {
saveToApi(latestContentRef.current);
}
}, SAVE_INTERVAL_MS);
return () => {
clearInterval(apiSyncIntervalRef.current);
};
}, [saveToApi]);
// --- Clean up the debounce timer on unmount to avoid a stray setTimeout ---
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// --- Check for a recoverable draft on mount ---
useEffect(() => {
const savedDraft = localStorage.getItem(DRAFT_KEY);
if (savedDraft && savedDraft.trim().length > 0) {
setShowRestoreBanner(true);
}
}, []);
const handleRestoreDraft = () => {
const savedDraft = localStorage.getItem(DRAFT_KEY);
if (savedDraft) {
setContent(savedDraft);
latestContentRef.current = savedDraft;
}
setShowRestoreBanner(false);
};
const handleDiscardDraft = () => {
localStorage.removeItem(DRAFT_KEY);
setShowRestoreBanner(false);
};
// --- Warn before the tab closes if there's unsaved work ---
useEffect(() => {
const handleBeforeUnload = (event) => {
if (hasUnsavedChangesRef.current) {
event.preventDefault();
// Most modern browsers show a generic message regardless of this string
event.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);
const editorConfig = {
placeholderText: 'Start writing your draft...',
// Keep this off for autosave-only use cases. Turning it on updates the React
// model on every keyup, which costs a re-render per keystroke you don't need
// here since contentChanged plus the debounce already covers autosave timing.
immediateReactModelUpdate: false,
events: {
contentChanged: function () {
// 'this' refers to the Froala editor instance here
handleContentChanged(this.html.get());
},
},
};
return (
<div className="editor-wrapper">
{showRestoreBanner && (
<div className="restore-banner">
<span>We found an unsaved draft from your last session.</span>
<button onClick={handleRestoreDraft}>Restore draft</button>
<button onClick={handleDiscardDraft}>Discard</button>
</div>
)}
<div className="editor-header">
<h3>Draft</h3>
<SaveIndicator status={saveStatus} />
</div>
<FroalaEditor
model={content}
onModelChange={setContent}
config={editorConfig}
/>
</div>
);
}
export default AutosaveEditor;
How to Test React Autosave and Draft Recovery
Autosave logic is easy to get subtly wrong, since most of it happens in the background with no visible feedback beyond the status indicator. Work through these checks manually before you ship it.
1. Confirm contentChanged is firing, and keyup isn’t needed. Open your browser’s DevTools console and add a temporary log inside handleContentChanged. Type a few words, then use the toolbar to bold a selection or paste an image with no keyboard involved. Both should log. If the toolbar or paste actions don’t trigger a save, you’ve likely wired the listener to keyup instead of contentChanged.
const handleContentChanged = useCallback((html) => {
console.log('contentChanged fired', html.length, 'chars'); // remove before shipping
// ...debounce logic
}, []); 2. Verify the debounce is actually debouncing. Type continuously for five seconds without pausing. Check localStorage in DevTools (Application tab -> Local Storage) while typing. The froala-draft-content key should not update on every keystroke, it should only update roughly 800ms after you stop typing. If you see it updating constantly, the clearTimeout call is likely missing or not clearing the right ref.
3. Confirm the API syncs on interval, not on every change. Open the Network tab, filter for your /api/drafts endpoint, and type continuously for 20-30 seconds. You should see requests roughly every 5 seconds, not one per keystroke and not one per debounced local save. If requests fire more often than SAVE_INTERVAL_MS, check that hasUnsavedChangesRef is being read inside the interval callback rather than recreating the interval on every render.
4. Test the “no unnecessary requests” behavior. Let the editor sit idle for 10+ seconds after your last edit and watch the Network tab. No new requests should appear, since hasUnsavedChangesRef.current should be false after a successful save. If requests keep firing on an empty editor, the flag isn’t being reset in saveToApi.
5. Test draft recovery end to end. Type some content, wait for the local save to fire (check localStorage for the updated value), then refresh the page without submitting or navigating away cleanly. The restore banner should appear. Click “Restore draft” and confirm the editor repopulates with your content. Then repeat the test and click “Discard” instead, confirming the banner disappears and the localStorage key is cleared.
6. Test the offline / failed-save path. Open DevTools, go to the Network tab, and set throttling to “Offline.” Type some content and wait past your SAVE_INTERVAL_MS. The indicator should show your error state. Go back online and wait for the next interval tick, the indicator should flip to “Saved” without you needing to type anything else, since hasUnsavedChangesRef stays true until a save actually succeeds.
7. Test the beforeunload warning. Type content, don’t wait for it to save, and try closing the tab or navigating away immediately. Your browser should show its built-in “leave site” confirmation dialog. Then let content fully save (indicator shows “Saved”), and try closing again, this time there should be no warning.
8. Sanity-check immediateReactModelUpdate performance if you ever turn it on. If you’re experimenting with immediateReactModelUpdate: true for another feature, open the React DevTools Profiler, start recording, and type a long sentence. Compare the render count against the same test with it set to false. You should see noticeably more renders when it’s on, which confirms the performance tradeoff described earlier in this guide is real and not just theoretical.
Common React Autosave Pitfalls
- Relying on
keyupfor save triggers. It fires on non-content-changing keys and misses toolbar-driven changes like pasted images or applied formatting. - Skipping the debounce. Without it, you’ll flood
localStorageand your API with redundant writes during normal typing. - Forgetting to clear the debounce timer on unmount. Leaving a
setTimeoutrunning after the component unmounts can throw errors or leak memory. Always clean up in youruseEffectreturn function. - Not handling
localStoragewrite failures. Storage can be full or disabled in private browsing. Wrap writes in a try/catch so a storage failure doesn’t crash your save flow. - Overwriting the server draft without asking. Always let the user choose between the local draft and the server version instead of picking one automatically.
- Turning on
immediateReactModelUpdate“just in case.” It’s rarely needed for autosave and adds performance cost you don’t need to pay.
Froala React Autosave FAQ
Does contentChanged fire for programmatic changes, like setContent() calls?
It can, depending on how the content is set. If you’re setting content through editor.html.set(), test this in your specific version, since behavior can vary. Setting the React model directly through model typically won’t re-trigger contentChanged on the editor instance itself.
How often should I sync to my API?
Every 5 to 10 seconds is a reasonable starting point for most text-heavy apps. Adjust based on your backend’s tolerance for request volume and how critical near-real-time durability is for your use case.
What if the user is offline when autosave tries to hit the API?
The fetch call will fail, and your catch block sets saveStatus to 'error'. Because hasUnsavedChangesRef.current stays true, the next interval tick will retry automatically once the connection returns.
Should I debounce the localStorage write too, or save on every keystroke?
Debouncing it, even with a short delay like 800ms, is still worth it. localStorage writes are synchronous and can block the main thread briefly on large documents, so avoiding a write on every single keystroke keeps typing feeling smooth.
Conclusion
You now have a complete autosave and draft recovery system for Froala Editor in React. It saves locally in near real time, syncs to your server on a controlled interval, shows users a clear save status, and gives them the choice to restore or discard a recovered draft. None of this required any exotic dependencies, just Froala’s contentChanged event, a couple of useRef timers, and standard browser APIs.
For more on Froala’s event system and React-specific configuration options, check the official Froala React documentation.
Try wiring this into your own project and see how it feels. If you want to go further, the same debounce pattern works well for syncing to collaborative editing backends or version history systems built on top of Froala.
- Whats on this page hide
No comment yet, add your voice below!