Using jotai to Control Application State#
jotai is an atomic state management tool, and its name is actually the romanization of the Japanese word 状態
, which means "state". 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 TypeScript interfaces 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 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 entries directly when querying the database on the backend, since our data volume shouldn't be too large and I'm lazy, I just do SELECT * from Todo
and let the frontend filter all the data. Of course, this behavior is not recommended. =.=
Using Atoms in the Application#
After defining 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 obtained Todo[]
to render in the TodoList
component.
We need to pass this data through props, so we first need to define the props for these two components. First is 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 then 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 an effect to assign the data returned by invoke('get_todos')
to the AllTodosAtom
atom and pass the corresponding data to filterAtom
. Now we should see that the application has become 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 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 will reload once because modifying files under the /tauri-src/
directory will trigger 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 don't know how to solve this problem, and 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 necessary for web application development as well. 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 just left it; you can see the final 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, and compared to the JavaScript/TypeScript we usually deal with, it can be much more enjoyable to write (personal experience), even though the syntax may seem particularly awkward at first. The core ideas are still consistent.
Additionally, Rust has made remarkable achievements in frontend infrastructure and is also a top contender in the WASM space. If frontend developers eventually have to learn Rust, wouldn't it be too late? It's better to get ahead of others now!