Skip to content

<Builder> throws uncaught "Bee is not started" under React StrictMode (instance reconstructed on remount; loadConfig runs before start() resolves) #15

Description

@ethantran

Summary

When an app is wrapped in <React.StrictMode>, mounting <Builder> — or navigating between pages that mount/unmount it — intermittently throws an uncaught Error: Bee is not started. It propagates out of a passive effect to the nearest error boundary and replaces the editor with an error screen. Since StrictMode is the default in Next.js (reactStrictMode: true) and Create React App, and is recommended by the React team, <Builder> is effectively unusable out-of-the-box in most modern React apps unless StrictMode is disabled app-wide.

Versions

  • @beefree.io/react-email-builder: 1.0.3
  • @beefree.io/sdk: 11.2.1
  • react / react-dom: 19.x (also reproduces on 18.x)

Reproduction

  1. Use this repo's own example/.
  2. Wrap the app in StrictMode — e.g. in example/index.tsx: root.render(<React.StrictMode><App /></React.StrictMode>) (or wrap the builder in example/App.tsx).
  3. Run the example, open the builder, and navigate between pages so the Builder mounts/unmounts a few times.
  4. The editor intermittently fails with an uncaught Error: Bee is not started, caught by the surrounding error boundary.

It's a timing-dependent race, so it may take a few mounts to surface. In Next.js dev with reactStrictMode: true it reproduces on essentially every mount.

Stack trace (abridged)

Error: Bee is not started
    at Bee.executeAction        (@beefree.io/sdk)
    at Bee.loadConfig           (@beefree.io/sdk)
    at <loadConfig effect>      (react-email-builder/src/Builder.tsx)
    at commitHookEffectListMount (react-dom)   // thrown from a passive effect

Root causesrc/Builder.tsx (v1.0.3)

The SDK instance is constructed and started during render, and the "started" flag is flipped from an async .then():

// Builder.tsx:77-113
if (instanceRef.current === null && token) {
  ...
  instanceRef.current = new BeefreeSDK(token, {...})
  ...
  void beeInstance.start(config, template, bucketDir, { shared }).then(() => {
    setEditorReady(true)   // started flag flipped asynchronously
  })
}

The unmount cleanup nulls the ref and resets the flag (no SDK dispose(), per the comment):

// Builder.tsx:117-131
useEffect(() => {
  return () => {
    const el = document.getElementById(container)
    if (el) el.innerHTML = ''
    instanceRef.current = null
    setEditorReady(false)
  }
}, [container])

And loadConfig runs whenever editorReady flips true:

// Builder.tsx:43-53
useEffect(() => {
  if (editorReady) {
    instanceRef.current?.loadConfig?.(callbacks).catch(...)
  }
}, [callbacks, container, editorReady])

Under StrictMode's mount → cleanup → remount cycle:

  1. Render constructs Bee A and calls start() (async, unresolved). editorReady === false.
  2. StrictMode runs the cleanup: instanceRef.current = null, setEditorReady(false).
  3. StrictMode re-runs effects but does not re-run render, so instanceRef.current stays null.
  4. Bee A's start() resolves → setEditorReady(true) → re-render.
  5. The re-render sees instanceRef.current === null and constructs a second instance, Bee B, calling start() again (still unresolved).
  6. The loadConfig effect fires because editorReady changed false → true, calling loadConfig(callbacks) on Bee B, whose underlying SDK instance is still nullexecuteAction throws "Bee is not started".

Two things make it fatal rather than recoverable:

  • start().then(() => setEditorReady(true)) has no guard that this instance is still the current one (no disposed/generation check), so a stale instance's resolution flips the flag for a different, not-yet-started instance.
  • loadConfig() throws synchronously (via executeAction) when the instance isn't started, so the .catch() in the effect never attaches — the error escapes the effect and hits the error boundary.

Suggested fixes (any one largely resolves it; 1 + 2 together is robust)

  1. Construct/start the SDK instance inside a useEffect (not during render) and cancel on cleanup with a disposed/generation flag, so a superseded start() resolution can't flip editorReady for a stale instance.
  2. In the loadConfig effect, guard on the instance actually being started (not just editorReady), and/or wrap the call in try/catch since executeAction can throw synchronously.
  3. Consider exposing a real teardown/destroy() so consumers don't have to clear innerHTML to dispose.

Impact

StrictMode ships on by default in create-react-app and Next.js, and is recommended by the React team. As written, <Builder> requires disabling StrictMode app-wide to be reliable — which removes a valuable dev-time safety net. (For reference, a consumer can work around it today by bypassing the wrapper and driving @beefree.io/sdk directly from an effect with per-mount container teardown — but the wrapper should handle this out of the box.)

App.tsx

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions