Get Started for FREE

How to Autosave Froala Content and Recover Drafts in React

Froala React autosave

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 contentChanged event, not keyup, to trigger saves. It fires once per meaningful edit instead of on every keystroke.
  • Debounce your save logic with a useRef timer so you don’t flood localStorage or your API during fast typing.
  • Save to localStorage on every debounced change (cheap and instant), but only hit your API every few seconds (expensive and rate-limited).
  • Leave immediateReactModelUpdate off unless another part of your UI needs to read the editor’s content on every keystroke.
  • Add a beforeunload listener 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-editor and react-froala-wysiwyg)
  • React 18 or later
  • A basic backend endpoint (or a mock one) that can accept a POST request 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:

  1. Local safety net. localStorage writes are synchronous and don’t depend on the network, so you get instant protection against tab crashes and accidental refreshes.
  2. Server durability. localStorage is scoped to one browser. If the user switches devices, they need their draft saved to your backend too.
  3. 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 localStorage layer 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 keyup for 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 localStorage and your API with redundant writes during normal typing.
  • Forgetting to clear the debounce timer on unmount. Leaving a setTimeout running after the component unmounts can throw errors or leak memory. Always clean up in your useEffect return function.
  • Not handling localStorage write 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.

graphical user interface, text

Posted on August 14, 2026

Mostafa Yousef

Senior web developer with a profound knowledge of the Javascript and PHP ecosystem. Familiar with several JS tools, frameworks, and libraries. Experienced in developing interactive websites and applications.

No comment yet, add your voice below!


Add a Comment

Your email address will not be published. Required fields are marked *