MobX-State-Tree (MST) gives structural typing, actions, and immutable “snapshots” that can be persisted or replayed.
@shopify/react-native-skia renders thousands of vector primitives at 60 FPS and exposes a declarative React renderer that feels just like normal JSX.
Paired with Expo, these libraries lets you prototype on device and web, profile JS with Flipper, and ship OTA updates without touching Xcode/Android Studio.
Your package.json
already brings in everything we need – Expo, Skia, MobX, MST and the lightweight mobx-react-lite observer glue
// package.json
"dependencies": {
"expo": "~53.0.10",
"react-native": "0.79.3",
"@shopify/react-native-skia": "^2.0.3",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
"mobx-state-tree": "^7.0.2",
// …
}
Run expo prebuild once so that Skia’s native code is compiled into your iOS / Android binaries.
A canvas node is declared as an MST model with serialisable, strongly-typed props:
// CanvasObject.ts
export const CanvasObjectProps = types.model({
id: types.identifier,
x: types.number,
y: types.number,
width: types.number,
height: types.number,
color: types.string,
});
export const CanvasObject = types
.model({
id: types.identifier,
objectId: types.string,
canvasProps: CanvasObjectProps,
type: types.string, // "rect" | "text" | …
children: types.optional(types.array(types.late(() => CanvasObject)), []),
})
.actions((self) => ({
addChildren(objs) { self.children = objs; },
updateColor(value) { self.canvasProps.color = value; },
updateY(delta) { self.canvasProps.y += delta; },
}));
Why MST instead of vanilla MobX? Because you get snapshots, middleware and structural typing without extra boilerplate.
The singleton store holds the root canvas object and exposes a couple of convenience mutators:
// Root.ts
const RootModel = types.model({
object: types.maybe(CanvasObject),
}).actions((self) => ({
addCanvasObject(id: string, obj) { self.object = obj; },
updateObjectColor(i: number, c: string) { self.object.children[i].updateColor(c); },
updateObjectY(i: number, y: number) { self.object.children[i].updateY(y); },
}));
export const rootStore = RootModel.create();
export const Provider = createContext<RootInstance | null>(null).Provider;
export const useMst = () => {
const store = useContext(RootStoreContext);
if (!store) throw new Error("RootStore provider missing");
return store;
};
Because rootStore
is injected via React Context, any component can access state with a simple useMst()
call.
The singleton store holds the root canvas object and exposes a couple of convenience mutators:
//index.tsx
function generateTree() {
const rootCanvasObject = createCanvasObject({ …whiteBackground… }, 'l0');
let x = 0, y = 0;
for (let i = 0; i < 50; i++) {
rootCanvasObject.children.push(
createCanvasObject(sampleData, `o-${i}`, { x, y })
);
x += 50;
if ((i + 1) % 10 === 0) { y += 50; x = 0; }
}
rootStore.addCanvasObject('0', rootCanvasObject);
}
Because everything runs inside a single action (addCanvasObject
), there’s no “progressive” flashing while the tree builds.
The Skia renderer walks the MST tree recursively; each node is rendered in native C++ at 60 FPS while React just provides the virtual hierarchy
//CanvasComponent.tsx
export const CanvasComponent = observer(() => {
const { object } = useMst();
const renderCanvasObject = (
node: ICanvasObject,
parent?: { x: number; y: number; id: string }
) => node.type === 'rect' && (
<Rect
key={`${parent?.id ?? ''}-${node.id}`}
x={node.canvasProps.x + (parent?.x ?? 0)}
y={node.canvasProps.y + (parent?.y ?? 0)}
width={node.canvasProps.width}
height={node.canvasProps.height}
color={node.canvasProps.color}
>
{node.children.map((child) =>
renderCanvasObject(child, { x: node.canvasProps.x, y: node.canvasProps.y, id: node.id })
)}
</Rect>
);
return (
<Canvas style={{ width: 400, height: 600 }}>
{object?.children.map(renderCanvasObject)}
</Canvas>
);
});
Because the component is wrapped in observer, only mutated sub-trees trigger a re-paint, yet Skia still draws on the render thread – scrolling and pinch-zoom remain perfectly smooth even with hundreds of shapes.
Need to move a rectangle, recolour it or build an undo stack? Just call the model actions and record snapshots:
rootStore.updateObjectColor(12, '#FF3366'); // instant paint-bucket
rootStore.updateObjectY(7, 15); // drag by 15 px
const snapshot = getSnapshot(rootStore); // serialise for undo / persistence
Because the component is wrapped in observer, only mutated sub-trees trigger a re-paint, yet Skia still draws on the render thread – scrolling and pinch-zoom remain perfectly smooth even with hundreds of shapes.
With MST handling state, Skia handling pixels and Expo handling builds, you get a fully reactive, maintainable canvas stack that still hits 60 FPS on mid-range devices – and you can time-travel every edit. Happy coding!