Fastn.ai React Core Documentation
A React library for integrating Fastn AI connectors into your application. It provides powerful hooks to manage:
- Connector listing, activation, and deactivation
- Configuration form rendering and submission
- Seamless integration with React Query for state management
This enables you to build fully custom UIs on top of Fastn's powerful data and logic engine.
Install the core library:
npm install @fastn-ai/react-core
Also, make sure you install the required peer dependencies:
npm install react react-dom @tanstack/react-query
✅ Requires React 18+
Before diving into the code, let's understand the key Fastn concepts and terminology:
A Space (also called Workspace) is the top-level container in Fastn that groups all your connectors, configurations, and data flows. Think of it as your project or organization's workspace where all integrations live.
A Tenant represents a user, team, or organization within your application. Each tenant has isolated data and configurations. For example:
- A single user account
- A team within your app
- An organization or company
- A client's workspace
A Connector represents an integration with an external service (like Slack, Google Drive, etc.). Connectors define what external services your app can connect to.
A Configuration is a specific instance of a connector with saved settings and authentication. For example:
- A Slack workspace connection with specific channels selected
- A Google Drive connection with specific folders configured
- A database connection with connection parameters
A Configuration ID is a unique identifier that represents a specific configuration instance. This ID is used to:
- Load existing configurations
- Update configuration settings
- Manage the lifecycle of a specific integration
- Connector Management: List, activate, and deactivate connectors
- Tenant Isolation: Each tenant has its own isolated connector state and configurations
-
Configuration Persistence: Save and retrieve configurations using unique
configurationId
s - Dynamic Forms: Render configuration forms using Fastn's form schema
- React Query Integration: Built-in support for efficient caching and request handling
- Authentication Flow: Handle OAuth and custom authentication flows seamlessly
This sets up Fastn in your app and gives you access to its hooks and logic.
import { FastnProvider } from "@fastn-ai/react-core";
const fastnConfig = {
environment: "LIVE", // "LIVE", "DRAFT", or a custom environment string for widgets
authToken: "your-auth-token", // Your app's access token, authenticated through Fastn Custom Auth
tenantId: "your-tenant-id", // A unique ID representing the user, team, or organization
spaceId: "your-space-id", // Fastn Space ID (also called Workspace ID) - groups all connectors and configurations
};
function App() {
return (
<FastnProvider config={fastnConfig}>
{/* Your app components */}
</FastnProvider>
);
}
Field | Description |
---|---|
environment |
The widget environment: use "LIVE" for production, "DRAFT" for preview/testing, or any custom string configured in Fastn |
authToken |
The token from your authentication flow. Fastn uses this to authenticate the user via the fastnCustomAuth flow |
tenantId |
A unique identifier for the current user or organization. This helps Fastn isolate data per tenant |
spaceId |
The Fastn Space ID, also called the Workspace ID. It groups all connectors, configurations, and flows |
If your app already uses React Query, you can pass your own client:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FastnProvider } from "@fastn-ai/react-core";
const queryClient = new QueryClient();
const fastnConfig = {
environment: "LIVE",
authToken: "your-auth-token",
tenantId: "your-tenant-id",
spaceId: "your-space-id",
};
function App() {
return (
<QueryClientProvider client={queryClient}>
<FastnProvider config={fastnConfig}>
{/* Your app components */}
</FastnProvider>
</QueryClientProvider>
);
}
interface Connector {
id: string;
name: string;
description: string;
imageUri?: string;
status: ConnectorStatus;
actions: ConnectorAction[];
}
enum ConnectorStatus {
ACTIVE = "ACTIVE",
INACTIVE = "INACTIVE",
ALL = "ALL",
}
interface ConnectorAction {
name: string;
actionType: ConnectorActionType | string;
onClick?: () => Promise<ConnectorActionResult>;
}
interface ConnectorActionResult {
status: "SUCCESS" | "ERROR" | "CANCELLED";
}
enum ConnectorActionType {
ACTIVATION = "ACTIVATION",
DEACTIVATION = "DEACTIVATION",
NONE = "NONE",
}
interface Configuration {
id: string;
connectorId: string;
name: string;
description: string;
imageUri?: string;
status: ConfigurationStatus;
actions: ConfigurationAction[];
}
enum ConfigurationStatus {
ENABLED = "ENABLED",
DISABLED = "DISABLED",
PENDING = "PENDING",
}
interface ConfigurationAction {
name: string;
actionType: ConfigurationActionType | string;
onClick?: () => Promise<ConfigurationActionResult>;
}
interface ConfigurationActionResult {
status: "SUCCESS" | "ERROR" | "CANCELLED";
}
enum ConfigurationActionType {
ENABLE = "ENABLE",
DISABLE = "DISABLE",
DELETE = "DELETE",
UPDATE = "UPDATE",
}
interface ConfigurationForm {
name: string;
description: string;
imageUri: string;
fields: ConnectorField[];
submitHandler: (formData: FormData) => Promise<void>;
}
type FormData =
| Record<
string,
Record<string, Primitive> | Record<string, Primitive>[] | undefined | null
>
| Record<
string,
Record<string, Primitive> | Record<string, Primitive>[] | undefined | null
>[]
| undefined
| null;
interface ConnectorField {
readonly name: string;
readonly key: string;
readonly label: string;
readonly type: ConnectorFieldType | string;
readonly required: boolean;
readonly placeholder: string;
readonly description: string;
readonly hidden?: boolean;
readonly disabled?: boolean;
readonly initialValue?:
| Record<string, Primitive>
| Record<string, Primitive>[]
| Primitive
| Primitive[];
readonly optionsSource?: SelectOptionSource;
}
Let's walk through a complete example of setting up a Slack integration from scratch.
First, show users what connectors are available:
import { useConnectors } from "@fastn-ai/react-core";
function ConnectorList() {
const { data: connectors, isLoading, error } = useConnectors();
if (isLoading) return <div>Loading available integrations...</div>;
if (error) return <div>Error loading connectors: {error.message}</div>;
return (
<div className="connector-grid">
<h2>Available Integrations</h2>
{connectors?.map((connector) => (
<div key={connector.id} className="connector-card">
<img src={connector.imageUri} alt={connector.name} />
<h3>{connector.name}</h3>
<p>{connector.description}</p>
{connector.status === "ACTIVE" && (
<span className="status-badge connected">Connected</span>
)}
{connector.actions?.map((action) => (
<button
key={action.name}
onClick={action.onClick}
className={`action-btn ${action.actionType.toLowerCase()}`}
>
{action.name}
</button>
))}
</div>
))}
</div>
);
}
After a connector is activated, you can list its configurations:
import { useConfigurations } from "@fastn-ai/react-core";
function ConfigurationList({ configurationId }) {
const { data: configurations, isLoading, error } = useConfigurations({ configurationId });
const [selectedConfig, setSelectedConfig] = useState(null);
if (isLoading) return <div>Loading configurations...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="configuration-list">
<h2>Your Integrations</h2>
{configurations?.map((config) => (
<div key={config.id} className="config-card">
<div className="config-info">
<img src={config.imageUri} alt={config.name} />
<div>
<h3>{config.name}</h3>
<p>{config.description}</p>
</div>
</div>
<div className="config-actions">
{config.status === "ENABLED" && (
<span className="status-badge enabled">Active</span>
)}
{config.actions?.map((action) => (
<button
key={action.name}
onClick={async () => {
const result = await action.onClick();
if (action.actionType === "ENABLE" && result?.status === "SUCCESS") {
// Show configuration form for new setup
setSelectedConfig(config);
} else if (action.actionType === "UPDATE" && result?.status === "SUCCESS") {
// Show configuration form for editing
setSelectedConfig(config);
}
}}
className={`action-btn ${action.actionType.toLowerCase()}`}
>
{action.name}
</button>
))}
</div>
</div>
))}
{selectedConfig && (
<ConfigurationForm
configurationId={selectedConfig.id}
onClose={() => setSelectedConfig(null)}
/>
)}
</div>
);
}
When a configuration is selected (either for new setup or editing), show the configuration form:
import { useConfigurationForm } from "@fastn-ai/react-core";
function ConfigurationForm({ configurationId, onClose }) {
const {
data: configurationForm,
isLoading,
error,
handleSubmit,
} = useConfigurationForm({ configurationId });
const [formData, setFormData] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Pre-populate form with existing values if editing
useEffect(() => {
if (configurationForm?.fields) {
const initialData = {};
configurationForm.fields.forEach((field) => {
if (field.initialValue !== undefined) {
initialData[field.key] = field.initialValue;
}
});
setFormData(initialData);
}
}, [configurationForm]);
if (isLoading) return <div>Loading configuration form...</div>;
if (error) return <div>Error: {error.message}</div>;
const onSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
await handleSubmit({ formData });
console.log("Configuration saved successfully!");
onClose();
} catch (error) {
console.error("Failed to save configuration:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="modal">
<form onSubmit={onSubmit} className="configuration-form">
<h2>Configure {configurationForm.name}</h2>
<p>{configurationForm.description}</p>
{configurationForm.fields.map((field) => (
<FormField
key={field.key}
field={field}
value={formData[field.key]}
onChange={(value) =>
setFormData((prev) => ({ ...prev, [field.key]: value }))
}
/>
))}
<div className="form-actions">
<button type="button" onClick={onClose}>Cancel</button>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Configuration"}
</button>
</div>
</form>
</div>
);
}
Now let's show how to manage existing configurations - viewing, editing, and disabling them.
import { useConfigurations } from "@fastn-ai/react-core";
function ConfigurationManager({ configurationId }) {
const {
data: configurations,
isLoading,
error,
} = useConfigurations({ configurationId });
const [selectedConfig, setSelectedConfig] = useState(null);
if (isLoading) return <div>Loading your integrations...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="configuration-manager">
<h2>Your Integrations</h2>
{configurations?.map((config) => (
<div key={config.id} className="config-card">
<div className="config-info">
<img src={config.imageUri} alt={config.name} />
<div>
<h3>{config.name}</h3>
<p>{config.description}</p>
</div>
</div>
<div className="config-actions">
{config.status === "ENABLED" && (
<span className="status-badge enabled">Active</span>
)}
{config.actions?.map((action) => (
<button
key={action.name}
onClick={async () => {
const result = await action.onClick();
if (
action.actionType === "UPDATE" &&
result?.status === "SUCCESS"
) {
setSelectedConfig(config);
}
}}
className={`action-btn ${action.actionType.toLowerCase()}`}
>
{action.name}
</button>
))}
</div>
</div>
))}
{selectedConfig && (
<ConfigurationForm
configurationId={selectedConfig.id}
onClose={() => setSelectedConfig(null)}
/>
)}
</div>
);
}
function ConfigActions({ config }) {
const handleDisable = async (action) => {
if (action.actionType === "DISABLE") {
const result = await action.onClick();
if (result?.status === "SUCCESS") {
console.log("Configuration disabled successfully");
// Refresh the configuration list
}
}
};
return (
<div className="config-actions">
{config.actions?.map((action) => (
<button
key={action.name}
onClick={() => handleDisable(action)}
className={`action-btn ${action.actionType.toLowerCase()}`}
>
{action.name}
</button>
))}
</div>
);
}
The form fields handle different value types based on the field type:
-
Select fields: Always return
{ label: string, value: string }
objects -
Multi-select fields: Always return
{ label: string, value: string }[]
arrays -
Google Drive picker fields: Always return
{ label: string, value: string }
objects or arrays - Other fields: Return primitive values (string, number, boolean)
// Example form data structure
const formData = {
// Select field - single object
channel: { label: "General", value: "C123456" },
// Multi-select field - array of objects
channels: [
{ label: "General", value: "C123456" },
{ label: "Random", value: "C789012" }
],
// Google Drive picker - single object
folder: { label: "My Documents", value: "folder_id_123" },
// Google Drive picker multi - array of objects
files: [
{ label: "document1.pdf", value: "file_id_1" },
{ label: "document2.pdf", value: "file_id_2" }
],
// Text field - primitive
webhookUrl: "https://hooks.slack.com/...",
// Boolean field - primitive
enableNotifications: true
};
For fields of type select
or multi-select
, use the useFieldOptions
hook to handle dynamic options loading. These fields always work with { label, value }
objects:
import { useFieldOptions } from "@fastn-ai/react-core";
function SelectField({ field, value, onChange, isMulti = false }) {
const {
options,
loading,
loadingMore,
hasNext,
loadMore,
error,
search,
totalLoadedOptions,
} = useFieldOptions(field);
function handleInputChange(e) {
search(e.target.value);
}
function handleLoadMore() {
if (hasNext && !loadingMore) loadMore();
}
function handleSelectChange(selectedOptions) {
if (isMulti) {
// For multi-select, value should be an array of { label, value } objects
const selectedValues = selectedOptions.map(option => ({
label: option.label,
value: option.value
}));
onChange(selectedValues);
} else {
// For single select, value should be a single { label, value } object
const selectedValue = selectedOptions[0] ? {
label: selectedOptions[0].label,
value: selectedOptions[0].value
} : null;
onChange(selectedValue);
}
}
return (
<div className="field-container">
<label className="field-label">
{field.label}
{field.required && <span className="required"> *</span>}
</label>
{error && (
<div className="error-message">
Error loading options: {error.message}
</div>
)}
<input
type="text"
placeholder={field.placeholder || `Search ${field.label}`}
onChange={handleInputChange}
disabled={loading}
className="search-input"
/>
<select
multiple={isMulti}
value={isMulti ? (value || []).map(v => v.value) : (value?.value || '')}
onChange={(e) => {
if (isMulti) {
const selectedOptions = Array.from(e.target.selectedOptions).map(option => {
const opt = options.find(o => o.value === option.value);
return { label: opt.label, value: opt.value };
});
handleSelectChange(selectedOptions);
} else {
const selectedOption = options.find(o => o.value === e.target.value);
handleSelectChange(selectedOption ? [selectedOption] : []);
}
}}
disabled={loading}
className="select-field"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{loading && <div className="loading">Loading options...</div>}
{hasNext && !loadingMore && (
<button
type="button"
onClick={handleLoadMore}
className="load-more-btn"
>
Load More
</button>
)}
{loadingMore && <div className="loading">Loading more...</div>}
<div className="options-info">
Loaded {totalLoadedOptions} options{hasNext ? "" : " (all loaded)"}
</div>
{field.description && (
<div className="field-description">{field.description}</div>
)}
</div>
);
}
For Google Drive file picker fields, handle the file selection flow. These fields also work with { label, value }
objects:
function GoogleFilesPickerField({ field, value, onChange, isMulti = false }) {
async function handlePickFiles() {
if (field.optionsSource?.openGoogleFilesPicker) {
await field.optionsSource.openGoogleFilesPicker({
onComplete: async (files) => {
if (isMulti) {
// For multi-select, ensure we have an array of { label, value } objects
const formattedFiles = files.map(file => ({
label: file.label || file.name || file.value,
value: file.value || file.id
}));
onChange(formattedFiles);
} else {
// For single select, ensure we have a single { label, value } object
const formattedFile = {
label: files[0]?.label || files[0]?.name || files[0]?.value,
value: files[0]?.value || files[0]?.id
};
onChange(formattedFile);
}
},
onError: async (pickerError) => {
console.error("Google Files Picker error:", pickerError);
alert("Failed to pick files: " + pickerError);
},
});
}
}
return (
<div className="field-container">
<label className="field-label">
{field.label}
{field.required && <span className="required"> *</span>}
</label>
<button
type="button"
onClick={handlePickFiles}
className="google-picker-btn"
>
Pick from Google Drive
</button>
{value && (
<div className="selected-files">
<strong>Selected file{isMulti ? "s" : ""}:</strong>
<ul>
{(isMulti ? value : [value]).map((file, idx) => (
<li key={file.value || idx}>{file.label || file.value}</li>
))}
</ul>
</div>
)}
{field.description && (
<div className="field-description">{field.description}</div>
)}
</div>
);
}
Create a reusable component that handles different field types with proper value handling:
function FormField({ field, value, onChange }) {
switch (field.type) {
case "text":
case "email":
case "password":
case "number":
return (
<div className="field-container">
<label className="field-label">
{field.label}
{field.required && <span className="required"> *</span>}
</label>
<input
type={field.type}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={field.disabled}
className="text-input"
/>
{field.description && (
<div className="field-description">{field.description}</div>
)}
</div>
);
case "checkbox":
return (
<div className="field-container">
<label className="field-label">
<input
type="checkbox"
checked={value || false}
onChange={(e) => onChange(e.target.checked)}
disabled={field.disabled}
className="checkbox-input"
/>
{field.label}
{field.required && <span className="required"> *</span>}
</label>
{field.description && (
<div className="field-description">{field.description}</div>
)}
</div>
);
case "select":
return (
<SelectField
field={field}
value={value}
onChange={onChange}
isMulti={false}
/>
);
case "multi-select":
return (
<SelectField
field={field}
value={value}
onChange={onChange}
isMulti={true}
/>
);
case "google-files-picker-select":
return (
<GoogleFilesPickerField
field={field}
value={value}
onChange={onChange}
isMulti={false}
/>
);
case "google-files-picker-multi-select":
return (
<GoogleFilesPickerField
field={field}
value={value}
onChange={onChange}
isMulti={true}
/>
);
default:
return (
<div className="field-container">
<label className="field-label">
{field.label} (Unsupported type: {field.type})
</label>
</div>
);
}
}
function ConnectorManager() {
const { data: connectors, isLoading, error, refetch } = useConnectors();
const [retryCount, setRetryCount] = useState(0);
const handleRetry = () => {
setRetryCount((prev) => prev + 1);
refetch();
};
if (isLoading) {
return (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading your integrations...</p>
</div>
);
}
if (error) {
return (
<div className="error-container">
<h3>Failed to load integrations</h3>
<p>{error.message}</p>
<button onClick={handleRetry} className="retry-btn">
Retry ({retryCount} attempts)
</button>
</div>
);
}
return (
<div className="connector-list">
{connectors?.map((connector) => (
<ConnectorCard key={connector.id} connector={connector} />
))}
</div>
);
}
function ConfigurationActions({ config }) {
const queryClient = useQueryClient();
const handleAction = async (action) => {
// Optimistically update the UI
queryClient.setQueryData(["configurations"], (oldData) => {
return oldData?.map((c) =>
c.id === config.id
? {
...c,
status: action.actionType === "ENABLE" ? "ENABLED" : "DISABLED",
}
: c
);
});
try {
const result = await action.onClick();
if (result?.status === "SUCCESS") {
// Invalidate and refetch to ensure consistency
queryClient.invalidateQueries(["configurations"]);
}
} catch (error) {
// Revert optimistic update on error
queryClient.invalidateQueries(["configurations"]);
console.error("Action failed:", error);
}
};
return (
<div className="config-actions">
{config.actions?.map((action) => (
<button
key={action.name}
onClick={() => handleAction(action)}
className={`action-btn ${action.actionType.toLowerCase()}`}
>
{action.name}
</button>
))}
</div>
);
}
-
"Invalid tenant ID" error
- Ensure your
tenantId
is a valid string and matches your user/organization identifier - Check that the tenant has proper permissions in your Fastn space
- Ensure your
-
"Space not found" error
- Verify your
spaceId
is correct - Ensure your auth token has access to the specified space
- Verify your
-
Configuration form not loading
- Check that the
configurationId
is valid and exists - Ensure the connector is properly activated before trying to configure it
- Check that the
-
Google Drive picker not working
- Verify Google Drive connector is properly configured in your Fastn space
- Check that the user has granted necessary permissions
Enable debug logging to troubleshoot issues:
const fastnConfig = {
environment: "LIVE",
authToken: "your-auth-token",
tenantId: "your-tenant-id",
spaceId: "your-space-id",
debug: true, // Enable debug logging
};
We welcome contributions! Please see our Contributing Guide for details.
This project is licensed under the MIT License - see the LICENSE file for details.