• Home
  • Blog
  • State-Driven Skia Canvases in React Native + Expo with MobX-State-Tree

State-Driven Skia Canvases in React Native + Expo with MobX-State-Tree

mobx-skia-react-native

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

// 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.

The domain model – CanvasObject

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.

Root store & context

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.

Populating the tree

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.

Rendering

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.

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

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.

Production tips

1. Batch large pastes with applySnapshot
one observer notification instead of hundreds.
2. Persist snapshots to AsyncStorage on every commit
instant warm-start and crash recovery
3. Enable onSnapshot in dev
time-travel debug your canvas edits, then disable for release
4. 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!