State management
Every store here subscribes through React's own useSyncExternalStore and
never touches a host environment. Their out-of-React surface matters more in
a desktop app than on the web: an X event callback, an ntk timer or a D-Bus
signal handler can write to the store directly, and every subscribed
component under every <window> re-renders.
Versions are the ones tested; newer releases in the same major are expected to behave the same.
zustand
Out of the box. zustand@5.0.14.
A small store: create((set) => ({...})) returns a hook that components
call with a selector, and useStore.getState() / useStore.subscribe()
work outside React. The default recommendation for app state.
import React from 'react';
import { createRoot } from 'react-x11';
import { create } from 'zustand';
const useStore = create((set) => ({
n: 0,
inc: () => set((s) => ({ n: s.n + 1 })),
}));
function App() {
const n = useStore((s) => s.n);
return (
<window width={200} height={100} title="counter">
<box style={{ padding: 8 }} onClick={() => useStore.getState().inc()}>
<text>count: {n}</text>
</box>
</window>
);
}
(await createRoot()).render(<App />);
The store is module-level, so several windows share it.
zustand/middleware'spersistdefaults tolocalStorage; pass astoragebacked bynode:fs(createJSONStorage(() => fileStorage)).zustand/traditional(createWithEqualityFn) also works; the shallow-compare helper is pure.
jotai
Out of the box. jotai@2.20.2.
Atomic state: atom(initial) defines a unit, useAtom/useAtomValue/
useSetAtom read and write it, and derived atoms recompute from their
dependencies. In a desktop app use an explicit store: create it at startup,
hand it to <Provider>, and let X event handlers or background work call
store.set(...). One store shared by several <window> trees keeps them in
sync.
import React from 'react';
import { createRoot } from 'react-x11';
import { atom, createStore, Provider, useAtomValue } from 'jotai';
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);
const store = createStore();
function App() {
const doubled = useAtomValue(doubledAtom);
return (
<window width={200} height={100} title="jotai">
<text>doubled: {doubled}</text>
</window>
);
}
(await createRoot()).render(
<Provider store={store}>
<App />
</Provider>,
);
setInterval(() => store.set(countAtom, (n) => n + 1), 1000);
jotai/utils'atomWithStoragedefaults tolocalStorage; supply a custom storage (sync or async).- Async atoms suspend; wrap consumers in
<React.Suspense>(react-x11 renders fallbacks like any React 19 host) or useloadable().
valtio
Out of the box. valtio@2.3.2.
Proxy-based state: proxy(obj) returns an object you mutate directly;
useSnapshot(state) returns an immutable snapshot and re-renders only when
the properties read change. Mutation from anywhere suits X event handlers
and ntk callbacks; subscribe(state, cb) pushes state into non-React things
— a selection onto the clipboard, a window title set imperatively.
import React from 'react';
import { createRoot } from 'react-x11';
import { proxy, useSnapshot } from 'valtio';
const state = proxy({ n: 0, log: [] });
function App() {
const snap = useSnapshot(state);
return (
<window width={220} height={120} title="valtio">
<box
style={{ padding: 8 }}
onClick={() => {
state.n += 1;
state.log.push(Date.now());
}}
>
<text>clicked {snap.n} times</text>
</box>
</window>
);
}
(await createRoot()).render(<App />);
- Valtio 2 no longer deep-clones snapshots in dev the way v1 did; read the v2 migration notes if you port v1 examples.
valtio/utils'devtools()targets the Redux DevTools browser extension and is a no-op here.
XState
Out of the box. xstate@5.32.5, @xstate/react@6.1.0.
createMachine describes states, events, guards and actions;
createActor interprets one; @xstate/react provides useMachine,
useActor, useActorRef and useSelector. Statecharts suit desktop
chrome: drag-resize, modal and focus flows, a connection lifecycle
(connecting → connected → retrying), cancellable jobs. An actor started at
app level can outlive any single <window> and drive several.
import React from 'react';
import { createRoot } from 'react-x11';
import { createMachine, createActor } from 'xstate';
import { useSelector } from '@xstate/react';
const toggle = createMachine({
id: 'toggle',
initial: 'off',
states: {
off: { on: { TOGGLE: 'on' } },
on: { on: { TOGGLE: 'off' } },
},
});
const actor = createActor(toggle).start();
function App() {
const value = useSelector(actor, (s) => s.value);
return (
<window width={220} height={120} title="xstate">
<box
style={{ padding: 8 }}
onClick={() => actor.send({ type: 'TOGGLE' })}
>
<text>state: {value}</text>
</box>
</window>
);
}
(await createRoot()).render(<App />);
useMachine(machine)inside a component gives a component-scoped actor; the external actor above is better for machines shared across windows.@statelyai/inspectconnects over WebSocket to Stately's editor, which works from Node; the iframe inspector expects a browser.
Redux
Out of the box — pin react-redux@>=9. react-redux@9.3.0,
@reduxjs/toolkit@2.12.0, redux@5.0.1.
react-redux 9 no longer imports react-dom/react-native for
unstable_batchedUpdates and subscribes through useSyncExternalStore, so
it needs no shim. redux core and RTK, listener and observable middleware
included, are pure JS.
import React from 'react';
import { createRoot } from 'react-x11';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
const counter = createSlice({
name: 'counter',
initialState: { n: 0 },
reducers: {
inc: (s) => {
s.n += 1;
}, // immer draft — mutation is fine
},
});
const store = configureStore({ reducer: { counter: counter.reducer } });
function Counter() {
const n = useSelector((s) => s.counter.n);
const dispatch = useDispatch();
return (
<box style={{ padding: 8 }} onClick={() => dispatch(counter.actions.inc())}>
<text>count: {n}</text>
</box>
);
}
(await createRoot()).render(
<Provider store={store}>
<window width={220} height={120} title="redux">
<Counter />
</window>
</Provider>,
);
- The version pin is the one thing you must know. react-redux 8 imports
react-domunconditionally: npm refuses it withERESOLVEon React 19, and behind--legacy-peer-depsthe app fails at startup withCannot find module 'react-dom'. Both are in the negative-results register. - RTK's
devTools: true(the default) looks for the Redux DevTools extension global and is harmless when absent;@redux-devtools/cliis a remote devtools server if you want inspection. - RTK Query works, but
setupListeners()wireswindow.addEventListener('focus'|'online'), absent under Node. PasssetupListeners(dispatch, customHandler)a handler wired to<window onFocus>, as in the TanStack Query adapter.
Immer
Out of the box. immer@11.1.15.
produce(base, recipe) runs a mutating recipe against a Proxy draft and
returns a structurally-shared immutable result; curried produce(recipe) is
a drop-in setState updater. It is already inside @reduxjs/toolkit's
createSlice reducers and available as zustand middleware
(zustand/middleware/immer).
import React from 'react';
import { createRoot } from 'react-x11';
import { produce } from 'immer';
function App() {
const [state, setState] = React.useState({
items: [{ label: 'a', done: false }],
});
const toggle = (i) =>
setState(
produce((draft) => {
draft.items[i].done = !draft.items[i].done;
}),
);
return (
<window width={260} height={160} title="todo">
{state.items.map((it, i) => (
<box key={it.label} style={{ padding: 4 }} onClick={() => toggle(i)}>
<text>
{it.done ? '[x] ' : '[ ] '}
{it.label}
</text>
</box>
))}
</window>
);
}
(await createRoot()).render(<App />);
Useful directly with useState/useReducer for the nested state desktop
apps accumulate — tree views, multi-pane layouts.
MobX
Adapter — no code, but react-dom must be installed. mobx@6.16.1,
mobx-react-lite@4.1.1.
Observable state via makeAutoObservable/observable; mobx-react-lite's
observer() re-renders a component when the observables it dereferences
change. mobx core is pure JS.
Every build of mobx-react-lite does
import { unstable_batchedUpdates } from "react-dom" at module load, to
install React's batcher as mobx's reaction scheduler. Without react-dom the
app fails at startup with Cannot find module 'react-dom'.
The fix is npm install react-dom. Its top-level entry is Node-safe (the
one SSR uses), react-x11 never renders through it, and React 19's
unstable_batchedUpdates batches correctly for any renderer. With it
present, observer subscribes through use-sync-external-store and
mutations from X event handlers or timers re-render the tree.
// prerequisite: npm install react-dom (satisfies mobx-react-lite's import;
// react-x11 does not render through it)
import React from 'react';
import { createRoot } from 'react-x11';
import { makeAutoObservable, runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
const store = makeAutoObservable({ n: 0 });
const App = observer(function App() {
return (
<window width={220} height={120} title="mobx">
<box
style={{ padding: 8 }}
onClick={() =>
runInAction(() => {
store.n += 1;
})
}
>
<text>count: {store.n}</text>
</box>
</window>
);
});
(await createRoot()).render(<App />);
- Without react-dom the failure is at import time, not render time, and the require stack names mobx-react-lite.
mobx-reactlayers on mobx-react-lite, so the same applies. Prefer-lite.- Do not import from
react-domyourself; it is a compatibility shim here, nothing more.