"React"

React hooks for Tether with full TypeScript support and realtime subscriptions.

Installation

npm install @tthr/react

Setup

TetherProvider

Wrap your application with TetherProvider to make the Tether client available to all hooks:

import { TetherProvider } from '@tthr/react';

function App() {
  return (
    <TetherProvider
      url="https://tether-api.strands.gg"
      projectId="your-project-id"
      publishableKey="tthr_pk_xxxxxxxxxxxxx"
    >
      <MyApp />
    </TetherProvider>
  );
}

SPA mode (publishable key)

To call Tether functions directly from the browser without a backend proxy, use a publishable key instead of a secret API key:

<TetherProvider
  url="https://tether-api.strands.gg"
  projectId="your-project-id"
  publishableKey="tthr_pk_xxxxxxxxxxxxx"
  auth={{ mode: 'localstorage', key: 'auth_token' }}
>
  <MyApp />
</TetherProvider>

Publishable keys are safe for client-side code and are restricted by your domain allowlist. Function access levels control which functions are available -- see the Functions docs for details on public, authenticated, and internal access levels.

AuthTokenSource

The auth prop on TetherProvider accepts an AuthTokenSource configuration. This tells the SDK where to read the user's JWT for authenticated function calls:

// Read token from localStorage
auth={{ mode: 'localstorage', key: 'auth_token' }}

// Read a nested value from a JSON object in localStorage (dot-notation)
// e.g. reads localStorage key "session" and extracts .accessToken
auth={{ mode: 'localstorage', key: 'session.accessToken' }}

// Read token from a cookie
auth={{ mode: 'cookie', name: 'session_token' }}

// Provide a callback that returns the token
auth={{ mode: 'callback', fn: () => getTokenFromAuthProvider() }}

configure() -- outside the React tree

If you need to access Tether outside of React components (e.g. in utility files, API helpers, or third-party integrations), use configure() to set up a global client instance:

import { configure } from '@tthr/react';

configure({
  url: 'https://tether-api.strands.gg',
  projectId: 'your-project-id',
  publishableKey: 'tthr_pk_xxxxxxxxxxxxx',
  auth: { mode: 'localstorage', key: 'auth_token' },
});

Once configured, the standalone $query() and $mutation() functions can be used anywhere without needing the React context.

Hooks

useQuery

Subscribe to a query with reactive updates. Data is fetched on mount and automatically kept in sync via WebSocket:

import { useQuery } from '@tthr/react';
import { api } from '../_generated/api';

