enpitsulin

enpitsulin

这个人很懒,没有留下什么🤡
twitter
github
bilibili
nintendo switch
mastodon

Building Desktop Applications with Tauri - A Case Study of TodoMVC (Part 2)

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 type we need is our Todo type, 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, 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 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 to check 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 items directly when querying the database on the backend, since our data volume should not be very 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#

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 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 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 a side effect in App.tsx to assign the data returned from invoke('get_todos') to the AllTodosAtom atom and pass the corresponding data to filterAtom. Now we check that the application 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

preview|1002x789

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 will reload once because modifying files in 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, and I am not sure how to resolve this. I hope someone can comment on it.

new todo|1002x789

Modifying, Completing, and Deleting Todos#

We place all 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 web application development. Debouncing events that may be triggered frequently and call side effects is essential.

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 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 won't comment on it. 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 always 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 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 pleasant 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 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?

References#

  • tarui - A cross-platform desktop application development framework, a lightweight version of Electron.
  • TodoMVC - A classic web framework development example.
  • rusqlite - SQLite with Rust.
  • jotai - A flexible state management tool for React with a simple API.
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.