使用 jotai 控制アプリケーションの状態#
jotai は原子状態管理ツールで、名前は実際には日本語の状態
のローマ字表記です。API は非常にシンプルで、私たちのフロントエンドプロジェクトではこのライブラリを使用して状態管理を行っています。
フロントエンドプロジェクトで使用する型宣言#
ここで必要なのは、私たちの Todo の型です。実際には、Rust で以前に書いた構造体を TypeScript のインターフェースとして宣言することで、フロントエンドプロジェクトで使用できるようにします。
export interface Todo {
id: string
label: string
done: boolean
is_delete: boolean
}
ここには、コンパイル時に自動的に TypeScript の型バインディングを生成できるrust パッケージがありますが、私たちのプロジェクトは大きくないので、自分で書くことにしました。
原子の定義#
まず、src の下に store ディレクトリを作成し、todo.ts ファイルを追加して、いくつかの原子の宣言を保存します。
import { atom } from 'jotai'
import { Todo } from '../types/todo'
/** 完了状態をフィルタリングするためのもの */
export const filterType = atom<'all' | 'completed' | 'active'>('all')
/** is_deleteがtrueのtodosを含む */
export const allTodosAtom = atom<Todo[]>([])
/** ソフト削除されていないtodos */
export const todosAtom = atom<Todo[]>((get) => {
const todos = get(allTodosAtom)
return todos.filter((todo) => !todo.is_delete)
})
/** フィルタリングされた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')
})
})
/** 未完了のtodoの数をカウントするための原子 */
export const activeTodoCountAtom = atom((get) => {
const todos = get(todosAtom)
return todos.filter((todo) => !todo.done).length
})
/** todoが完了しているかどうかを確認するための原子 */
export const anyTodosDone = atom((get) => {
const todos = get(todosAtom)
return todos.some((todo) => todo.done)
})
バックエンドでテーブルをクエリする際にソフト削除を直接フィルタリングする方が良いかもしれませんが、私たちのデータ量はそれほど大きくないと思うので、私は怠惰なので、直接SELECT * from Todo
を実行して、すべてのデータをフロントエンドに渡してフィルタリングさせます。もちろん、この行動は推奨されません =。=
アプリケーションでの原子の使用#
原子を定義したら、当然使用する必要があります。ページのトップレベルコンポーネント App.tsx に必要な原子をインポートし、副作用を通じてinvoke
を使用して原子に値を設定し、得られたTodo[]
を TodoList コンポーネントで TodoItem としてレンダリングします。
そのため、props を通じてこのデータを渡す必要があります。まず、これらの 2 つのコンポーネントの props を定義する必要があります。まずは 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
次に 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="何をする必要がありますか?" />
</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> 件のアイテムが残っています
</span>
<ul className="filters">
<li>
<a>すべて</a>
</li>
<li>
<a>アクティブ</a>
</li>
<li>
<a>完了</a>
</li>
</ul>
</footer>
</>
)
}
export default TodoList
次に、App.tsx で副作用を使用して invoke ('get_todos') から返されたデータを AllTodosAtom 原子に設定し、filterAtom に対応するデータを渡します。アプリケーションは現在、空のリストの状態になっているはずです。
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
Todo の追加#
新しい todo を追加するには、明らかにバックエンドの "new_todo" を呼び出してデータベースに新しいレコードを挿入する必要があります。同時に、原子に新しいデータを allTodoAtom に挿入する必要があります。
そのため、TodoList を修正して input タグが機能するようにし、フィルタリング用の a タグが 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="何をする必要がありますか?"
/>
</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> 件のアイテムが残っています
</span>
<ul className="filters">
<li>
<a onClick={() => setType('all')} className={type == 'all' ? 'selected' : ''}>
すべて
</a>
</li>
<li>
<a onClick={() => setType('active')} className={type == 'active' ? 'selected' : ''}>
アクティブ
</a>
</li>
<li>
<a onClick={() => setType('completed')} className={type == 'completed' ? 'selected' : ''}>
完了
</a>
</li>
</ul>
{anyDone && (
<button className="clear-completed" onClick={onClearComplete}>
完了したものをクリア
</button>
)}
</footer>
</>
)
}
export default TodoList
次に、効果をテストします。アプリケーションは一度リロードされることに気づくでしょう。これは、/tauri-src/ ディレクトリ内のファイルの変更が tauri の HMR をトリガーするためです。sqlite に対して db.sqlite ファイルを変更したため、トリガーされました。パスを変更すると、パッケージ配布時にパスに問題が発生する可能性があります。この問題はどう解決するかわかりませんので、誰かがコメントで指摘してくれることを願っています。
Todo の変更、完了、削除#
これらの 3 つの操作はすべて TodoItem コンポーネントに配置しますが、"toggle_done" と "update_todo" の操作には必ずデバウンスを加える必要があります。これは Web アプリケーションの開発と同様です。頻繁にトリガーされる副作用を呼び出すイベントのデバウンスは非常に重要です。
最終的なコンポーネントコードは以下の通りです。私は追加で react-use と use-debounce の 2 つのパッケージのいくつかのフックを使用しました。本来は自分で書こうと思ったのですが、1 つ書いたら放置してしまいました。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
パッケージ化と配布#
pnpm tarui build
を実行するだけで、tauri-src/target/release/bundle/msi
に Windows プラットフォームのインストーラーが見つかります。インストーラーをカスタマイズしたい場合は、こちらを参考にしてください。MacOS や Linux については試していませんが~~、なのでスルーしました~~理論的にはクロスプラットフォームで動作するはずですが、具体的な操作についてはよくわかりません_(:3」∠)_
Rust 学習に関する議論#
実際、Rust を学ぶことは流行しているという風潮があります。確かに、ビジネスを行うフロントエンド開発者が Rust を学ぶのは少々過剰かもしれませんが、単純に個人的な好みで新しい言語を学ぶことは、余暇の時間に学ぶことで視野を広げ、経験を増やすことができるので、非常に良いことです。
また、Rust はコンパイラをうまく扱えれば安全に実行できる言語であり、日常的に接触する JavaScript/TypeScript と比べて、虚無的な型システムと戦う必要がないため、実際に書くのは非常に快適です(個人的な体験)。文法が特に奇妙に見えるかもしれませんが、核心的な考え方は一致しています。
さらに、Rust はフロントエンド基盤において素晴らしい成果を上げており、WSAM の分野でもトップクラスの選手です。将来的にフロントエンドが Rust を学ばざるを得なくなった場合、遅すぎることはありません。今のうちに他の人より一歩先に進んでおくのが良いでしょう!