function PostList() {
  // With a generated API reference for full type safety
  const { data: posts, isLoading, error, refetch } = useQuery(api.posts.list);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {posts?.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

You can also pass arguments and use string-based query names:

// With arguments
const { data: todos } = useQuery(api.todos.list, { completed: false });

// With a query name string (no type inference)
const { data: todos } = useQuery('todos.list', { completed: false });

// refetch() manually re-fetches the data
const { data, refetch } = useQuery(api.todos.list);
await refetch();

useMutation

Execute mutations with loading and error state:

import { useMutation } from '@tthr/react';
import { api } from '../_generated/api';

function CreatePost() {
  const { mutate, isPending, error } = useMutation(api.posts.create, {
    invalidates: ['posts.list'],
  });

  return (
    <button
      onClick={() => mutate({ title: 'Hello World' })}
      disabled={isPending}
    >
      {isPending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

The invalidates option accepts an array of query names. When the mutation completes, any active useQuery subscriptions matching those names will automatically refetch their data.

$query & $mutation

For one-shot async calls without reactive state or subscriptions. Use these in event handlers, utility functions, or anywhere you just need the data:

import { $query, $mutation } from '@tthr/react';
import { api } from '../_generated/api';

// Fetch data directly — returns raw data, no state management
const messages = await $query(api.messages.listByChannel, { channelId });

// Execute a mutation directly — returns the result
const post = await $mutation(api.posts.create, { title: 'Hello' });

// Also works with string names
const todos = await $query('todos.list', { completed: false });

These standalone functions use the client configured via TetherProvider or configure(). They do not establish subscriptions or manage loading state.

Database proxy

When writing functions in tether/functions/, you get access to a typed database proxy via the db parameter. This provides a Prisma-style API for all your tables.

// tether/functions/todos.ts
import { query, mutation, z } from '../_generated/db';

export const list = query({
  handler: async ({ db }) => {
    return db.todos.findMany({
      where: { completed: false },
      orderBy: { createdAt: 'desc' },
      limit: 50,
    });
  },
});

export const create = mutation({
  args: z.object({ title: z.string().min(1) }),
  handler: async ({ args, db }) => {
    return db.todos.insert({
      title: args.title,
      completed: false,
    });
  },
});

export const complete = mutation({
  args: z.object({ _id: z.string() }),
  handler: async ({ args, db }) => {
    // Update by _id (system-generated primary key)
    return db.todos.update({
      where: { _id: args._id },
      data: { completed: true },
    });
  },
});

See the Database API for all available methods.

File storage

Storage operations require the Tether client instance. In a React app, you can use the vanilla @tthr/client directly for uploads, or handle them via your backend.

Client-side uploads

import { TetherClient } from '@tthr/client';

const client = new TetherClient({
  url: 'https://tether-api.strands.gg',
  projectId: 'your-project-id',
});

async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
  const file = event.target.files?.[0];
  if (!file) return;

  const assetId = await client.storage.upload(file, {
    metadata: { alt: 'User photo' },
    onProgress: ({ loaded, total }) => {
      console.log(`${Math.round(loaded / total * 100)}%`);
    }
  });
}

// Get download URL (expires after 1 hour)
const photoUrl = await client.storage.getUrl(assetId);

// Get asset metadata
const metadata = await client.storage.getMetadata(assetId);

// Delete an asset
await client.storage.delete(assetId);

Server-side (Next.js API routes)

// app/api/upload/route.ts
import { TetherServerClient } from '@tthr/client';

const tether = new TetherServerClient({
  url: process.env.TETHER_URL!,
  projectId: process.env.TETHER_PROJECT_ID!,
  apiKey: process.env.TETHER_API_KEY,
});

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File;
  if (!file) return Response.json({ error: 'No file' }, { status: 400 });

  const assetId = await tether.storage.upload(file, {
    metadata: { alt: 'Uploaded file' },
  });

  await tether.mutation('profile.update', { photoId: assetId });
  return Response.json({ assetId });
}

Server-side usage (Next.js)

For Next.js API routes, server actions, and server components, use TetherServerClient from @tthr/client. This client uses HTTP-only requests and does not establish WebSocket connections, making it suitable for serverless environments.

// lib/tether-server.ts
import { TetherServerClient } from '@tthr/client';

export const tether = new TetherServerClient({
  url: process.env.TETHER_URL!,
  projectId: process.env.TETHER_PROJECT_ID!,
  apiKey: process.env.TETHER_API_KEY,
});

Then use in any API route or server action:

// app/api/webhook/route.ts
import { tether } from '@/lib/tether-server';

export async function POST(request: Request) {
  const body = await request.json();

  // Execute queries
  const users = await tether.query('users.list');

  // Execute mutations
  await tether.mutation('users.update', { id: 1, status: 'active' });

  // Execute actions (external side effects)
  await tether.action('emails.sendWelcome', { userId: 1 });

  return Response.json({ ok: true });
}

Environment variables

Set your credentials in your .env.local file:

TETHER_URL=https://tether-api.strands.gg
TETHER_PROJECT_ID=your-project-id
TETHER_API_KEY=tthr_live_xxxxxxxxxxxxx

The API key should only be used server-side and is never exposed to the client. Use tthr_live_ keys for production and tthr_dev_ keys for development.

Server client vs browser client

The server client uses HTTP-only requests and does not establish WebSocket connections. This makes it lightweight and suitable for serverless environments like Next.js API routes, Vercel functions, or Cloudflare Workers.

// Available methods on server client:
tether.query(name, args)      // Read data
tether.mutation(name, args)   // Write data
tether.action(name, args)     // External side effects
tether.storage.*              // Full storage API

Full example

A complete React component using queries and mutations together:

import { useQuery, useMutation } from '@tthr/react';
import { api } from '../_generated/api';
import { useState } from 'react';

function TodoApp() {
  const [title, setTitle] = useState('');
  const { data: todos, isLoading } = useQuery(api.todos.list, {
    completed: false,
  });

  const { mutate: createTodo, isPending: isCreating } = useMutation(
    api.todos.create,
    { invalidates: ['todos.list'] },
  );

  const { mutate: completeTodo } = useMutation(
    api.todos.complete,
    { invalidates: ['todos.list'] },
  );

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!title.trim()) return;
    await createTodo({ title });
    setTitle('');
  }

  if (isLoading) return <p>Loading...</p>;

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          value={title}
          onChange={e => setTitle(e.target.value)}
          placeholder="What needs doing?"
          disabled={isCreating}
        />
        <button type="submit" disabled={isCreating}>
          {isCreating ? 'Adding...' : 'Add'}
        </button>
      </form>
      <ul>
        {todos?.map(todo => (
          <li key={todo._id}>
            <span>{todo.title}</span>
            <button onClick={() => completeTodo({ _id: todo._id })}>
              Done
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

TypeScript

The SDK is fully typed. Run tthr generate to create types from your schema for full autocomplete on queries and mutations.