Using jotai to Control Application State#
jotai is an atomic state management tool, and its name is actually the romanization of the Japanese word 状態
. The API is very concise, and our frontend project uses this library for state management.
Type Declarations Used in the Frontend Project#
Another thing we need is the type for our Todo, which is essentially declaring the struct we wrote in Rust using a TypeScript interface for use in the frontend project.
export interface Todo {
id: string
label: string
done: boolean
is_delete: boolean
}
There is actually a Rust package that can automatically generate TypeScript type bindings during compilation, but since our project is not large, it's fine to write it ourselves.
Defining Atoms#
First, create a store directory under src and add a todo.ts file to store the declarations of some atoms.
import { atom } from 'jotai'
import { Todo } from '../types/todo'
/** Used to filter completion status */
export const filterType = atom<'all' | 'completed' | 'active'>('all')
/** Includes todos with is_delete set to true */
export const allTodosAtom = atom<Todo[]>([])
/** Todos that have not been soft deleted */
export const todosAtom = atom<Todo[]>((get) => {
const todos = get(allTodosAtom)
return todos.filter((todo) => !todo.is_delete)
})
/** Filtered todos */
export const filterAtom = atom((get) => {
const todos = get(todosAtom)
return todos.filter((todo) => {
if (get(filterType) === 'all') return true
return todo.done === (get(filterType) === 'completed')
})
})
/** An atom for conveniently counting the number of incomplete todos */
export const activeTodoCountAtom = atom((get) => {
const todos = get(todosAtom)
return todos.filter((todo) => !todo.done).length
})
/** An atom for checking if any todos are completed */
export const anyTodosDone = atom((get) => {
const todos = get(todosAtom)
return todos.some((todo) => todo.done)
})
While it might be better to filter out soft-deleted entries directly when querying the database on the backend, since our data volume should not be large and I'm lazy, I simply do SELECT * from Todo
and let the frontend filter all the data. Of course, this behavior is not recommended. =.=
Using Atoms in the Application#
Now that we have defined the atoms, we need to use them. We need to import the required atoms in the top-level component App.tsx, and then we use a side effect to invoke
and assign values to the atoms, using the resulting Todo[]
in the TodoList component to render TodoItem.
We need to pass this data through props, so we first need to define the props for these two components. First, for TodoItem:
import { Todo } from './types/todo'
const TodoItem: React.FC<{ todo:Todo }> = ({ todo }) => {
return (
<li>
<div className="view">
<input type="checkbox" className="toggle" checked={todo.done} autoFocus />
<label>{todo.label}</label>
<button className="destroy"></button>
</div>
</li>
)
}
export default TodoItem
And for TodoList:
import { Todo } from './types/todo'
import TodoItem from './TodoItem'
const TodoList:React.FC<{ todos: Todo[] }> = ({ todos }) => {
return (
<>
<header className="header">
<h1>todos</h1>
<input type="text" className="new-todo" placeholder="What needs to be done?" />
</header>
<section className="main">
<input type="checkbox" className="toggle-all" />
<label htmlFor="togle-all"></label>
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</section>
<footer className="footer">
<span className="todo-count">
<strong>1</strong> items left
</span>
<ul className="filters">
<li>
<a>All</a>
</li>
<li>
<a>Active</a>
</li>
<li>
<a>Completed</a>
</li>
</ul>
</footer>
</>
)
}
export default TodoList
Then, in App.tsx, we use a side effect to assign the data returned from invoke('get_todos')
to the AllTodosAtom atom and pass the corresponding data to filterAtom. We can then check that the application should now display an empty list.
import { invoke } from '@tauri-apps/api'
import { useAtom } from 'jotai'
import { useEffect } from 'react'
import TodoList from './component/TodoList'
import { allTodosAtom, filterAtom } from './store/todos'
import { Todo } from './types/todo'
function App() {
const [, setAllTodos] = useAtom(allTodosAtom)
const [todos] = useAtom(filterAtom)
useEffect(() => {
invoke<Todo[]>('get_todos').then((res) => {
setAllTodos(res)
})
}, [])
return (
<div className="todoapp">
<TodoList todos={todos}/>
</div>
)
}
export default App
Adding a Todo#
To add a new todo, we obviously need to call the backend's "new_todo" to insert a new record into the database, and we also need to manipulate the atom to insert the new data into allTodoAtom.
So modify TodoList to make the input tag functional and allow the filter links to control filterTypeAtom.
import { useAtom } from 'jotai'
import { v4 as randomUUID } from 'uuid'
import { useState, useCallback, KeyboardEventHandler } from 'react'
import { activeTodoCountAtom, allTodosAtom, anyTodosDone, filterType } from '../store/todos'
import { Todo } from '../types/todo'
import TodoItem from './TodoItem'
import { invoke } from '@tauri-apps/api'
const TodoList: React.FC<{ todos: Todo[] }> = ({ todos }) => {
const [, setTodos] = useAtom(allTodosAtom)
const [type, setType] = useAtom(filterType)
const [activeCount] = useAtom(activeTodoCountAtom)
const [anyDone] = useAtom(anyTodosDone)
const [newTodo, setNewTodo] = useState('')
const addTodo = async (label: string, id: string) => {
invoke('new_todo', { todo: { id, label, done: false, is_delete: false } })
}
const onAddTodo = useCallback<KeyboardEventHandler<HTMLInputElement>>(
(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (newTodo) {
const id = randomUUID()
addTodo(newTodo, id)
setTodos((oldTodos) => {
return [...oldTodos, { label: newTodo, id, done: false } as Todo]
})
setNewTodo('')
}
}
},
[newTodo]
)
const onClearComplete = () => {
setTodos((oldTodos) => {
return oldTodos.filter((todo) => {
const isDone = todo.done
if (isDone) {
invoke('update_todo', {
todo: { ...todo, is_delete: true }
})
return false
}
return true
})
})
}
return (
<>
<header className="header">
<h1>todos</h1>
<input
type="text"
className="new-todo"
value={newTodo}
onChange={(e) => {
setNewTodo(e.target.value)
}}
onKeyPress={onAddTodo}
placeholder="What needs to be done?"
/>
</header>
<section className="main">
<input type="checkbox" className="toggle-all" />
<label htmlFor="togle-all"></label>
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</section>
<footer className="footer">
<span className="todo-count">
<strong>{activeCount}</strong> items left
</span>
<ul className="filters">
<li>
<a onClick={() => setType('all')} className={type == 'all' ? 'selected' : ''}>
All
</a>
</li>
<li>
<a onClick={() => setType('active')} className={type == 'active' ? 'selected' : ''}>
Active
</a>
</li>
<li>
<a onClick={() => setType('completed')} className={type == 'completed' ? 'selected' : ''}>
Completed
</a>
</li>
</ul>
{anyDone && (
<button className="clear-completed" onClick={onClearComplete}>
Clear completed
</button>
)}
</footer>
</>
)
}
export default TodoList
Then test the effect, and we will find that the application reloads once because modifying files under the /tauri-src/ directory triggers Tauri's HMR. We modified the db.sqlite file in SQLite, which caused a trigger. If the modification path changes, it may also cause issues during packaging and distribution, but I'm not sure how to resolve this. I hope someone can comment on it.
Modifying, Completing, and Deleting Todos#
We place these three operations in the TodoItem component, but it is important to note that operations for "toggle_done" and "update_todo" must include debouncing, which is consistent with developing web applications. Debouncing events that may be triggered frequently and call side effects is essential.
The final component code is as follows. I also used some hooks from the react-use and use-debounce packages. I originally wanted to write my own, but after writing one, I left it. The useDoubleClick can be seen in the final repository code.
import { useAtom } from 'jotai'
import { ChangeEventHandler, KeyboardEventHandler, useCallback, useRef, useState } from 'react'
import { useClickAway } from 'react-use'
import { useDebouncedCallback } from 'use-debounce'
import { allTodosAtom } from '../store/todos'
import { Todo } from '../types/todo'
import { useDoubleClick } from '../hooks/useDoubleClick'
import { invoke } from '@tauri-apps/api'
const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => {
const [, setTodos] = useAtom(allTodosAtom)
const [editing, setEditing] = useState(false)
const ref = useRef<HTMLInputElement>(null)
const toggleDone = useDebouncedCallback(() => {
invoke('toggle_done', { id: todo.id })
}, 500)
const setLabel = useDebouncedCallback((label: string) => {
invoke('update_todo', {
todo: { ...todo, label }
})
}, 500)
const deleteTodo = useCallback(() => {
invoke('update_todo', {
todo: { ...todo, is_delete: true }
})
}, [todo])
const onDelete = () => {
setTodos((todos) => {
return todos.filter((t) => {
return t.id !== todo.id
})
})
deleteTodo()
}
const onChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const label = e?.target.value
setTodos((todos) => {
return todos.map((t) => {
if (t.id === todo.id) {
setLabel(label)
return { ...t, label }
}
return t
})
})
}
useClickAway(ref, () => {
finishEditing()
})
const finishEditing = useCallback(() => {
setEditing(false)
}, [todo])
const handleViewClick = useDoubleClick(null, () => {
setEditing(true)
})
const onDone = useCallback(() => {
setTodos((todos) => {
return todos.map((t) => {
if (t.id === todo.id) {
toggleDone()
return { ...t, done: !t.done }
}
return t
})
})
}, [todo.id])
const onEnter = useCallback<KeyboardEventHandler<HTMLInputElement>>(
(event) => {
if (event.key === 'Enter') {
event.preventDefault()
finishEditing()
}
},
[todo]
)
return (
<li
className={[editing && 'editing', todo.done && 'completed'].filter(Boolean).join(' ')}
onClick={handleViewClick}
>
<div className="view">
<input type="checkbox" className="toggle" checked={todo.done} onChange={onDone} autoFocus />
<label>{todo.label}</label>
<button className="destroy" onClick={onDelete}></button>
</div>
{editing && (
<input
ref={ref}
type="text"
autoFocus={true}
value={todo.label}
onChange={onChange}
className="edit"
onKeyPress={onEnter}
/>
)}
</li>
)
}
export default TodoItem
Packaging and Distribution#
We just need to execute pnpm tarui build
, and then we can find the installation package for the Windows platform in tauri-src/target/release/bundle/msi
. If you want to customize the installer, you can refer to this. As for MacOS and Linux, I haven't tried it so I’m not sure. Theoretically, it should be able to run cross-platform, but I'm not clear on the specific operations _(:3」∠)_
Discussion on Learning Rust#
There has been a trend suggesting that learning Rust is competitive. Indeed, for a frontend developer focused on business, learning Rust can be seen as competitive. However, learning a new emerging language out of personal interest during spare time can broaden one's horizons and increase experience, which is definitely beneficial.
Moreover, Rust is a language that can run safely as long as you can handle the compiler, and compared to the JavaScript/TypeScript we usually deal with, it can be much more enjoyable to write (personal experience). Although the syntax may seem particularly awkward, the core ideas are still consistent.
Additionally, Rust has made remarkable achievements in frontend infrastructure and is a top contender in the WASM space. If frontend developers eventually have to learn Rust, wouldn't it be better to get ahead of the curve now?