How it works
bevy-react uses a bridge architecture, like early versions of React Native,
with Bevy's ECS in place of native views. React runs in an embedded
JavaScript engine and only describes the UI. A custom reconciler turns each
React commit into a batch of small operations, and the Bevy side applies them
to ordinary ECS entities — bevy_ui nodes for every built-in element. Layout,
input, picking and rendering are plain Bevy; nothing is emulated.
The two sides
- The JS side is your React app plus bevy-react's reconciler, which
stands in for react-dom. In a native build it runs in a V8 isolate
(through
deno_core) on a dedicated thread, off the game loop: there is no Node and no browser. In a web build the same code runs in the browser's own JS engine. - The Bevy side is
ReactUiPlugin(part ofReactPlugins). It hosts the JS runtime, loads your bundle, applies the operations to the ECS and sends events back.
Your UI ships as two bundles built by the bevy-react CLI: vendor.js
(React, the reconciler and the runtime) and app.js (your components). See
Getting started.
From a render to entities
When React commits, the reconciler sends that commit's changes as one batch: create a node, update its props, append, insert or remove a child, set a text. In its next frame, Bevy drains every batch that has arrived, applies them in order, and then lays out and renders as usual.
- Every element becomes an entity that carries Bevy components such as
Node,BackgroundColor,TextorImageNode. Which ones depends on the element type and its props. - A commit shows up in the first Bevy frame after it was sent. Natively, React renders on its own thread, so a slow render delays the update but never stalls a Bevy frame. On the web the two share the browser's main thread.
- Props cross generically. Every prop except
children,keyandrefis sent; a handler such asonClickcrosses only as a flag, and the function stays in JS. Bevy decodes each prop against the element's registered attributes and the registered style properties, and reports an unknown one as a warning (in the log and in devtools) instead of failing. - The bridge owns the components it writes. Your systems can find React nodes, read their layout and add components of their own (see Named nodes), but must not overwrite what the bridge manages.
Elements and style properties are registered in Rust. The core elements,
the feature elements (<svg>, <canvas>, …) and your own all go through
the same API (see Custom elements and
Custom styles).
Not only UI
The bridge itself manages ECS entities, not UI specifically. bevy_ui is
the built-in element set, and it is where layout, style, picking, layers
and transitions come from. A custom element can spawn any entity instead: a
3D mesh, a light, a sound emitter. React then mounts, updates and unmounts it
like any other element, driving its attributes from state, and name makes
it reachable from your systems. The demos' <cube> is such an element: a
mesh in the 3D scene, rendered from a React list. What a non-UI element gets,
and what it has to bring itself, is listed under
Beyond UI.
Updates are deltas
On a re-render, the reconciler compares old and new props and sends only
the fields that changed. style is compared field by field, and other
props by value, so an inline object literal that did not change sends
nothing. A re-render that produces identical values sends no operation at
all, which makes idiomatic React (re-render freely, let the diff sort it
out) cheap.
On the Bevy side, a change re-runs only the code that reads the changed
properties. A color change repaints the node without a relayout; a width
change triggers a relayout, as it would in bevy_ui itself.
Events flow back
Interactions travel the other way over a single channel. Bevy's picking finds what is under the pointer, and the runtime routes each event to the handler registered for that node and event name. Events do not bubble: the topmost node under the pointer that handles the event receives it (see Mouse). The same channel carries the app's own events, request responses and animation completions.
Talking to your app
App-level state uses three typed channels, each defined by a Rust type and mirrored to TypeScript by codegen: messages from React to Bevy (React to Bevy), requests that Bevy answers (Request / response) and events from Bevy to React (Bevy to React).
TSX
import { bevy } from "./bevy"; // generated
bevy.player.setName("Ada");Rust
#[react_message(name = "player.setName")]
struct SetName(String);
app.add_react_handler(|on: On<SetName>| {
info!("name: {}", on.event().0);
});The generated bevy.ts is the contract: change a Rust type, regenerate,
and the TypeScript compiler points at every call site to update (see
TypeScript codegen).
Animations run in Bevy
Style transitions and
animated values are declared from React
once and driven by Bevy every frame. A transition is part of the style; an
animated value is a number that lives on the Bevy side, bound into a style
and moved by a driver such as withTiming. No JS runs per frame and nothing
crosses the bridge per tick; the only message back is a completion callback
when you ask for one. In a native build, a busy JS thread therefore never
makes an animation stutter.
Hot reload
In a native build the JS runtime and the React tree survive edits. Saving a
file rebuilds only app.js, which runs again in the live runtime, and React
Fast Refresh re-renders. Components keep their useState and other hook
state, and the Bevy side (entities, running animations) carries on. A
component whose hook calls changed remounts with fresh state. See
Hot reload.
Composited layers
Some styles promote a subtree to a composited layer: group opacity on a
node with children, a filter chain, a backdropFilter, a transform3d, a
morphFilter, or cache: "always" or "never". The subtree is rendered
into an offscreen texture and drawn back as one quad.
- The capture is cached: a layer whose content did not change is not rendered again.
- Moving it, fading it and changing filter params happen when the quad is drawn, so animating them never re-renders the content.
- Promotion itself changes neither layout nor picking.
See Layers.
The web host
On wasm the UI is still bevy_ui drawn by Bevy into its canvas, not DOM.
The page loads vendor.js and app.js next to the wasm module, the bridge
operations are exposed to the browser's JS engine, and a Bevy system
delivers events to JS each frame. The bundle is the same as natively. See
Web builds.
What this means for performance
- Re-render freely: unchanged props cost nothing to send.
- Animate with transitions and animated values, not with a
setStateper frame. Each state change is a render, a diff and a batch. - Prefer
transformandopacityfor motion. Changing sizes, positions and other layout properties relayouts the UI on every change. - Every element is an entity, and the cost of a commit grows with the number of nodes it creates or changes.
- The devtools Bridge tab shows the operations each commit sends (see Devtools), and Performance has benchmark numbers.
bevy-react