Client/Server Example
This example synchronizes two separate root stores through a server to simulate how two instances of an app stay in sync: calls in, patches out (see Serialization). Each client captures its local actions, encodes them with defaultSerializer.encodeCall and sends them to the server, which runs them with applyCallAsync and broadcasts the patches they produce, which every client applies. It uses pessimistic updates: the local action is cancelled first, and its effects arrive as the server's patches.
info
This example has an artificial delay to simulate network latency.
Code
server.ts
examples/clientServer/server.ts
import {
applyCallAsync,
CallError,
CallErrorJson,
defaultSerializer,
EncodedCall,
getCurrentActionContext,
getSnapshot,
JsonValue,
onPatches,
Patch,
} from "mobx-keystone"
import { createRootStore } from "../todoList/store"
export interface ServerMessage {
patches: Patch[]
// the action that made the change, for display only
actionName: string | undefined
}
export type CallReply = { ok: JsonValue } | { error: CallErrorJson | { message: string } }
type MsgListener = (msg: ServerMessage) => void
// the timeouts are just to simulate network delays
const networkDelay = () => new Promise((resolve) => setTimeout(resolve, 250))
// JSON round trip, as if it went through the network
const overTheWire = <T>(value: T): T => JSON.parse(JSON.stringify(value))
class Server {
private serverRootStore = createRootStore()
private msgListeners: MsgListener[] = []
constructor() {
// broadcast every change, in order, tagged with the top-level action that made it
// (for a flow step, the flow)
onPatches(this.serverRootStore, (patches) => {
const msg = overTheWire<ServerMessage>({
patches,
actionName: getCurrentActionContext()?.rootContext.actionName,
})
void networkDelay().then(() => {
this.msgListeners.forEach((listener) => {
listener(msg)
})
})
})
}
getInitialState() {
return getSnapshot(this.serverRootStore)
}
onMessage(listener: MsgListener) {
this.msgListeners.push(listener)
}
async sendCall(encodedCall: EncodedCall): Promise<CallReply> {
const json = overTheWire(encodedCall)
await networkDelay()
const root = this.serverRootStore
try {
// resolve the target and the action, then run it (flows included)
// the default `canCall` only allows model actions, which is all clients need here
const call = defaultSerializer.decodeCall(root, json)
const result = await applyCallAsync(root, call)
return overTheWire({ ok: defaultSerializer.encodeValue(root, result) })
} catch (err) {
// calls can fail, e.g. when one client deletes a todo another one is changing.
// a `CallError` describes what was wrong with the call and is safe to send back;
// any other error comes from the app, so log it and send a generic message
if (err instanceof CallError) {
return overTheWire({ error: err.toJSON() })
}
console.error("error applying call on the server:", err)
return { error: { message: "the call failed" } }
}
}
}
export const server = new Server()
appInstance.tsx
examples/clientServer/appInstance.tsx
import {
ActionTrackingResult,
applyPatches,
defaultSerializer,
fromSnapshot,
onActionMiddleware,
Patch,
} from "mobx-keystone"
import { observer } from "mobx-react-lite"
import { useEffect, useState } from "react"
import { TodoListView } from "../todoList/app"
import { cancelledActionSymbol, LogsView, serverActionNameSymbol } 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<TodoList>(rootStoreSnapshot)
let serverAction = false
let serverActionName: string | undefined
const applyServerPatches = (patches: Patch[], actionName: string | undefined) => {
const wasServerAction = serverAction
const previousServerActionName = serverActionName
serverAction = true
serverActionName = actionName
try {
// the server sends the patches its actions produced, so applying them leaves the local root
// store exactly as the server's, including any model ids generated there
applyPatches(rootStore, patches)
} finally {
serverAction = wasServerAction
serverActionName = previousServerActionName
}
}
// listen to patch messages to be replicated into the local root store
server.onMessage(({ patches, actionName }) => {
applyServerPatches(patches, actionName)
})
// also listen to local actions, cancel them and send them to the server
const sendLocalActionsToServer = () =>
onActionMiddleware(rootStore, {
onStart(actionCall, ctx) {
if (serverAction) {
// just run the server action unmodified
ctx.data[serverActionNameSymbol] = serverActionName // just for logging purposes
return undefined
} else {
// if the action does not come from the server cancel it silently
// and send it to the server, which will run it and send back the patches it produced
// (the call is encoded before the action runs, so any references in its arguments
// point at the tree as the server still has it)
void server
.sendCall(defaultSerializer.encodeCall(rootStore, actionCall))
.then((reply) => {
if ("error" in reply) {
console.warn("the server could not run the call:", reply.error.message)
}
})
ctx.data[cancelledActionSymbol] = true // just for logging purposes
// "cancel" the action by returning undefined
return {
result: ActionTrackingResult.Return,
value: undefined,
}
}
},
})
return { rootStore, sendLocalActionsToServer }
}
export const AppInstance = observer(() => {
const [{ rootStore, sendLocalActionsToServer }] = useState(() => initAppInstance())
// register this middleware in an effect, after `LogsView` registers its own (children's effects
// run first), so the log wraps it and still shows the actions this one cancels
useEffect(() => sendLocalActionsToServer(), [sendLocalActionsToServer])
return (
<>
<TodoListView list={rootStore} />
<br />
<LogsView rootStore={rootStore} />
</>
)
})
app.tsx
examples/clientServer/app.tsx
import { observer } from "mobx-react-lite"
import { AppInstance } from "./appInstance"
// we will expose both app instances in the ui
export const App = observer(() => {
return (
<div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flex: "1 1 0" }}>
<h2>App Instance #1</h2>
<AppInstance />
</div>
<div style={{ flex: "1 1 0" }}>
<h2>App Instance #2</h2>
<AppInstance />
</div>
</div>
)
})