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
- Use this repo's own
example/.
- 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).
- Run the example, open the builder, and navigate between pages so the
Builder mounts/unmounts a few times.
- 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 cause — src/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:
- Render constructs Bee A and calls
start() (async, unresolved). editorReady === false.
- StrictMode runs the cleanup:
instanceRef.current = null, setEditorReady(false).
- StrictMode re-runs effects but does not re-run render, so
instanceRef.current stays null.
- Bee A's
start() resolves → setEditorReady(true) → re-render.
- The re-render sees
instanceRef.current === null and constructs a second instance, Bee B, calling start() again (still unresolved).
- The
loadConfig effect fires because editorReady changed false → true, calling loadConfig(callbacks) on Bee B, whose underlying SDK instance is still null → executeAction 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)
- 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.
- 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.
- 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
Summary
When an app is wrapped in
<React.StrictMode>, mounting<Builder>— or navigating between pages that mount/unmount it — intermittently throws an uncaughtError: 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.1react/react-dom:19.x(also reproduces on 18.x)Reproduction
example/.example/index.tsx:root.render(<React.StrictMode><App /></React.StrictMode>)(or wrap the builder inexample/App.tsx).Buildermounts/unmounts a few times.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: trueit reproduces on essentially every mount.Stack trace (abridged)
Root cause —
src/Builder.tsx(v1.0.3)The SDK instance is constructed and started during render, and the "started" flag is flipped from an async
.then():The unmount cleanup nulls the ref and resets the flag (no SDK
dispose(), per the comment):And
loadConfigruns whenevereditorReadyflips true:Under StrictMode's mount → cleanup → remount cycle:
start()(async, unresolved).editorReady === false.instanceRef.current = null,setEditorReady(false).instanceRef.currentstaysnull.start()resolves →setEditorReady(true)→ re-render.instanceRef.current === nulland constructs a second instance, Bee B, callingstart()again (still unresolved).loadConfigeffect fires becauseeditorReadychangedfalse → true, callingloadConfig(callbacks)on Bee B, whose underlying SDK instance is stillnull→executeActionthrows"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 (nodisposed/generation check), so a stale instance's resolution flips the flag for a different, not-yet-started instance.loadConfig()throws synchronously (viaexecuteAction) 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)
useEffect(not during render) and cancel on cleanup with adisposed/generation flag, so a supersededstart()resolution can't flipeditorReadyfor a stale instance.loadConfigeffect, guard on the instance actually being started (not justeditorReady), and/or wrap the call intry/catchsinceexecuteActioncan throw synchronously.destroy()so consumers don't have to clearinnerHTMLto dispose.Impact
StrictMode ships on by default in
create-react-appand 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/sdkdirectly from an effect with per-mount container teardown — but the wrapper should handle this out of the box.)App.tsx