Getting Started with @ductape/react
The @ductape/react package provides React hooks and components for building real-time applications with Ductape. Built on top of @ductape/client, it offers a React-idiomatic way to work with databases, storage, workflows, and more.
Installation
npm install @ductape/react @ductape/client
# or
yarn add @ductape/react @ductape/client
# or
pnpm add @ductape/react @ductape/client
Both @ductape/react and @ductape/client are required. The React package is a peer dependency wrapper around the core client.
Requirements
- React 17.0.0 or higher
- TypeScript 4.5+ (optional but recommended)
Basic Setup
In browser/frontend never use accessKey. Use publishableKey and baseUrl (your Ductape proxy). Get your publishable key from Workbench → Tokens → Publishable Key.
The design is: a long-lived publishable key (safe in the frontend) and a short-lived session token per user (created on your backend). Your backend calls ductape.sessions.start() with your access key (e.g. at user login) and returns the session token to the client. The frontend then includes that token as session in every Ductape request. Session hooks (useSessionStart, useSessionVerify, etc.) are not available with a publishable key—sessions are created only on the backend. See Sessions (backend) and Session Hooks.
1. Create a config (publishable key, product, env)
Use a long-lived publishable key in your frontend config. The session token is not part of this config—it comes from your backend (e.g. login response) and must be passed in every request (step 2).
// config.ts
export const baseUrl = import.meta.env.VITE_BASE_URL || 'https://api.ductape.app';
export const publishableKey = import.meta.env.VITE_PUBLISHABLE_KEY || '';
export const product = import.meta.env.VITE_PRODUCT || 'ductape:rematch';
export const env = import.meta.env.VITE_ENV || 'snd';
2. Wrap Your App with DuctapeProvider
import { DuctapeProvider } from '@ductape/react';
import { publishableKey, product, env } from './config';
function App() {
return (
<DuctapeProvider
config={{
publishableKey,
product,
env,
}}
autoConnect={false}
>
<YourApp />
</DuctapeProvider>
);
}
export default App;
3. Get the session token from your backend and pass it to every request
Your backend creates a session (e.g. at login) with the access key and returns the token to the frontend:
// Backend (e.g. login handler) — use @ductape/sdk with accessKey
const ductape = new Ductape({ accessKey: process.env.DUCTAPE_ACCESS_KEY });
const { token } = await ductape.sessions.start({
product: 'your-product',
env: 'prd',
tag: 'user-session',
data: { userId: user.id, email: user.email },
});
// Return { token } to the client in the login response
The frontend receives that token (e.g. from your auth state or API) and passes it as session in every Ductape request:
// Frontend: get session from your auth (e.g. login response / auth context)
const sessionToken = useAuth().sessionToken; // or from state/context after login
4. Use Hooks in Your Components (include session in every request)
import { useDatabaseQuery } from '@ductape/react';
import { useAuth } from './auth'; // your auth context that holds the session token from backend
function UsersList() {
const { sessionToken } = useAuth();
const { data, isLoading, error, refetch } = useDatabaseQuery(
'users',
{
table: 'users',
where: { active: true },
limit: 10,
session: sessionToken,
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={() => refetch()}>Refresh</button>
<ul>
{(data?.data ?? []).map((user: any) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Provider Configuration
The DuctapeProvider accepts these props:
interface DuctapeProviderProps {
config: {
publishableKey: string; // From Workbench → Tokens → Publishable Key
product?: string;
env?: string;
};
autoConnect?: boolean; // Auto-connect WebSocket (default: false)
children: React.ReactNode;
}
Session is not in the provider config. Pass the session token (from your backend) in the options for every hook call (e.g. useDatabaseQuery, useActionRun, useBrokerPublish, useMutation).
Environment Variables
Vite: use the VITE_ prefix so variables are exposed to the client. Only the publishable key and app config belong here. The session token must come from your backend (e.g. after login), not from env.
# .env (Vite)
VITE_PUBLISHABLE_KEY=dpk_xxx
VITE_PRODUCT=ductape:rematch
VITE_ENV=snd
// config.ts
export const publishableKey = import.meta.env.VITE_PUBLISHABLE_KEY || '';
export const product = import.meta.env.VITE_PRODUCT || 'ductape:rematch';
export const env = import.meta.env.VITE_ENV || 'snd';
For local development only, you may use a dev session token in env (e.g. VITE_SESSION=...) if your backend is not running; in production the session must always come from your backend.
Create React App: use REACT_APP_ prefix and process.env.REACT_APP_PUBLISHABLE_KEY, etc.
Quick Examples
In the examples below, sessionToken is the session token your frontend received from your backend (e.g. in the login response). Pass it in every request when using a publishable key.
Query Data
import { useDatabaseQuery } from '@ductape/react';
import { useAuth } from './auth'; // or wherever you store the backend-issued session token
function ProductList() {
const { sessionToken } = useAuth();
const { data, isLoading, error } = useDatabaseQuery(
'products',
{
table: 'products',
where: { category: 'electronics' },
orderBy: [{ column: 'price', order: 'asc' }],
limit: 20,
session: sessionToken,
}
);
if (isLoading) return <div>Loading products...</div>;
if (error) return <div>Error loading products</div>;
return (
<div>
{(data?.data ?? []).map((product: any) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
);
}
Insert Data
import { useDatabaseInsert } from '@ductape/react';
import { useState } from 'react';
import { useAuth } from './auth';
function CreateUser() {
const { sessionToken } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const { mutate, isLoading, error } = useDatabaseInsert({
onSuccess: (data) => {
console.log('User created:', data);
setName('');
setEmail('');
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutate({
table: 'users',
data: { name, email },
session: sessionToken,
});
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
required
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
type="email"
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create User'}
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
}
Real-time Subscription
import { useDatabaseSubscription } from '@ductape/react';
import { useState } from 'react';
import { useAuth } from './auth';
function LiveMessages() {
const { sessionToken } = useAuth();
const [messages, setMessages] = useState([]);
useDatabaseSubscription({
table: 'messages',
where: { channel: 'general' },
session: sessionToken,
onChange: (event) => {
if (event.type === 'insert') {
setMessages(prev => [...prev, event.data.new]);
}
}
});
return (
<ul>
{messages.map(msg => (
<li key={msg.id}>{msg.text}</li>
))}
</ul>
);
}
File Upload
import { useUpload } from '@ductape/react';
import { useAuth } from './auth';
function FileUploader() {
const { sessionToken } = useAuth();
const { upload, progress, isLoading, error } = useUpload();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
upload({
storage: 'gcp-storage',
fileName: `uploads/documents/${file.name}`,
data: file,
mimeType: file.type || 'application/octet-stream',
session: sessionToken,
});
}
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={isLoading}
/>
{isLoading && <p>Uploading: {progress?.percentage}%</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}
Execute Workflow
import { useWorkflowExecute } from '@ductape/react';
import { useAuth } from './auth';
function ProcessOrder({ orderId }: { orderId: string }) {
const { sessionToken } = useAuth();
const { mutate, isLoading, data, error } = useWorkflowExecute();
const handleProcess = () => {
mutate({
workflow: 'process-order',
input: { orderId },
session: sessionToken,
});
};
return (
<div>
<button onClick={handleProcess} disabled={isLoading}>
{isLoading ? 'Processing...' : 'Process Order'}
</button>
{data && <p>Execution ID: {data.executionId}</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}
Accessing the Client Directly
You can access the underlying Ductape client instance. Include the session token (from your backend) in every call when using a publishable key.
import { useDuctape } from '@ductape/react';
import { useAuth } from './auth';
function MyComponent() {
const { sessionToken } = useAuth();
const { client, isReady, isConnected } = useDuctape();
const handleCustomOperation = async () => {
const result = await client.databases.query({
table: 'custom_table',
session: sessionToken,
});
};
return (
<div>
<p>Ready: {isReady ? 'Yes' : 'No'}</p>
<p>Connected: {isConnected ? 'Yes' : 'No'}</p>
</div>
);
}
TypeScript Support
The hooks are fully typed. You can provide type parameters:
import { useAuth } from './auth';
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
function UsersList() {
const { sessionToken } = useAuth();
const { data, isLoading } = useDatabaseQuery<User>(
'users',
{
table: 'users',
limit: 10,
session: sessionToken,
}
);
// data.data is typed as User[]
return (
<ul>
{(data?.data ?? []).map(user => (
<li key={user.id}>
{user.name} - {user.role}
</li>
))}
</ul>
);
}
Error Boundaries
Wrap your components with error boundaries to handle errors gracefully:
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<DuctapeProvider config={...}>
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<YourApp />
</ErrorBoundary>
</DuctapeProvider>
);
}
Best Practices
-
Use query keys wisely: The first parameter to hooks like
useDatabaseQueryis a query key used for caching. Make it unique per query. -
Memoize query options: Use
useMemofor complex query options to prevent unnecessary re-renders:
import { useAuth } from './auth';
function UsersList() {
const { sessionToken } = useAuth();
const queryOptions = useMemo(
() => ({
table: 'users',
where: { active: true },
session: sessionToken,
}),
[sessionToken]
);
const { data } = useDatabaseQuery('users', queryOptions);
// ...
}
-
Handle loading and error states: Always provide UI feedback for loading and error states.
-
Cleanup subscriptions: Subscriptions automatically cleanup when components unmount.
-
Use TypeScript: Leverage TypeScript for type safety and better developer experience.
Next Steps
- Database Hooks
- Storage Hooks
- Workflow Hooks
- Real-time Subscriptions
- Session Hooks (backend/access-key only; with publishable key, pass
sessionin every request)