The conventional wisdom is that React-based MSFS gauges can't keep
up with the avionics framework — that you have to drop down to the
MSFS Avionics Framework (ew TS) or raw JS for anything serious without going to WASM.
That's true but not the entire story. React gauges run fine; what falls over is
the hook layer we have been using: per-frame setState spreads,
every consumer notified on every tick, fresh closures thrashing
useSyncExternalStore. Fix those and a 200-subscription gauge sits
comfortably under 5 µs/frame (see Benchmarks).
This library is that fix. Wraps SimVar, GlobalVar, GameVar,
Coherent, and CommBus behind a small, change-gated, dedup-on-key
store. The API mirrors the FlyByWire react-msfs shape so existing
JS/TS gauges port over easily, but we use ReScript to add those nice
zero cost abstractions and type safety:
let (alt, _) = useSimVar("INDICATED ALTITUDE", "feet", ())
let (qnh, set) = useSimVar("L:DC10_BARO_INHG", "number", ())This library is not a silver bullet. It simply allows me to write React without annihilating performance. React still will fall short of both raw JS and the avionics framework in raw performance. React really shines for gauges with lots of interactivity like an EFB. For displays that are querying a ton of sim data and rerendering nearly every tick (PFD, ND) as well as system computation, Rust or the avionics framework will still be the way to go.
Typescript is a great language, the choice of many MSFS gauge authors, and the ecosystem has some solid MSFS-specific libraries. So why ReScript? Mainly developer preference and our desire for a more type safe stack (Rust for systems, non-interactive displays, and ReScript for interactive displays). ReScript has first class bindings for React so the only missing piece was a React-friendly MSFS API wrapper.
Pair with infinity-msfs
(Rust CLI) to bundle, transform, and emit a runnable MSFS gauge
package — no esbuild, no Vite, no JS toolchain config.
- ReScript ≥ 12 (
@rescript/core,@rescript/react ≥ 0.15) - React 19.2+ (the hooks rely on
useSyncExternalStoreand the React Compiler for closure memoization) - Rust 1.85+ (only if you use
infinity-msfsto build/package) - MSFS 2020 or 2024
The React Compiler is required. Without it, useStoreValue's
subscribe/getSnapshot closures are fresh every render and
useSyncExternalStore thrashes the underlying store. ReScript ≥ 12
emits compiler-friendly output by default; if you opt out, wrap the
hook callsites in useMemo/useCallback yourself.
bun add @infinity-msfs/rescript-msfs
# peers
bun add react react-dom @rescript/react @rescript/core @rescript/runtime rescriptrescript.json:
{
"name": "your-gauge",
"sources": [{ "dir": "src", "subdirs": true }],
"package-specs": [{ "module": "es6", "in-source": false }],
"suffix": ".res.mjs",
"bs-dependencies": [
"@rescript/core",
"@rescript/react",
"@infinity-msfs/rescript-msfs"
],
"jsx": { "version": 4, "mode": "automatic" }
}All hooks share a single Store.global. If ten components subscribe
to "INDICATED ALTITUDE" in feet, the sim is polled exactly once
per tick — every subscriber reads the same cached value. The
smallest maxStaleness across subscribers wins, so a slow
subscriber piggybacking on a fast one is free.
InfinityMSFS.Provider mounts the per-frame tick. By default it
listens for the update event dispatched by BaseInstrument
(standard MSFS coherent-gauge pattern). Other modes:
source |
Use when |
|---|---|
BaseInstrument (default) |
Normal MSFS gauge with the generated harness |
Document |
FBW-compatible gauges that dispatch update on document |
RAF |
Dev/preview outside MSFS |
Manual |
You drive Provider.tick(deltaTime) from your own loop |
<InfinityMSFS.Provider source=BaseInstrument>
<YourPanel />
</InfinityMSFS.Provider>Read + write a SimVar. Setter goes through SimVar.SetSimVarValue.
For L-vars this is a direct write; for A-vars it depends on whether
the variable is settable.
let (lights, setLights) = useSimVar("L:LIGHTS_TEST", "bool", ())Read-only variant — half the surface, same store.
Setter-only variant. ~proxy writes through a different name
(e.g. a K-event) while the cache key stays at name.
useState-style functional updater. Reads current at call time,
applies the function, writes back.
For SimVars whose value is fully driven by H-events. Polls
infrequently, but force-refreshes from sim when any of events
fires.
let (toggle, setToggle) = useInteractionSimVar(
"L:RMP_LEFT_TOGGLE",
"bool",
["H:RMP_LEFT_TOGGLE"],
(),
)Read one SimVar, write another. Classic K-event pattern.
let (freq, setFreq) = useSplitSimVar(
"COM STANDBY FREQUENCY:2", "Hz",
"K:COM_2_RADIO_SET_HZ",
~writeUnit="number",
(),
)K-events take a number argument with event-specific scaling.
useSplitSimVar does not perform conversion — encode in your own
setter wrapper before calling.
Read-only access to GlobalVars / GameVars.
Coherent — Coherent.useCoherentEvent(name, handler) / useCoherentEventValue(name, ~initial) / useCoherentTrigger(name) / useCoherentCall(name)
Wraps the JS↔C++ Coherent bridge. Subscriptions are deduplicated:
many components subscribing to the same event share one underlying
Coherent.on slot.
Typed message bus over JS_LISTENER_COMM_BUS with a JSON envelope
codec by default. request does request/response with a rid and
a 5 s timeout; the peer (typically WASM) replies on
<channel>::reply.
let posCh = CommBus.Channel.makeJson("aircraft.position")
let pos = CommBus.useChannelValue(posCh, ~initial={lat: 0.0, lon: 0.0})open InfinityMSFS
module Panel = {
@react.component
let make = () => {
let (alt, _) = useSimVar("INDICATED ALTITUDE", "feet", ())
<div className="alt">{React.string(Float.toString(alt))}</div>
}
}
@module("react-dom/client") external createRoot: Dom.element => 'r = "createRoot"
@send external rootRender: ('r, React.element) => unit = "render"
let mount = (): unit => {
let target = RenderTarget.getRenderTarget()
let root = createRoot(target)
rootRender(root, <InfinityMSFS.Provider> <Panel /> </InfinityMSFS.Provider>)
}Note: do not call mount() at the bottom of the file when
building with infinity-msfs — the bundler injects the call
automatically (see below). For other bundlers, add mount() at
module scope.
infinity-msfs is a Rust CLI that runs the ReScript compiler,
bundles the output via rolldown, transforms it for Coherent GT
(MSFS's HTML UI engine), generates the BaseInstrument harness,
and emits the final HTML/JS/CSS into your aircraft package.
cargo install infinity-msfsyour-aircraft/
├── infinity-msfs.toml # build config (this file)
├── package.json # bun/npm with rescript-msfs deps
├── rescript.json # ReScript build config
├── src/
│ └── Altimeter.res # your gauge source
├── lib/ # rescript output (gitignore)
└── PackageSources/ # MSFS package — bundler writes here
└── html_ui/Pages/VCockpit/Instruments/<package_name>/<instrument>/
[js]
package_name = "dc10-cockpit" # used as the URL segment under VCockpit/Instruments/
package_dir = "PackageSources"
[[js.instruments]]
name = "altimeter" # output folder under <package_name>/
index = "lib/es6/src/Altimeter.res.mjs" # bundler entry (rescript output)
[js.instruments.simulator_package]
type = "rescriptReact"
file_name = "altimeter" # output basename: altimeter.html, altimeter.js, ...
template_id = "Altimeter" # MSFS template ID; must match panel.cfg htmlgauge
is_interactive = true # default true; set false for non-clickable displays
imports = ["/JS/dataStorage.js"]
build_command = "bun run build" # default; runs before bundlingFor each instrument the bundler produces:
<package_name>/<instrument.name>/
├── altimeter.html # template + script tags
├── altimeter.index.js # generated BaseInstrument harness
├── altimeter.js # rolldown bundle of your ReScript output
└── altimeter.css # extracted CSS (empty file if none)
[VCockpit01]
size_mm = 1080,1080
pixel_size = 1080,1080
texture = $DIS_MD_1
htmlgauge00 = dc10-cockpit/altimeter/altimeter.html,0,0,1080,1080The htmlgauge00 path is relative to
html_ui/Pages/VCockpit/Instruments/. The MSFS panel system loads
the HTML, which registers a BaseInstrument whose templateID
matches the <script type="text/html" id="..."> block — that's the
template_id field in your toml.
infinity-msfs build # one-shot
infinity-msfs build --watch # rebuild on .res changeThe bundler:
- Runs
bun run build(or yourbuild_command) so the ReScript compiler emitslib/es6/.../*.res.mjs. - Bundles via rolldown as IIFE, externalising
/Images/*and/Fonts/*, lowering syntax to ES2019 (Coherent GT chokes on?./??). - Injects an auto-invocation of the bundle's
mountexport — yourlet mount = ...runs on script load. - Renders the
BaseInstrumentharness from the template, wiringtemplateID,isInteractive, the bundle path, and any extraimports. - Writes everything into
PackageSources/html_ui/....
Override either template per-instrument:
[js.instruments.simulator_package]
type = "rescriptReact"
template_id = "Altimeter"
html_template = "templates/instrument.html" # custom HTML wrapper
js_template = "templates/harness.cjs" # custom BaseInstrument shellTemplates use a tiny {{ var }} / {{#list}}...{{/list}} syntax.
Available vars: templateId, mountElementId, instrumentName,
instrumentPath, cssPath, jsPath, isInteractive, imports.
bench/run.mjs compares this lib's store against the legacy
react-msfs-style provider (per-frame React-state spread, every
consumer notified on every tick). Scenario: 50 SimVars × 4
subscribers each = 200 active subscriptions, 10 000 frames @
16.6 ms, 200-frame warmup, best of two runs. Mocked SimVar.*
globals so the cost being measured is purely store + dispatch.
| Scenario | infinity (this lib) | Legacy model | Speedup |
|---|---|---|---|
| A. Steady state (no values change) | 4.31 µs/frame · 0 notifies | 19.63 µs/frame · 2 000 000 notifies | 4.6× |
| B. 1 var changes per frame | 4.35 µs/frame · 40 000 notifies | 19.52 µs/frame · 2 000 000 notifies | 4.5× |
| C. All vars change every frame | 4.70 µs/frame · 2 000 000 notifies | 19.64 µs/frame · 2 000 000 notifies | 4.2× |
Two structural wins drive the gap:
- Change-gated notifies. Subscribers fire only when the cached value actually moved. In scenario A the legacy model still notifies 2 M times (200 subs × 10 k frames); this lib notifies zero.
- One poll per (name, unit). Adding the 2nd–4th subscriber per var costs nothing on the read side.
Reproduce:
bun run build && node bench/run.mjs- Single subscription per (name, unit). Adding more components subscribing to the same key is free.
- Components only re-render on value change. Polls that return the same value are no-ops at the subscriber layer.
maxStalenessis the floor, not the ceiling. The store will poll at the smallest staleness any subscriber requested.- Optimistic writes.
useSimVar's setter writes the new value to the cache immediately and notifies subscribers, then the next poll reconciles with sim state. If the underlying write was rejected (wrong unit, invalid K-event payload), you'll see the value flicker back to its sim value on the next tick — that's your signal to check unit/scaling, not a lib bug.
Gauge loads but stays blank ("If you're seeing this, instrument
didn't load"). Either the bundle 404'd (check the <script src>
paths in the generated HTML against your deploy folder) or mount
was never invoked (using a bundler other than infinity-msfs?
add mount() at module scope).
SimVar writes appear to revert. The K-event was rejected.
K-events take a number parameter with event-specific scaling
(e.g. K:KOHLSMAN_SET wants millibars × 16 as an integer). Convert
in a wrapper around the setter, and pass ~writeUnit="number".
useSyncExternalStore warning / store thrashing. React Compiler
isn't running on the bundled output. Confirm your build pipeline
applies it. With infinity-msfs, the bundle goes through rolldown's
JSX/oxc transformer pipeline by default.
MIT