State-Driven Skia Canvases in React Native + Expo with MobX-State-Tree
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.
Project scaffold
Your package.json already brings in everything we need – Expo, Skia, MobX, MST and the lightweight mobx-react-lite observer glue
"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.
The domain model – CanvasObject.ts
A canvas node is declared as an MST model with serialisable, strongly-typed props:
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.
Root store & context – Root.ts
The singleton store holds the root canvas object and exposes a couple of convenience mutators:
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.
Populating the tree – index.tsx
generateTree() turns a bit of JSON into 50 coloured rectangles plus a white background, all within one MST action so React re-renders only once
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.
Rendering – CanvasComponent.tsx
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
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.
Mutations & time-travel
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
Production tips
- Batch large pastes with applySnapshot – one observer notification instead of hundreds.
- Persist snapshots to AsyncStorage on every commit – instant warm-start and crash recovery
- Enable onSnapshot in dev – time-travel debug your canvas edits, then disable for release
- Keep colour / width constants out of render loops – Skia prefers plain numbers; computed styles cost JS time
Where to go next
- Gestures – wire react-native-gesture-handler events to MST actions for drag, pinch and rotate.
- Undo / redo – keep a ring buffer of snapshots; applySnapshot rolls back instantly.
- Export traverse the snapshot tree and emit SVG or PDF for sharing.
Take-away
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!
Who am I?
My name is Aleksei, I am a Co-Founder at Appelian Software.
15+ years in the software development. Experienced Delivery Manager.
Appelian Software plans, designs, and develops MVPs ready to enter the market and be prepared for funding. On top of that, we help to migrate software to a new tech stack or improve the performance of the product.
Let's discuss your project
Your questions and requests are welcome