{
todo.setDone(!todo.done)
}}
onRemove={() => {
list.remove(todo)
}}
/>
)
return (
{list.pending.length > 0 && (
<>
TODO
{list.pending.map((t) => renderTodo(t))}
>
)}
{list.done.length > 0 && (
<>
DONE
{list.done.map((t) => renderTodo(t))}
>
)}
{
setNewTodo(ev.target.value || "")
}}
placeholder="I will..."
/>
)
})
function TodoView({
done,
text,
onClick,
onRemove,
}: {
done: boolean
text: string
onClick: () => void
onRemove: () => void
}) {
return (
{done ? "✔️" : "👀"}
{text}
{}
❌
)
}
```
### `logs.tsx`
```tsx
import { ActionCall, getSnapshot, onActionMiddleware, onPatches, Patch } from "mobx-keystone"
import { observer, useLocalObservable } from "mobx-react"
import React, { useEffect } from "react"
import { TodoList } from "./store"
// this is just for the client/server demo
export const cancelledActionSymbol = Symbol("cancelledAction")
interface ExtendedActionCall extends ActionCall {
cancelled: boolean
}
export const LogsView = observer((props: { rootStore: TodoList }) => {
const data = useLocalObservable(() => ({
actions: [] as ExtendedActionCall[],
patchesList: [] as Patch[][],
addAction(actionCall: ExtendedActionCall) {
this.actions.push(actionCall)
},
addPatches(patches: Patch[]) {
this.patchesList.push(patches)
},
}))
useEffect(() => {
// we can use action middlewares for several things
// in this case we will keep a log of the actions done over the todo list
const disposer = onActionMiddleware(props.rootStore, {
onFinish(actionCall, ctx) {
const extendedActionCall: ExtendedActionCall = {
...actionCall,
cancelled: !!ctx.data[cancelledActionSymbol],
}
data.addAction(extendedActionCall)
},
})
return disposer
}, [data, props.rootStore])
useEffect(() => {
// also it is possible to get a list of changes in the form of patches,
// even with inverse patches to undo the changes
const disposer = onPatches(props.rootStore, (patches, _inversePatches) => {
data.addPatches(patches)
})
return disposer
})
// we can convert any model (or part of it) into a plain JS structure
// with it we can:
// - serialize to later deserialize it with `fromSnapshot`
// - pass it to non mobx-friendly components
// snapshots respect immutability, so if a subobject is changed
// its refrence will be kept
const rootStoreSnapshot = getSnapshot(props.rootStore)
return (
<>
{data.actions.map((action, index) => (
))}
{data.patchesList.map(patchesToText)}
{JSON.stringify(rootStoreSnapshot, null, 2)}
>
)
})
function ActionCallToText(props: { actionCall: ExtendedActionCall }) {
const actionCall = props.actionCall
const args = actionCall.args.map((arg) => JSON.stringify(arg)).join(", ")
const path = actionCall.targetPath.join("/")
const text = `[${path}] ${actionCall.actionName}(${args})`
if (actionCall.cancelled) {
return (
<>
{text}{" "}
(cancelled and sent to server)
>
)
}
return (
<>
{text}
>
)
}
function patchToText(patch: Patch) {
const path = patch.path.join("/")
let str = `[${path}] ${patch.op}`
if (patch.op !== "remove") {
str += " -> " + JSON.stringify(patch.value)
}
return str + "\n"
}
function patchesToText(patches: Patch[]) {
return patches.map(patchToText)
}
function PreSection(props: { title: string; children: React.ReactNode }) {
return (
<>
{props.title}
{props.children}
>
)
}
```
---
# examples/clientServer/clientServer
In this example we will be synchronizing two separate root stores via action capturing and applying, which will simulate how to instances of an app talk with a server to keep in sync. We will use pessimistic updates, this is, we will cancel the local action and then actually run the action when the server tells the client to do so.
Note: This example has an artificial delay to simulate network latency.
## Code
### `server.ts`
```ts
import {
applySerializedActionAndTrackNewModelIds,
getSnapshot,
SerializedActionCall,
SerializedActionCallWithModelIdOverrides,
} from "mobx-keystone"
import { createRootStore } from "../todoList/store"
type MsgListener = (actionCall: SerializedActionCallWithModelIdOverrides) => void
class Server {
private serverRootStore = createRootStore()
private msgListeners: MsgListener[] = []
getInitialState() {
return getSnapshot(this.serverRootStore)
}
onMessage(listener: MsgListener) {
this.msgListeners.push(listener)
}
sendMessage(actionCall: SerializedActionCall) {
// the timeouts are just to simulate network delays
setTimeout(() => {
// apply the action over the server root store
// sometimes applying actions might fail (for example on invalid operations
// such as when one client asks to delete a model from an array and other asks to mutate it)
// so we try / catch it
let serializedActionCallToReplicate: SerializedActionCallWithModelIdOverrides | undefined
try {
// we use this to apply the action on the server side and keep track of new model IDs being
// generated, so the clients will have the chance to keep those in sync
const applyActionResult = applySerializedActionAndTrackNewModelIds(
this.serverRootStore,
actionCall
)
serializedActionCallToReplicate = applyActionResult.serializedActionCall
} catch (err) {
console.error("error applying action to server:", err)
}
if (serializedActionCallToReplicate) {
setTimeout(() => {
// and distribute message, which includes new model IDs to keep them in sync
this.msgListeners.forEach((listener) => {
listener(serializedActionCallToReplicate)
})
}, 500)
}
}, 500)
}
}
export const server = new Server()
```
### `appInstance.tsx`
```tsx
import {
ActionTrackingResult,
applySerializedActionAndSyncNewModelIds,
fromSnapshot,
onActionMiddleware,
SerializedActionCallWithModelIdOverrides,
serializeActionCall,
} from "mobx-keystone"
import { observer } from "mobx-react"
import { useState } from "react"
import { TodoListView } from "../todoList/app"
import { cancelledActionSymbol, LogsView } from "../todoList/logs"
import { TodoList } from "../todoList/store"
import { server } from "./server"
function initAppInstance() {
// we get the snapshot from the server, which is a serializable object
const rootStoreSnapshot = server.getInitialState()
// and hydrate it into a proper object
const rootStore = fromSnapshot(rootStoreSnapshot)
let serverAction = false
const runServerActionLocally = (actionCall: SerializedActionCallWithModelIdOverrides) => {
const wasServerAction = serverAction
serverAction = true
try {
// in clients we use the sync new model ids version to make sure that
// any model ids that were generated in the server side end up being
// the same in the client side
applySerializedActionAndSyncNewModelIds(rootStore, actionCall)
} finally {
serverAction = wasServerAction
}
}
// listen to action messages to be replicated into the local root store
server.onMessage((actionCall) => {
runServerActionLocally(actionCall)
})
// also listen to local actions, cancel them and send them to the server
onActionMiddleware(rootStore, {
onStart(actionCall, ctx) {
if (serverAction) {
// just run the server action unmodified
return undefined
} else {
// if the action does not come from the server cancel it silently
// and send it to the server
// it will then be replicated by the server and properly executed
server.sendMessage(serializeActionCall(actionCall, rootStore))
ctx.data[cancelledActionSymbol] = true // just for logging purposes
// "cancel" the action by returning undefined
return {
result: ActionTrackingResult.Return,
value: undefined,
}
}
},
})
return rootStore
}
export const AppInstance = observer(() => {
const [rootStore] = useState(() => initAppInstance())
return (
<>
>
)
})
```
### `app.tsx`
```tsx
import { observer } from "mobx-react"
import { AppInstance } from "./appInstance"
// we will expose both app instances in the ui
export const App = observer(() => {
return (
)
})
```
---
# examples/yjsBinding/yjsBinding
In this example we will be synchronizing two separate root stores via the `mobx-keystone-yjs` package (documentation [here](../../integrations/yjsBinding.mdx)). This package ensures a `Y.js` document is kept in sync with a `mobx-keystone` store and vice-versa. This is useful for example when you when you want to have multiple clients that keep in sync with each other using a peer-to-peer connection, an intermediate server, etc. Another nice feature of CRDTs is that they are conflict-free, so you don't have to worry about conflicts when two clients edit the same data at the same time, even if they were offline while doing such changes. Due to this all updates become optimistic updates and perceived performance is great since it does not require a server confirming the operation.
Note: This example uses `y-webrtc` to share state using WebRTC (P2P).
## Code
### `appInstance.tsx`
```tsx
import { observable } from "mobx"
import { getSnapshot, registerRootStore } from "mobx-keystone"
import { applyJsonObjectToYMap, bindYjsToMobxKeystone } from "mobx-keystone-yjs"
import { observer } from "mobx-react"
import { useState } from "react"
import { WebrtcProvider } from "y-webrtc"
import * as Y from "yjs"
import { TodoListView } from "../todoList/app"
import { createDefaultTodoList, TodoList } from "../todoList/store"
function getInitialState() {
// here we just generate what a client would have saved from a previous session,
// but it could be stored in each client local storage or something like that
const yjsDoc = new Y.Doc()
const yjsRootStore = yjsDoc.getMap("rootStore")
const todoListSnapshot = getSnapshot(createDefaultTodoList())
applyJsonObjectToYMap(yjsRootStore, todoListSnapshot)
const updateV2 = Y.encodeStateAsUpdateV2(yjsDoc)
yjsDoc.destroy()
return updateV2
}
function initAppInstance() {
// we get the initial state from the server, which is a Yjs update
const rootStoreYjsUpdate = getInitialState()
// hydrate into a Yjs document
const yjsDoc = new Y.Doc()
Y.applyUpdateV2(yjsDoc, rootStoreYjsUpdate)
// and create a binding into a mobx-keystone object rootStore
const { boundObject: rootStore } = bindYjsToMobxKeystone({
mobxKeystoneType: TodoList,
yjsDoc,
yjsObject: yjsDoc.getMap("rootStore"),
})
// although not strictly required, it is always a good idea to register your root stores
// as such, since this allows the model hook `onAttachedToRootStore` to work and other goodies
registerRootStore(rootStore)
// connect to other peers via webrtc
const webrtcProvider = new WebrtcProvider("mobx-keystone-yjs-binding-demo", yjsDoc)
// expose the webrtc connection status as an observable
const status = observable({
connected: webrtcProvider.connected,
setConnected(value: boolean) {
this.connected = value
},
})
webrtcProvider.on("status", (event) => {
status.setConnected(event.connected)
})
const toggleWebrtcProviderConnection = () => {
if (webrtcProvider.connected) {
webrtcProvider.disconnect()
} else {
webrtcProvider.connect()
}
}
return { rootStore, status, toggleWebrtcProviderConnection }
}
export const AppInstance = observer(() => {
const [{ rootStore, status, toggleWebrtcProviderConnection }] = useState(() => initAppInstance())
return (
<>
{status.connected ? "Online (sync enabled)" : "Offline (sync disabled)"}
>
)
})
```
### `app.tsx`
```tsx
import { AppInstance } from "./appInstance"
let iframeResizerChildInited = false
function initIframeResizerChild() {
if (!iframeResizerChildInited) {
iframeResizerChildInited = true
void import("@iframe-resizer/child")
}
}
export const App = () => {
initIframeResizerChild()
return (
<>
>
)
}
```
---
# examples/loroBinding/loroBinding
In this example we will be synchronizing two separate root stores via the `mobx-keystone-loro` package (documentation [here](../../integrations/loroBinding.mdx)). This package ensures a `Loro` document is kept in sync with a `mobx-keystone` store and vice-versa. This is useful for example when you when you want to have multiple clients that keep in sync with each other using a peer-to-peer connection, an intermediate server, etc. Another nice feature of CRDTs is that they are conflict-free, so you don't have to worry about conflicts when two clients edit the same data at the same time, even if they were offline while doing such changes. Due to this all updates become optimistic updates and perceived performance is great since it does not require a server confirming the operation.
Note: This example uses `BroadcastChannel` to share state between the two instances in the same browser.
## Code
### `appInstance.tsx`
```tsx
import { LoroDoc } from "loro-crdt"
import { observable } from "mobx"
import { getSnapshot, registerRootStore } from "mobx-keystone"
import { applyJsonObjectToLoroMap, bindLoroToMobxKeystone } from "mobx-keystone-loro"
import { observer } from "mobx-react"
import { useEffect, useState } from "react"
import { TodoListView } from "../todoList/app"
import { createDefaultTodoList, TodoList } from "../todoList/store"
function getInitialState() {
const doc = new LoroDoc()
const loroRootStore = doc.getMap("rootStore")
const todoListSnapshot = getSnapshot(createDefaultTodoList())
applyJsonObjectToLoroMap(loroRootStore, todoListSnapshot)
doc.commit()
const update = doc.export({ mode: "snapshot" })
return update
}
// we get the initial state from the server, which is a Loro update
const rootStoreLoroUpdate = getInitialState()
function initAppInstance() {
// hydrate into a Loro document
const doc = new LoroDoc()
doc.import(rootStoreLoroUpdate)
// and create a binding into a mobx-keystone object rootStore
const { boundObject: rootStore } = bindLoroToMobxKeystone({
mobxKeystoneType: TodoList,
loroDoc: doc,
loroObject: doc.getMap("rootStore"),
})
// although not strictly required, it is always a good idea to register your root stores
// as such, since this allows the model hook `onAttachedToRootStore` to work and other goodies
registerRootStore(rootStore)
// expose the connection status as an observable
const status = observable({
connected: true,
setConnected(value: boolean) {
this.connected = value
},
})
// Simple sync via BroadcastChannel
const channel = new BroadcastChannel("mobx-keystone-loro-binding-demo")
let lastSentVersion = doc.version()
const unsubLocalUpdates = doc.subscribeLocalUpdates((update) => {
if (status.connected) {
channel.postMessage({ update, fromPeer: doc.peerIdStr })
lastSentVersion = doc.version()
}
})
channel.onmessage = (event) => {
if (!status.connected) return
const { update, fromPeer, isSyncRequest } = event.data
if (fromPeer === doc.peerIdStr) return // ignore own messages if any
try {
doc.import(update)
lastSentVersion = doc.version()
if (isSyncRequest) {
// if it is a sync request, send back only the changes they are missing
channel.postMessage({
update: doc.export({ mode: "update", from: event.data.version }),
fromPeer: doc.peerIdStr,
isSyncRequest: false,
})
}
} catch (e) {
console.error("Import failed", e)
}
}
const toggleConnection = () => {
const newConnected = !status.connected
status.setConnected(newConnected)
if (newConnected) {
// when reconnecting, send our current state to others and ask for theirs
channel.postMessage({
update: doc.export({ mode: "update", from: lastSentVersion }),
version: doc.version(),
fromPeer: doc.peerIdStr,
isSyncRequest: true,
})
// checkpoint the version we just sent
lastSentVersion = doc.version()
}
}
return { rootStore, status, toggleConnection, dispose: () => unsubLocalUpdates() }
}
export const AppInstance = observer(() => {
const [{ rootStore, status, toggleConnection, dispose }] = useState(() => initAppInstance())
useEffect(() => {
return () => {
dispose()
}
}, [dispose])
return (
<>
{status.connected ? "Online (sync enabled)" : "Offline (sync disabled)"}
>
)
})
```
### `app.tsx`
```tsx
import React from "react"
import { AppInstance } from "./appInstance"
let iframeResizerChildInited = false
function initIframeResizerChild() {
if (!iframeResizerChildInited) {
iframeResizerChildInited = true
void import("@iframe-resizer/child")
}
}
export const App = () => {
initIframeResizerChild()
return (
)
}
```