Using jotai to Control Application State#
jotai is an atomic state management tool, and its name is actually the romanization of the Japanese word 状態
, meaning "state." The API is very concise, and our frontend project uses this library for state management.
Type Declarations Used in the Frontend Project#
Here we also need to declare the type for our Todo, which is essentially declaring the struct we wrote in Rust using TypeScript interfaces for 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, writing it ourselves is not a problem.
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 to conveniently count the number of incomplete todos */
export const activeTodoCountAtom = atom((get) => {
const todos = get(todosAtom)
return todos.filter((todo) => !todo.done).length
})
/** An atom to check if there are any completed todos */
export const anyTodosDone = atom((get) => {
const todos = get(todosAtom)
return todos.some((todo) => todo.done)
})
Although it might be better to filter out soft-deleted records directly when querying the table on the backend, since our data volume should not be very large and I am lazy, I directly use SELECT * from Todo
and send all the data to the frontend for filtering. 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 an 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 we use an effect in App.tsx
to assign the data returned by invoke('get_todos')
to the AllTodosAtom
atom and pass the corresponding data to filterAtom
. Now we check the application, which should show 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 we 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 we 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 triggered it once. If the modification path changes, it may also cause issues during packaging and distribution. I am not sure how to solve this problem, and I hope someone can comment on it.
Modifying, Completing, and Deleting Todos#
We put 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 also consistent with developing web applications. Debouncing events that may be triggered frequently and call side effects is still necessary.
The final component code is as follows. I additionally used some hooks from the react-use
and use-debounce
packages. I originally wanted to write it myself, but after writing one, I left it; you can see the last repository code for useDoubleClick
.
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 skipped it. Theoretically, it should be able to run cross-platform, but I'm not sure about the specific operations _(:3」∠)_
Discussion on Learning Rust#
There has always been a trend saying 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 really 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. Compared to the JavaScript/TypeScript I usually deal with, it can be much more pleasant to write (personal experience) without having to wrestle with an elusive type system.
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. What if frontend development has to learn Rust in the future? It would be too late then! It's better to get ahead of others now!