Client/Server Example
This example synchronizes two separate root stores via action capture and replay to simulate how two instances of an app talk to a server to stay in sync. It uses pessimistic updates: the local action is cancelled first, then run for real when the server tells the client to do so.
Note: This example has an artificial delay to simulate network latency.
Code
server.ts
examples/clientServer/server.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
examples/clientServer/appInstance.tsx
import {
ActionTrackingResult,
applySerializedActionAndSyncNewModelIds,
fromSnapshot,
onActionMiddleware,
SerializedActionCallWithModelIdOverrides,
serializeActionCall,
} from "mobx-keystone"
import { observer } from "mobx-react-lite"
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<TodoList>(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 (
<>
<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>
)
})