Skip to main content

Todo List Example

Code

store.ts

examples/todoList/store.ts
import { computed } from "mobx"
import {
connectReduxDevTools,
idProp,
model,
Model,
modelAction,
ModelAutoTypeCheckingMode,
registerRootStore,
setGlobalConfig,
tProp,
types,
} from "mobx-keystone"

// for this example we will enable runtime data checking even in production mode
setGlobalConfig({
modelAutoTypeChecking: ModelAutoTypeCheckingMode.AlwaysOn,
})

// the model decorator marks this class as a model, an object with actions, etc.
// the string identifies this model type and must be unique across your whole application
@model("todoSample/Todo")
export class Todo extends Model({
// here we define the type of the model data, which is observable and snapshotable
// and also part of the required initialization data of the model

// in this case we use runtime type checking,
id: idProp, // an optional string that will use a random id when not provided
text: tProp(types.string), // a required string
done: tProp(types.boolean, false), // an optional boolean that will default to false

// if we didn't require runtime type checking we could do this
// id: idProp,
// text: prop<string>(),
// done: prop(false)
}) {
// the modelAction decorator marks the function as a model action, giving it access
// to modify any model data and other superpowers such as action
// middlewares, replication, etc.
@modelAction
setDone(done: boolean) {
this.done = done
}

@modelAction
setText(text: string) {
this.text = text
}
}

@model("todoSample/TodoList")
export class TodoList extends Model({
// in this case the default uses an arrow function to create the object since it is not a primitive
// and we need a different array for each model instane
todos: tProp(types.array(types.model(Todo)), () => []),

// if we didn't require runtime type checking
// todos: prop<Todo[]>(() => [])
}) {
// standard mobx decorators (such as computed) can be used as usual, since props are observables
@computed
get pending() {
return this.todos.filter((t) => !t.done)
}

@computed
get done() {
return this.todos.filter((t) => t.done)
}

@modelAction
add(todo: Todo) {
this.todos.push(todo)
}

@modelAction
remove(todo: Todo) {
const index = this.todos.indexOf(todo)
if (index >= 0) {
this.todos.splice(index, 1)
}
}
}

export function createDefaultTodoList(): TodoList {
// the parameter is the initial data for the model
return new TodoList({
todos: [
new Todo({ text: "make mobx-keystone awesome!" }),
new Todo({ text: "spread the word" }),
new Todo({ text: "buy some milk", done: true }),
],
})
}

export function createRootStore(): TodoList {
const rootStore = createDefaultTodoList()

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

// we can also connect the store to the redux dev tools
const remotedev = require("remotedev")
const connection = remotedev.connectViaExtension({
name: "Todo List Example",
})

connectReduxDevTools(remotedev, connection, rootStore)

return rootStore
}

app.tsx

examples/todoList/app.tsx
import { observer } from "mobx-react"
import React, { useState } from "react"
import { LogsView } from "./logs"
import { createRootStore, Todo, TodoList } from "./store"

// we use mobx-react to connect to the data, as it is usual in mobx
// this library is framework agnostic, so it can work anywhere mobx can work
// (even outside of a UI)

export const App = observer(() => {
const [rootStore] = useState(() => createRootStore())

return (
<>
<TodoListView list={rootStore} />
<br />
<LogsView rootStore={rootStore} />
</>
)
})

export const TodoListView = observer(({ list }: { list: TodoList }) => {
const [newTodo, setNewTodo] = React.useState("")

const renderTodo = (todo: Todo) => (
<TodoView
key={todo.id}
done={todo.done}
text={todo.text}
onClick={() => {
todo.setDone(!todo.done)
}}
onRemove={() => {
list.remove(todo)
}}
/>
)

return (
<div>
{list.pending.length > 0 && (
<>
<h5>TODO</h5>
{list.pending.map((t) => renderTodo(t))}
</>
)}

{list.done.length > 0 && (
<>
<h5>DONE</h5>
{list.done.map((t) => renderTodo(t))}
</>
)}
<br />
<input
value={newTodo}
onChange={(ev) => {
setNewTodo(ev.target.value || "")
}}
placeholder="I will..."
/>
<button
onClick={() => {
list.add(new Todo({ text: newTodo }))
setNewTodo("")
}}
>
Add todo
</button>
</div>
)
})

function TodoView({
done,
text,
onClick,
onRemove,
}: {
done: boolean
text: string
onClick(): void
onRemove(): void
}) {
return (
<div style={{ cursor: "pointer" }}>
<span
onClick={onClick}
style={{
textDecoration: done ? "line-through" : "inherit",
}}
>
<span
style={{
display: "inline-block",
width: "1.5rem",
textAlign: "center",
marginRight: 8,
}}
>
{done ? "✔️" : "👀"}
</span>
{text}
{}
</span>
<span onClick={onRemove} style={{ marginLeft: 16 }}>

</span>
</div>
)
}

logs.tsx

examples/todoList/logs.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 (
<>
<PreSection title="Action log">
{data.actions.map((action, index) => (
<ActionCallToText actionCall={action} key={index} />
))}
</PreSection>

<PreSection title="Patch log">{data.patchesList.map(patchesToText)}</PreSection>

<PreSection title="Generated immutable snapshot">
{JSON.stringify(rootStoreSnapshot, null, 2)}
</PreSection>
</>
)
})

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 (
<>
<span style={{ textDecoration: "line-through" }}>{text}</span>{" "}
<span>(cancelled and sent to server)</span>
<br />
<br />
</>
)
}
return (
<>
<span>{text}</span>
<br />
<br />
</>
)
}

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 (
<>
<h5>{props.title}</h5>
<pre style={{ fontSize: 10, whiteSpace: "pre-wrap" }}>{props.children}</pre>
</>
)
}