Back

/ 9 min read

Building an iOS Todo App with Tauri 2.0, React, & TypeScript

Building mobile apps doesn’t always require learning platform-specific languages. With Tauri 2.0, you can create native iOS applications using web technologies you already know. In this comprehensive guide, I’ll walk you through building a beautiful, fully-functional todo app that feels right at home on iOS.

iOS Todo App

Why Tauri for iOS Development?

Tauri 2.0 brings exciting iOS support, allowing developers to leverage their web development skills while creating truly native mobile applications. Unlike hybrid solutions, Tauri apps compile to native code, offering excellent performance and a genuine native feel.

Our tech stack includes:

  • Tauri 2.0 for iOS compilation and native features
  • React + TypeScript for the frontend
  • Vite for fast development
  • Zustand for lightweight state management
  • Tailwind CSS for styling
  • shadcn/ui for beautiful components

Setting Up the Project

Let’s start by creating our Tauri project using the official CLI:

Terminal window
npm create tauri-app@latest todo-app -- --template react-ts
cd todo-app

Next, we’ll add our essential dependencies:

Terminal window
npm install zustand react-router-dom lucide-react clsx tailwind-merge
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Configuring Tailwind CSS

Update your tailwind.config.js to include the paths to your components:

export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {}
},
plugins: []
}

Don’t forget to update your PostCSS config to use ES modules:

export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

Building the Core Data Types

Let’s define our todo structure. Create src/types/todo.ts:

export interface Todo {
id: string
text: string
completed: boolean
createdAt: Date
completedAt?: Date
priority: 'low' | 'medium' | 'high'
category: string
}
export type FilterType = 'all' | 'active' | 'completed'

State Management with Zustand

Zustand provides a simple, powerful way to manage our app’s state. Create src/stores/todoStore.ts:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { Todo, FilterType } from '../types/todo'
interface TodoStore {
todos: Todo[]
filter: FilterType
addTodo: (text: string, category?: string, priority?: 'low' | 'medium' | 'high') => void
toggleTodo: (id: string) => void
deleteTodo: (id: string) => void
setFilter: (filter: FilterType) => void
getTodayTodos: () => Todo[]
getOverdueTodos: () => Todo[]
}
export const useTodoStore = create<TodoStore>()(
persist(
(set, get) => ({
todos: [],
filter: 'all',
addTodo: (text, category = 'Inbox', priority = 'medium') =>
set((state) => ({
todos: [
...state.todos,
{
id: Date.now().toString(),
text,
completed: false,
createdAt: new Date(),
priority,
category
}
]
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? {
...todo,
completed: !todo.completed,
completedAt: !todo.completed ? new Date() : undefined
}
: todo
)
})),
deleteTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id)
})),
setFilter: (filter) => set({ filter }),
getTodayTodos: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return get().todos.filter((todo) => todo.createdAt >= today && !todo.completed)
},
getOverdueTodos: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return get().todos.filter((todo) => todo.createdAt < today && !todo.completed)
}
}),
{
name: 'todo-storage',
// Handle Date serialization
partialize: (state) => ({
...state,
todos: state.todos.map((todo) => ({
...todo,
createdAt: todo.createdAt.toISOString(),
completedAt: todo.completedAt?.toISOString()
}))
}),
onRehydrateStorage: () => (state) => {
if (state?.todos) {
state.todos = state.todos.map((todo) => ({
...todo,
createdAt: new Date(todo.createdAt as string),
completedAt: todo.completedAt ? new Date(todo.completedAt as string) : undefined
}))
}
}
}
)
)

Creating Beautiful UI Components

The Todo Item Component

Let’s create a sleek todo item that feels native to iOS. Create src/components/TodoItem.tsx:

import React from 'react'
import { Check, Trash2 } from 'lucide-react'
import { Todo } from '../types/todo'
import { useTodoStore } from '../stores/todoStore'
interface TodoItemProps {
todo: Todo
}
export const TodoItem: React.FC<TodoItemProps> = ({ todo }) => {
const { toggleTodo, deleteTodo } = useTodoStore()
const priorityColors = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-red-500'
}
return (
<div className='mb-2 flex items-center gap-3 rounded-lg bg-gray-900 p-4'>
<button
onClick={() => toggleTodo(todo.id)}
className={`flex h-6 w-6 items-center justify-center rounded-full border-2 transition-colors ${
todo.completed ? 'border-blue-500 bg-blue-500' : 'border-gray-400 hover:border-blue-400'
}`}
>
{todo.completed && <Check className='h-4 w-4 text-white' />}
</button>
<div className='flex-1'>
<p className={`text-white ${todo.completed ? 'line-through opacity-60' : ''}`}>
{todo.text}
</p>
<div className='mt-1 flex items-center gap-2'>
<span className='text-sm text-red-400'>{todo.createdAt.toLocaleDateString()}</span>
<span className='text-sm text-gray-400'>{todo.category}</span>
</div>
</div>
<div className={`h-3 w-3 rounded-full ${priorityColors[todo.priority]}`} />
<button
onClick={() => deleteTodo(todo.id)}
className='p-2 text-gray-400 transition-colors hover:text-red-400'
>
<Trash2 className='h-4 w-4' />
</button>
</div>
)
}

The Add Todo Form

Create an elegant form for adding new todos in src/components/TodoForm.tsx:

import React, { useState } from 'react'
import { Plus } from 'lucide-react'
import { useTodoStore } from '../stores/todoStore'
export const TodoForm: React.FC = () => {
const [text, setText] = useState('')
const [category, setCategory] = useState('Inbox')
const [isOpen, setIsOpen] = useState(false)
const { addTodo } = useTodoStore()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (text.trim()) {
addTodo(text, category)
setText('')
setIsOpen(false)
}
}
return (
<>
{/* Floating Action Button */}
<button
onClick={() => setIsOpen(true)}
className='fixed bottom-20 right-6 z-10 flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-lg'
>
<Plus className='h-6 w-6 text-black' />
</button>
{/* Modal Overlay */}
{isOpen && (
<div
className='fixed inset-0 z-20 flex items-end bg-black bg-opacity-50'
onClick={() => setIsOpen(false)}
>
<div
className='w-full space-y-4 rounded-t-3xl bg-gray-800 p-6'
onClick={(e) => e.stopPropagation()}
>
<div className='mx-auto mb-4 h-1 w-12 rounded-full bg-gray-600' />
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='flex items-center gap-3'>
<div className='h-6 w-6 rounded-full border-2 border-gray-400' />
<input
type='text'
value={text}
onChange={(e) => setText(e.target.value)}
placeholder='What needs to be done?'
className='flex-1 bg-transparent text-lg text-white outline-none'
autoFocus
/>
</div>
<div className='flex gap-2'>
<button
type='button'
onClick={() => setCategory('Inbox')}
className={`rounded-full px-4 py-2 text-sm ${
category === 'Inbox' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300'
}`}
>
Inbox
</button>
<button
type='button'
onClick={() => setCategory('Today')}
className={`rounded-full px-4 py-2 text-sm ${
category === 'Today' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300'
}`}
>
Today
</button>
</div>
<button
type='submit'
disabled={!text.trim()}
className='w-full rounded-lg bg-blue-500 py-3 font-medium text-white disabled:opacity-50'
>
Add Task
</button>
</form>
</div>
</div>
)}
</>
)
}

Building Navigation Pages

The Main App Structure

Create your main app structure in src/App.tsx:

import React from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { Home } from './pages/Home'
import { Upcoming } from './pages/Upcoming'
import { Search } from './pages/Search'
import { Settings } from './pages/Settings'
import { Navigation } from './components/Navigation'
import { TodoForm } from './components/TodoForm'
function App() {
return (
<Router>
<div className='font-system min-h-screen bg-black text-white'>
<main className='pb-16'>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/upcoming' element={<Upcoming />} />
<Route path='/search' element={<Search />} />
<Route path='/settings' element={<Settings />} />
</Routes>
</main>
<Navigation />
<TodoForm />
</div>
</Router>
)
}
export default App

The Home Page

Create the main todo list view in src/pages/Home.tsx:

