Skip to main content

MobX Tutorial

This guide shows how to use Promise Saga with MobX. Step-by-step, we will create a simple React Todo app to introduce you to the basic concepts. You can easily find all the source code at the end of this tutorial.

Configure your project​

0. Install and set up Create React App​

If you don't wish to create a project from scratch, create a demo project from a template.

npx create-react-app --template=cra-template-typescript demo-mobx

Remove its src/ directory and proceed further.

cd demo-mobx && rm src/*

1. Install required dependencies​

Add the Promise Saga and MobX packages to the project.

npm install mobx mobx-react-lite @promise-saga/core @promise-saga/plugin-default @promise-saga/plugin-react

2. Create application starting point​

Create an index.tsx file.

index.tsx
import {createRoot} from 'react-dom/client';
import App from './components/App';

const node = document.getElementById('root') as HTMLElement;
const root = createRoot(node);

root.render(<App />);

3. Configure sagas​

Configure sagas once for your project.

saga.ts
import {createCreateSaga} from '@promise-saga/core';
import {plugin} from '@promise-saga/plugin-default';

export const createSaga = createCreateSaga({plugin});

4. Create App component​

components/App.tsx
export default function App() {
return (
<h4>Todo List</h4>
);
}

Run npm start from your command line to see an empty app.

empty App

Implement TodoList​

5. Create TodoList component​

components/TodoList.tsx
import {KeyboardEventHandler, useRef} from 'react';

// initial todos to show
const todos = [
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
];

export default function TodoList() {
// `new todo` text field ref
const newTodoText = useRef<HTMLInputElement>(null);

const addTodo: KeyboardEventHandler<HTMLInputElement> = (e) => {
// handler to add todo with `text`
};

const toggleTodo = (id: number) => {
// handler to toggle todo by `id`
};

return (
<>
<h4>Todos</h4>

<input
type="text"
placeholder="New todo"
ref={newTodoText} // attach `new todo` ref
onKeyDown={addTodo}
/>

<ol>
{todos.map((todo) => ( // create an ordered list of todos
<li key={todo.id}>
<label
style={{
textDecoration:
todo.isCompleted
? 'line-through' // cross out completed todos
: 'none'
}}
>
<input
type="checkbox"
checked={todo.isCompleted || false}
onChange={() => toggleTodo(todo.id)} // checkbox to toggle todo
/>

{todo.text}
</label>
</li>
))}
</ol>
</>
);
}

Turn on the App component in components/App.tsx.

components/App.tsx
import TodoList from './TodoList';

export default function App() {
return (
<TodoList />
);
}

Having no logic, the application is not interactive for now.

6. Create TodoList model​

Create a models directory and a Todos.ts file in it. Configure MobX's model.

models/Todos.ts
import {makeObservable, observable} from 'mobx';

// Create Todo type
type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};

class TodosStore {
data: ITodo[] = [ // set todos to model initialState
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
];

constructor() {
makeObservable(this, {
data: observable,
});
}

// handler to add todo with `text`
addTodo(text: string) {
this.data.push({
id: Date.now(),
text,
isCompleted: false,
});
}

// handler to toggle todo by `id`
toggleTodo(id: number) {
const todo = this.data.find((todo) => todo.id === id);
if (todo) todo.isCompleted = !todo.isCompleted;
}
}

export default new TodosStore();

7. Bind Todos model to UI component​

components/TodoList.tsx
import {KeyboardEventHandler, useRef} from 'react';
import {observer} from 'mobx-react-lite';
import Todos from '../models/Todos'; // import a model

const TodoList = observer(() => {
const newTodoText = useRef<HTMLInputElement>(null);

const addTodo: KeyboardEventHandler<HTMLInputElement> = (e) => {
// if `Enter` pressed
if (e.key === 'Enter') {
// pass text input value to `addTodo` handler
Todos.addTodo(e.currentTarget.value);

if (newTodoText.current) {
// clear text input value if rendered
newTodoText.current.value = '';
}
}
};

const toggleTodo = (id: number) => {
// pass todo id to `toggleTodo` handler
Todos.toggleTodo(id);
};

return (
<>
<h4>Todos</h4>

<input
type="text"
placeholder="New todo"
ref={newTodoText}
onKeyDown={addTodo}
/>

<ol>
{Todos.data.map((todo) => (
<li key={todo.id}>
<label style={{textDecoration: todo.isCompleted ? 'line-through' : 'none'}}>
<input
type="checkbox"
checked={todo.isCompleted || false}
onChange={() => toggleTodo(todo.id)}
/>

{todo.text}
</label>
</li>
))}
</ol>
</>
);
});

export default TodoList;

At this point, you have an interactive TodoList app, and you can add and complete todos with it:

initial TodoList

Playing with sagas​

8. Create first saga to play​

For instance, let us count todos being completed by 3 and log to the console if they're all actually completed at this certain point of time. Edit models/Todos.ts for that.

models/Todos.ts
import {makeObservable, observable} from 'mobx';
import {createSaga} from '../saga';

type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};

class TodosStore {
data: ITodo[] = [
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
];

constructor() {
makeObservable(this, {
data: observable,
});
}

getData = () => this.data;

addTodo(text: string) {
this.data.push({
id: Date.now(),
text,
isCompleted: false,
});
}

toggleTodo(id: number) {
const todo = this.data.find((todo) => todo.id === id);
if (todo) todo.isCompleted = !todo.isCompleted;
}

// create a saga to listen for todo `toggles`
listenTodoToggles = createSaga(async function () {
for (let i = 1; ; i++) { // listen forever
await this.take(this.toggleTodo); // wait for `toggle` action to happen

if (i % 3 === 0) { // count `toggles` by 3
const todos = this.getData(); // get todos list
const isAllCompleted = todos.every((todo) => todo.isCompleted); // define if all todos completed

console.log(`Toggled ${i} todos!`, {isAllCompleted});
}
}
}, {extraThis: this}); // pass `this` to have access to model methods
}

export default new TodosStore();

9. Wrap methods to createAction​

At this point, you're going to get an error while calling await this.take because raw MobX model methods are not takeable. Try wrapping MobX methods with a Promise Saga createAction helper.

models/Todos.ts
import {makeObservable, observable} from 'mobx';
import {createAction} from '@promise-saga/plugin-default';
import {createSaga} from '../saga';

type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};

class TodosStore {
data: ITodo[] = [
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
];

constructor() {
makeObservable(this, {
data: observable,
});
}

getData = () => this.data;

addTodo = createAction((text: string) => {
this.data.push({
id: Date.now(),
text,
isCompleted: false,
});
});

toggleTodo = createAction((id: number) => {
const todo = this.data.find((todo) => todo.id === id);
if (todo) todo.isCompleted = !todo.isCompleted;
});

listenTodoToggles = createSaga(async function () {
for (let i = 1; ; i++) {
await this.take(this.toggleTodo);

if (i % 3 === 0) {
const todos = this.getData();
const isAllCompleted = todos.every((todo) => todo.isCompleted);

console.log(`Toggled ${i} todos!`, {isAllCompleted});
}
}
}, {extraThis: this});
}

export default new TodosStore();

Use the saga on an App level, for example.

components/App.tsx
import {observer} from 'mobx-react-lite';
import {useSaga} from '@promise-saga/plugin-react';
import TodoList from './TodoList';
import Todos from '../models/Todos';

const App = observer(() => {
useSaga(Todos.listenTodoToggles);

return (
<TodoList />
);
});

export default App;

Test the example by toggling todos, counted by 3 now:

initial TodoList

10. Try Promise Saga hooks​

You've already used some lower effects in the previous step, like take and select.

Now, let's see how to debounce text input value with some additional saga hooks, implementing higher effects inside. Turn them on in saga.ts.

saga.ts
import {createCreateSaga} from '@promise-saga/core';
import {plugin, createHigherHooks} from '@promise-saga/plugin-default';

export const createSaga = createCreateSaga({plugin});

export const {
useTakeEvery,
useTakeLeading,
useTakeLatest,
useDebounce,
useThrottle,
} = createHigherHooks(createSaga);

11. Create action for testing​

Name it changeNewTodoText to be clear.

models/Todos.ts
import {makeObservable, observable} from 'mobx';
import {createAction} from '@promise-saga/plugin-default';
import {createSaga} from '../saga';

type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};

class TodosStore {
data: ITodo[] = [
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
];

constructor() {
makeObservable(this, {
data: observable,
});
}

getData = () => this.data;

changeNewTodoText = createAction();

addTodo = createAction((text: string) => {
this.data.push({
id: Date.now(),
text,
isCompleted: false,
});
});

toggleTodo = createAction((id: number) => {
const todo = this.data.find((todo) => todo.id === id);
if (todo) todo.isCompleted = !todo.isCompleted;
});

listenTodoToggles = createSaga(async function () {
for (let i = 1; ; i++) {
await this.take(this.toggleTodo);

if (i % 3 === 0) {
const todos = this.getData();
const isAllCompleted = todos.every((todo) => todo.isCompleted);

console.log(`Toggled ${i} todos!`, {isAllCompleted});
}
}
}, {extraThis: this});
}

export default new TodosStore();

12. Call an action on input change​

Call the changeNewTodoText action once the new todo text gets changed.

components/TodoList.tsx
import {ChangeEventHandler, KeyboardEventHandler, useRef} from 'react';
import {observer} from 'mobx-react-lite';
import Todos from '../models/Todos';

const TodoList = observer(() => {
const newTodoText = useRef<HTMLInputElement>(null);

const addTodo: KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === 'Enter') {
Todos.addTodo(e.currentTarget.value);

if (newTodoText.current) {
newTodoText.current.value = '';
}
}
};

const toggleTodo = (id: number) => {
Todos.toggleTodo(id);
};

const changeNewTodo: ChangeEventHandler<HTMLInputElement> = (e) => {
Todos.changeNewTodoText(e.currentTarget.value);
};

return (
<>
<h4>Todos</h4>

<input
type="text"
placeholder="New todo"
ref={newTodoText}
onKeyDown={addTodo}
onChange={changeNewTodo}
/>

<ol>
{Todos.data.map((todo) => (
<li key={todo.id}>
<label style={{textDecoration: todo.isCompleted ? 'line-through' : 'none'}}>
<input
type="checkbox"
checked={todo.isCompleted || false}
onChange={() => toggleTodo(todo.id)}
/>

{todo.text}
</label>
</li>
))}
</ol>
</>
);
});

export default TodoList;

13. Debounce text input​

Bind the debouncing MobX action to a simple handler like console.log.

components/App.tsx
import {observer} from 'mobx-react-lite';
import {useSaga} from '@promise-saga/plugin-react';
import {useDebounce} from '../saga';
import Todos from '../models/Todos';
import TodoList from './TodoList';

const App = observer(() => {
useSaga(Todos.listenTodoToggles);
useDebounce(1000, Todos.changeNewTodoText, console.log);

return (
<TodoList />
);
});

export default App;

Test it. Your new todo text is going to be logged once 1000ms pass after input finished:

higher effects hooks

14. Manage advanced sagas canceling​

Both sagas used above within a React component are automatically cancelled on component unmount and called again on component mount. But you might want to have more control over sagas flow. Since every saga returns a SagaIterator, you can toggle it.

components/App.tsx
import {observer} from 'mobx-react-lite';
import {useSaga} from '@promise-saga/plugin-react';
import {useDebounce} from '../saga';
import Todos from '../models/Todos';
import TodoList from './TodoList';

// checkbox component to control saga flow
const SagaCheckbox = ({flow}: {flow: ReturnType<typeof useSaga>}) => (
<input type="checkbox" onChange={flow.toggle} checked={flow.isRunning} />
);

const App = observer(() => {
// save saga iterators to variables
const listenTogglesFlow = useSaga(Todos.listenTodoToggles);
const logNewTodoFlow = useDebounce(1000, Todos.changeNewTodoText, console.log);

return (
<>
<div>
<label>
<SagaCheckbox flow={logNewTodoFlow} />
Log new todo text, debounce 1000ms
</label>
</div>

<div>
<label>
<SagaCheckbox flow={listenTogglesFlow} />
Log todos toggling, count by 3
</label>
</div>

<TodoList />
</>
);
});

export default App;

Test the result! Since we've implemented checkboxes controlling sagas flow, notice that:

  1. Changing new todo text is debounced with a timeout of 1000ms (1st checkbox). Changing the text input and turning its saga off instantly doesn't give any effect, as the saga gets cancelled sooner.
  2. Todos toggling is counted by 3 (2nd checkbox). Turning this checkbox off and back resets the counter, as the saga flow gets renewed.

advanced canceling

Source code​

Refer to the complete MobX example for more information.