Zustand Tutorial
This guide shows how to use Promise Saga with Zustand. 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-zustand
Remove its src/
directory and proceed further.
cd demo-zustand && rm src/*
1. Install required dependenciesβ
Add the Promise Saga and Zustand packages to the project.
npm install zustand @promise-saga/core @promise-saga/plugin-default @promise-saga/plugin-react
2. Create application starting pointβ
Create an index.tsx
file.
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.
import {createCreateSaga} from '@promise-saga/core';
import {plugin} from '@promise-saga/plugin-default';
export const createSaga = createCreateSaga({plugin});
4. Create App componentβ
export default function App() {
return (
<h4>Todo List</h4>
);
}
Run npm start
from your command line to see an empty app.
Implement TodoListβ
5. Create TodoList componentβ
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
.
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 Zustand's model.
import {create} from 'zustand';
// Create Todo type
type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};
// Create Todos store type
type ITodosStore = {
todos: ITodo[];
addTodo(text: string): void;
toggleTodo(id: number): void;
};
export const useTodosStore = create<ITodosStore>()((set) => ({
todos: [ // set todos to model initialState
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
],
// handler to add todo with `text`
addTodo(text: string) {
set((state) => ({
todos: state.todos.concat({
id: Date.now(),
text,
isCompleted: false,
}),
}));
},
// handler to toggle todo by `id`
toggleTodo(id: number) {
set((state) => ({
todos: state.todos.map((todo) => ({
...todo,
isCompleted: todo.id === id ? !todo.isCompleted : todo.isCompleted, // toggle `isCompleted`
})),
}));
},
}));
7. Bind Todos model to UI componentβ
import {KeyboardEventHandler, useRef} from 'react';
import {useTodosStore} from '../models/Todos'; // import a model
export default function TodoList() {
const todos = useTodosStore(); // get todos store
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.todos.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>
</>
);
}
At this point, you have an interactive TodoList app, and you can add
and complete
todos with it:
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.
import {create} from 'zustand';
import {SagaIterator} from '@promise-saga/core';
import {createSaga} from '../saga';
type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};
type ITodosStore = {
todos: ITodo[];
addTodo(text: string): void;
toggleTodo(id: number): void;
listenTodoToggles(): SagaIterator;
};
export const useTodosStore = create<ITodosStore>()((set, get) => ({
todos: [
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
],
addTodo(text: string) {
set((state) => ({
todos: state.todos.concat({
id: Date.now(),
text,
isCompleted: false,
}),
}));
},
toggleTodo(id: number) {
set((state) => ({
todos: state.todos.map((todo) => ({
...todo,
isCompleted: todo.id === id ? !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(get().toggleTodo); // wait for `toggle` action to happen
if (i % 3 === 0) { // count `toggles` by 3
const todos = get().todos; // get todos list
const isAllCompleted = todos.every((todo) => todo.isCompleted); // define if all todos completed
console.log(`Toggled ${i} todos!`, {isAllCompleted}); // log!
}
}
}),
}));
9. Wrap methods to createActionβ
At this point, you're going to get an error while calling await this.take
because raw Zustand model methods are not takeable. Try wrapping Zustand methods with a Promise Saga createAction
helper.
import {create} from 'zustand';
import {SagaIterator} from '@promise-saga/core';
import {createAction, FnAction} from '@promise-saga/plugin-default';
import {createSaga} from '../saga';
type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};
type ITodosStore = {
todos: ITodo[];
addTodo: FnAction<void, [string]>;
toggleTodo: FnAction<void, [number]>;
listenTodoToggles(): SagaIterator;
};
export const useTodosStore = create<ITodosStore>()((set, get) => ({
todos: [
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
],
addTodo: createAction((text: string) => {
set((state) => ({
todos: state.todos.concat({
id: Date.now(),
text,
isCompleted: false,
}),
}));
}),
toggleTodo: createAction((id: number) => {
set((state) => ({
todos: state.todos.map((todo) => ({
...todo,
isCompleted: todo.id === id ? !todo.isCompleted : todo.isCompleted,
})),
}));
}),
listenTodoToggles: createSaga(async function () {
for (let i = 1; ; i++) {
await this.take(get().toggleTodo);
if (i % 3 === 0) {
const todos = get().todos;
const isAllCompleted = todos.every((todo) => todo.isCompleted);
console.log(`Toggled ${i} todos!`, {isAllCompleted});
}
}
}),
}));
Use the saga on an App level, for example.
import {useSaga} from '@promise-saga/plugin-react';
import {useTodosStore} from '../models/Todos';
import TodoList from './TodoList';
export default function App() {
const todos = useTodosStore();
useSaga(todos.listenTodoToggles); // use a saga
return (
<TodoList />
);
}
Test the example by toggling todos, counted by 3 now:
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
.
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.
import {create} from 'zustand';
import {SagaIterator} from '@promise-saga/core';
import {createAction, FnAction} from '@promise-saga/plugin-default';
import {createSaga} from '../saga';
type ITodo = {
id: number;
text: string;
isCompleted?: boolean;
};
type ITodosStore = {
todos: ITodo[];
addTodo: FnAction<void, [string]>;
toggleTodo: FnAction<void, [number]>;
changeNewTodoText: FnAction;
listenTodoToggles(): SagaIterator;
};
export const useTodosStore = create<ITodosStore>()((set, get) => ({
todos: [
{id: 1, text: 'Implement RTK', isCompleted: true},
{id: 2, text: 'Play with coroutines'},
],
addTodo: createAction((text: string) => {
set((state) => ({
todos: state.todos.concat({
id: Date.now(),
text,
isCompleted: false,
}),
}));
}),
toggleTodo: createAction((id: number) => {
set((state) => ({
todos: state.todos.map((todo) => ({
...todo,
isCompleted: todo.id === id ? !todo.isCompleted : todo.isCompleted,
})),
}));
}),
changeNewTodoText: createAction(),
listenTodoToggles: createSaga(async function () {
for (let i = 1; ; i++) {
await this.take(get().toggleTodo);
if (i % 3 === 0) {
const todos = get().todos;
const isAllCompleted = todos.every((todo) => todo.isCompleted);
console.log(`Toggled ${i} todos!`, {isAllCompleted});
}
}
}),
}));
12. Call an action on input changeβ
Call the changeNewTodoText
action once the new todo text gets changed.
import {ChangeEventHandler, KeyboardEventHandler, useRef} from 'react';
import {useTodosStore} from '../models/Todos';
export default function TodoList() {
const todos = useTodosStore();
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.todos.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>
</>
);
}
13. Debounce text inputβ
Bind the debouncing Zustand action to a simple handler like console.log
.
import {useSaga} from '@promise-saga/plugin-react';
import {useTodosStore} from '../models/Todos';
import TodoList from './TodoList';
export default function App() {
const todos = useTodosStore();
useSaga(todos.listenTodoToggles);
useDebounce(1000, todos.changeNewTodoText, todos.logNewTodo);
return (
<TodoList />
);
}
Test it. Your new todo text is going to be logged once 1000ms pass after input finished:
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.
import {useSaga} from '@promise-saga/plugin-react';
import {useDebounce} from '../saga';
import {useTodosStore} 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} />
);
export default function App() {
const todos = useTodosStore();
// 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 change (debounce: 1000ms)
</label>
</div>
<div>
<label>
<SagaCheckbox flow={listenTogglesFlow} />
Log todos toggling (per 3, and fetch pikachu data)
</label>
</div>
<TodoList/>
</>
);
}
Test the result! Since we've implemented checkboxes controlling sagas flow, notice that:
- 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.
- Todos toggling is counted by 3 (2nd checkbox). Turning this checkbox off and back resets the counter, as the saga flow gets renewed.
Source codeβ
Refer to the complete Zustand example for more information.