convex_dart 0.2.0
convex_dart: ^0.2.0 copied to clipboard
A Flutter package for Convex backend integration, enabling developers to interact with Convex services, manage data, and build applications with real-time synchronization.
convex_dart #
A Flutter package for seamless integration with Convex backends. This package provides type-safe, real-time connectivity to your Convex functions with automatic code generation and serialization.
Table of Contents #
- Features
- Installation
- Quick Start
- Advanced Usage
- CLI Integration
- Type System
- Performance & Best Practices
- Troubleshooting
- Contributing
Features #
- π Type Safety: Fully type-safe API calls with compile-time error checking
- β‘ Real-time: Built-in support for real-time subscriptions and live queries
- π Auto-generation: Automatic Dart client generation from your Convex schema
- π Cross-platform: Works on iOS, Android, Web, macOS, Windows, and Linux
- π¦ Zero Config: Minimal setup required - just generate and use
- π― Developer Experience: IntelliSense, auto-completion, and error handling
Installation #
Add convex_dart
and convex_dart_cli
to your pubspec.yaml
:
dependencies:
convex_dart: ^latest_version
dev_dependencies:
convex_dart_cli: ^latest_version
Or install via the command line:
flutter pub add dev:convex_dart_cli convex_dart
Quick Start #
1. Set up your Convex Backend #
First, create your Convex functions in TypeScript:
// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const getTasks = query({
args: {},
returns: v.array(v.object({
id: v.id("tasks"),
title: v.string(),
completed: v.boolean(),
createdAt: v.number(),
})),
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
export const createTask = mutation({
args: {
title: v.string(),
},
returns: v.id("tasks"),
handler: async (ctx, { title }) => {
return await ctx.db.insert("tasks", {
title,
completed: false,
createdAt: Date.now(),
});
},
});
export const toggleTaskCompletion = mutation({
args: {
id: v.id("tasks"),
},
handler: async (ctx, { id }) => {
const task = await ctx.db.get(id);
if (!task) {
throw new Error("Task not found");
}
await ctx.db.patch(id, {
completed: !task.completed,
});
},
});
2. Generate Dart Client #
Run the CLI tool to generate your Dart client:
dart run convex_dart_cli generate
This generates type-safe Dart functions in lib/src/convex/
:
3. Initialize in Your Flutter App #
// main.dart
import 'package:flutter/material.dart';
import 'package:your_app/src/convex/client.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Convex client
await ConvexClient.init();
runApp(MyApp());
}
4. Use in Your Widgets #
// lib/pages/tasks_page.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:your_app/src/convex/functions/tasks/getTasks.dart';
import 'package:your_app/src/convex/functions/tasks/createTask.dart';
class TasksPage extends StatefulWidget {
@override
_TasksPageState createState() => _TasksPageState();
}
class _TasksPageState extends State<TasksPage> {
final _controller = TextEditingController();
// Create a new task
Future<void> _createTask() async {
if (_controller.text.isNotEmpty) {
try {
await createTask((title: _controller.text));
_controller.clear();
// No need to reload - StreamBuilder will update automatically
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error creating task: $e')),
);
}
}
}
// Mark a task as completed or not completed
Future<void> _toggleTask(TasksId taskId) async {
try {
await toggleTaskCompletion((id: taskId));
print('Toggle task: $taskId');
// No need to reload - StreamBuilder will update automatically
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error toggling task: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Tasks')),
body: Column(
children: [
// Add new task
Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Enter task title',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _createTask(),
),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: _createTask,
child: Text('Add'),
),
],
),
),
// Tasks list with StreamBuilder
Expanded(
child: StreamBuilder<GetTasksResponse>(
stream: getTasksStream(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text('Error loading tasks'),
SizedBox(height: 8),
Text('${snapshot.error}'),
],
),
);
}
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
}
final tasks = snapshot.data!.body;
if (tasks.isEmpty) {
return Center(child: Text('No tasks yet'));
}
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
leading: Checkbox(
value: task.completed,
onChanged: (_) => _toggleTask(task._id),
),
title: Text(
task.title,
style: TextStyle(
decoration: task.completed
? TextDecoration.lineThrough
: null,
),
),
subtitle: Text(
'Created: ${DateTime.fromMillisecondsSinceEpoch(task._creationTime.toInt())}',
),
onTap: () => _toggleTask(task._id),
);
},
);
},
),
),
],
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Advanced Usage #
Complex Types and Unions #
Convex Dart supports all Convex types, including complex unions and nested objects:
// convex/users.ts
export const getUserProfile = query({
args: { userId: v.id("users") },
returns: v.union(
v.object({
type: v.literal("premium"),
user: v.object({
name: v.string(),
email: v.string(),
subscription: v.object({
plan: v.union(v.literal("monthly"), v.literal("yearly")),
expiresAt: v.number(),
}),
}),
}),
v.object({
type: v.literal("free"),
user: v.object({
name: v.string(),
email: v.string(),
trialEndsAt: v.optional(v.number()),
}),
}),
v.null(),
),
handler: async (ctx, { userId }) => {
// Implementation
},
});
The generated Dart code handles all type checking and serialization:
// Usage in Dart
final profile = await getUserProfile(
(userId: UsersId("user123"))
);
// Type-safe pattern matching
profile?.split(
(premium) {
print('Premium user: ${premium.user.name}');
print('Plan: ${premium.user.subscription.plan}');
},
(free) {
print('Free user: ${free.user.name}');
if (free.user.trialEndsAt.isDefined) {
print('Trial ends: ${free.user.trialEndsAt.value}');
}
},
() => print('User not found'),
);
Error Handling #
Convex Dart provides two main exception types for comprehensive error handling:
Note:
ConvexError
extendsConvexClientError
, so you can catchConvexClientError
to handle both types, or catch them separately for more specific handling.
ConvexError
Thrown when a TypeScript ConvexError
is thrown on the backend. This contains both the error message and any custom data payload.
try {
final result = await createUser(args);
} on ConvexError catch (e) {
// Handle application-specific errors from the backend
print('Application error: ${e.message}');
print('Error data: ${e.data}'); // Custom data from the backend
// Example: Handle specific error types based on data
if (e.data is Map && e.data['code'] == 'USER_EXISTS') {
showErrorSnackBar('User already exists');
} else {
showErrorSnackBar('Failed to create user: ${e.message}');
}
}
ConvexClientError
Thrown for all other types of errors, including:
- Network connectivity issues
- Internal client errors
- Server-side errors that aren't application-specific
- Authentication failures
- Invalid request parameters
try {
final result = await getUser(args);
} on ConvexClientError catch (e) {
// Handle client-side and system errors
print('Client error: ${e.message}');
}
Optional Values #
Convex Dart uses a type-safe Optional<T>
type for optional fields:
// Check if optional value is defined
if (user.profilePicture.isDefined) {
final url = user.profilePicture.value;
// Use the URL
}
// Provide a default value
final displayName = user.nickname.asDefined()?.value ?? user.name;
// Transform optional values
final uppercaseName = user.nickname.map((name) => name.toUpperCase());
CLI Integration #
The convex_dart_cli
tool integrates seamlessly with your development workflow.
It wraps the convex dev
command and generates the Dart client code when any changes are detected.
# Generate and watch for changes (basic usage)
convex_dart_cli generate
# Specify custom paths
convex_dart_cli generate --js ./my-convex-project --output ./lib/src/api
# Production mode
convex_dart_cli generate --prod
CLI Features #
- π Auto-regeneration: Monitors your Convex functions and regenerates Dart code automatically
- β‘ Fast builds: Incremental generation only rebuilds changed functions
- π οΈ Development integration: Runs
convex dev
in the background - π Helpful errors: Provides detailed error messages and troubleshooting tips
- π― Type validation: Ensures all Convex types are supported before generation
Type System #
Convex Dart provides complete type safety for all Convex types:
Convex Type | Dart Type |
---|---|
v.string() |
String |
v.number() |
double |
v.boolean() |
bool |
v.int64() |
int |
v.bytes() |
Uint8ListWithEquality |
v.id("table") |
TableId (e.g., TasksId ) |
v.any() |
dynamic |
v.null() |
void |
v.literal("value") |
Generated literal class |
v.optional(T) |
Optional<T> |
v.union(A, B, C) |
Union3<A, B, C> |
v.array(T) |
IList<T> |
v.record(K, V) |
IMap<K, V> |
v.object({...}) |
Generated record type |
Tips #
Undefined Values
This package strictly follows the TypeScript types. This means that there is a meaningful difference between null
and undefined
.
Types which are v.optional(T)
are Optional<T>
instead of T?
.
To avoid dealing with this complex type, consider using v.union(v.null(), T)
instead of v.optional(T)
.
This will return a T?
on the Dart side, which is more familiar to Dart developers:
// Instead of this:
args: { name: v.optional(v.string()) }
// Consider this:
args: { name: v.union(v.string(), v.null()) }
// Results in familiar nullable syntax:
final name = args.name; // String? instead of Optional<String>
Duplicate Stream Events
The Convex client can sometimes report the same event multiple times.
To avoid triggering unnecessary re-renders, use .distinct()
on the stream.
// Use distinct to avoid duplicate events
final stream = myQueryStream(args).distinct();
Troubleshooting #
Common Issues #
"ConvexClient not initialized"
// Ensure you call init() before using any functions
await ConvexClient.init();
"Function not found"
- Regenerate your Dart client:
convex_dart_cli generate
- Ensure your Convex function is exported
- Check that the function name matches exactly
"Type mismatch errors"
- Verify your Convex function return types match the generated Dart types
- Regenerate after changing Convex schemas
- Check for typos in field names
"Stream not updating"
- Ensure you're subscribing to a query (not a mutation or action)
- Check that your Convex function is properly exported
- Verify network connectivity
Contributing #
We welcome contributions! Please see our Contributing Guide for details.
License #
This project is licensed under the MIT License - see the LICENSE file for details.