Skip to main content

Client/Server Example

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

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,
serializeActionCall,
SerializedActionCallWithModelIdOverrides,
} 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<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) {
// 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,
}
} else {
// just run the server action unmodified
return 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"
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>
)
})