V3 React apps
React works in V3, but it needs more thought than Vue because JSX cannot run without a transpiler. The browser will throw SyntaxError: Unexpected token '<' the first time it sees JSX in an uploaded source file. The V3 runtime does not transpile.
Loading the framework
Add react and react-dom via add_dependencies with lazy: true, then:
await Fliplet.require.lazy('react');
await Fliplet.require.lazy('react-dom');
const React = window.React;
const ReactDOM = window.ReactDOM;
Fliplet.require.lazy(name) resolves once the UMD bundle has executed; the module itself lands on window (window.React, window.ReactDOM, window.htm, window.ReactRouterDOM). Read it off window after the await — assigning the awaited value directly gives you the URL string, not the module.
For routing, react-router-dom’s UMD bundle is not self-contained — it delegates its named exports (Navigate, Outlet, useNavigate, useLocation, Link, createBrowserRouter, …) to two peer packages that must be loaded first as globals:
await Fliplet.require.lazy('@remix-run/router'); // sets window.RemixRouter
await Fliplet.require.lazy('react-router'); // sets window.ReactRouter, needs RemixRouter
await Fliplet.require.lazy('react-router-dom'); // sets window.ReactRouterDOM, needs the two above
const ReactRouterDOM = window.ReactRouterDOM;
Add all three via add_dependencies with lazy: true and match the minor versions across them (e.g. [email protected] + [email protected] + @remix-run/[email protected]). Skipping react-router or @remix-run/router leaves ReactRouterDOM.Navigate etc. as throwing getters that fail at first render.
Adding them via add_dependencies is not enough — listing a package as a lazy dep only registers the URL. The boot script must also await Fliplet.require.lazy(name) on all three (in the order above) before accessing any ReactRouterDOM.* export, or you’ll hit ReactRouterDOM.createBrowserRouter is not a function.
Features that need a build step
The big one:
JSX
JSX is not valid JavaScript. Three ways to write React in V3 without a transpiler:
| Option | Cost | When to use |
|---|---|---|
htm tagged templates |
~1KB | Recommended. Looks nearly identical to JSX; runs in the browser. Load via Fliplet.require.lazy('htm'). |
React.createElement |
0 | Verbose but dependency-free. Fine for small apps or one-off components. |
@babel/standalone |
~2MB, slow first boot | Only if the user explicitly insists on raw JSX. Add via add_dependencies with the CDN URL and lazy-load. Adds 1–2 seconds to app boot. |
Prefer htm unless the user has a specific reason to override.
Others
| Feature | Why it fails | Do this instead |
|---|---|---|
.tsx, .ts |
No TypeScript transpiler | Plain JavaScript |
Bare ESM imports (import React from 'react') |
No bundler resolves the specifier | Fliplet.require.lazy('react') |
CSS Modules (styles.module.css) |
No bundler to process them | Inline <style> or a plain .css uploaded as a media file |
import './styles.css' |
Same | Upload the CSS as a media file and inject a <link> with the authenticated URL |
Wiring to Fliplet.Router
Full contract in V3 routing. React-specific: pass basename when creating the router:
const router = createBrowserRouter(routes, {
basename: Fliplet.Router.getBasePath()
});
HashRouter is rejected by the boot-HTML lint (rule hash-router-react). Build routes from Fliplet.Router.getRouteManifest(); route loaders should call Fliplet.Router.resolveRoute(path).
Binding Fliplet.Media.authenticate
Authenticated URLs are async. Resolve them in useEffect and store in state:
const [logoSrc, setLogoSrc] = useState('');
useEffect(() => {
Fliplet.Media.authenticate(rawUrl).then(setLogoSrc);
}, [rawUrl]);
Then <img src={logoSrc} />. Calling Fliplet.Media.authenticate at module scope gives you a Promise, not a URL — the src will render empty.
Common errors
Symptom in get_preview_logs('errors') |
Cause | Fix |
|---|---|---|
SyntaxError: Unexpected token '<' |
Raw JSX in an uploaded source file | Switch to htm tagged templates or React.createElement |
Cannot use import statement outside a module |
Bare ESM import |
Use Fliplet.require.lazy |
ReferenceError: React is not defined inside a component |
Component file ran before react resolved |
Load dependencies in the boot HTML and pass React into component factories, or use Fliplet.require.lazy inside the component |
TypeError: useEffect is not a function |
React and react-dom versions mismatched | Pin matching versions in add_dependencies |
TypeError: React.createElement is not a function / htm.bind is not a function |
Assigned the await result to React/htm directly — that value is the URL string |
await Fliplet.require.lazy('react'); const React = window.React; (same for htm, ReactDOM, ReactRouterDOM) |
Cannot read properties of undefined (reading 'Navigate'\|'Outlet'\|'useNavigate'\|…) thrown from inside react-router-dom.*.min.js |
react-router-dom UMD loaded without its peer UMDs — named exports are getters that forward to window.ReactRouter / window.RemixRouter |
Also load @remix-run/router and react-router (matching minor version) before react-router-dom. See Loading the framework. |
DO / DON’T
- DO use
htmtagged templates as the default JSX alternative. - DO pass
basename: Fliplet.Router.getBasePath()tocreateBrowserRouter. - DO resolve
Fliplet.Media.authenticateinsideuseEffectand store in state. - DON’T ship raw JSX — it will always throw on first deploy.
- DON’T use
HashRouter— rejected by lint. - DON’T use
.tsxor TypeScript — no transpiler available. - DON’T use CSS Modules or
import './styles.css'— no bundler. - DON’T render with
innerHTML,outerHTML,insertAdjacentHTML, orelement.append(htmlString)inside screen files. If you wroteel.innerHTML = ...in a component, you wrote a templating engine — not React — and you introduced an XSS surface on every future edit. - DON’T write a manual
escapeHtml()helper. Needing one means you’re usinginnerHTMLsomewhere — fix that instead. React andhtmescape interpolated values by default. - DON’T
if-chain orswitchonlocation.pathnameto decide what to render, even withreact-router-dominstalled. That’s what the router you just installed is for. (The boot-HTML lint flags this viaruleId: path-dispatcher.) - DON’T use raw
<a href="/path">for in-app navigation — it triggers a full page reload and defeats the SPA. Use<Link>oruseNavigate()fromreact-router-dom. - DON’T reach into screens from the boot file via
document.getElementById/querySelector. Each screen owns its own state, fetches, and handlers; the boot owns routing and framework bootstrap only. If you’re wiring screens from the outside, you’ve inverted the component model.