import React from 'react'
import { TodoItem } from '../components/TodoItem'
import { useTodoStore } from '../stores/todoStore'
export const Home: React.FC = () => {
const { todos, filter } = useTodoStore()
const todayTodos = todos.filter((todo) => !todo.completed)
const todayCount = todayTodos.length
return (
<div className='px-4 pb-4 pt-12'>
<div className='mb-6'>
<h1 className='text-3xl font-bold text-white'>Today</h1>
<p className='text-gray-400'>
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className='space-y-2'>
{todayTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
{todayTodos.length === 0 && (
<div className='py-12 text-center text-gray-400'>
<p>No tasks for today!</p>
<p className='mt-2 text-sm'>Tap the + button to add a new task</p>
</div>
)}
</div>
</div>
)
}

iOS-Specific Configuration

Tauri Configuration

Update your src-tauri/tauri.conf.json for iOS. You can find more configuration options in the official documentation:

{
"$schema": "https://schema.tauri.app/config/2",
"productName": "todo-app",
"version": "0.1.0",
"identifier": "com.todoapp.mobile",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "todo-app",
"width": 375,
"height": 812,
"resizable": true,
"fullscreen": false
}
]
}
}

Package.json Scripts

Add iOS-specific scripts to your package.json:

{
"scripts": {
"ios:init": "npm run tauri ios init",
"ios:dev": "npm run tauri ios dev",
"ios:build": "npm run tauri ios build",
"ios:build:release": "npm run tauri ios build --release"
}
}

Styling for iOS

Add iOS-specific styles to your src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
-webkit-tap-highlight-color: transparent;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background-color: #000000;
color: #ffffff;
user-select: none;
-webkit-user-select: none;
}
}
@layer utilities {
.font-system {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
}
@media (max-width: 768px) {
.touch-manipulation {
touch-action: manipulation;
}
}

Building and Testing

Initialize iOS Support

First, initialize iOS support for your Tauri project. Make sure you have the required dependencies as outlined in the iOS prerequisites guide:

Terminal window
npm run ios:init

Development

Run your app in development mode:

Terminal window
npm run ios:dev

This will:

  1. Start the Vite development server
  2. Build the Rust backend
  3. Launch the iOS simulator
  4. Deploy your app to the simulator

Common Issues and Solutions

Simulator Not Starting: If you encounter CoreSimulator errors, restart the simulator service:

Terminal window
sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService
open -a Simulator

Product Name Mismatch: Ensure your productName in tauri.conf.json matches your build output (use hyphens, not spaces).

Build Failures: Clean your build directory:

Terminal window
rm -rf src-tauri/gen/apple/build

Advanced Features

Adding Haptic Feedback

For a truly native feel, you can add haptic feedback using Tauri’s API:

import { invoke } from '@tauri-apps/api/core'
const triggerHaptic = async () => {
try {
await invoke('haptic_feedback', { type: 'impact' })
} catch (error) {
console.log('Haptic feedback not available')
}
}

Local Notifications

Implement local notifications for task reminders using the notification plugin:

import {
isPermissionGranted,
requestPermission,
sendNotification
} from '@tauri-apps/plugin-notification'
const scheduleNotification = async (todo: Todo) => {
let permissionGranted = await isPermissionGranted()
if (!permissionGranted) {
const permission = await requestPermission()
permissionGranted = permission === 'granted'
}
if (permissionGranted) {
sendNotification({
title: 'Todo Reminder',
body: todo.text
})
}
}

Production Build

When you’re ready to build for production:

Terminal window
npm run ios:build:release

This creates an optimized build ready for App Store submission.

Conclusion

Building iOS apps with Tauri 2.0 opens up exciting possibilities for web developers. You can leverage your existing React and TypeScript skills while creating truly native mobile experiences. The combination of modern web technologies with native iOS capabilities provides the best of both worlds.

Key takeaways:

  • Tauri 2.0 enables native iOS development with web technologies
  • Proper configuration is crucial for iOS deployment
  • Zustand provides excellent state management for mobile apps
  • iOS-specific styling creates authentic mobile experiences
  • The development workflow is smooth once properly set up

The future of cross-platform mobile development is bright, and Tauri is leading the way in making native mobile development accessible to web developers everywhere.


Ready to build your own iOS app with Tauri? Start with this todo app template and customize it to fit your needs. The possibilities are endless!