tanstack-effect
v0.1.0
Published
React + Tanstack Query + Effect Fullstack e2e queries with validation
Maintainers
Readme
Tanstack Effect
Bun + Npm + Typescript + Standard Version + Flat Config Linting + Husky + Commit / Release Pipeline OpenAPI + Swagger UI + Tanstack Query + Effect Schemas
Summary
This package contains < tanstack effect > for MG.
Check out the Changelog to see what changed in the last releases.
Install
bun add tanstack-effectInstall Bun ( bun is the default package manager for this project ( its optional ) ):
# Supported on macOS, Linux, and WSL
curl -fsSL https://bun.sh/install | bash
# Upgrade Bun every once in a while
bun upgradeUsage
The library is designed for a typed server-client workflow using Effect's HttpApi.
- Use the client-safe hooks on the frontend
// You need to import the shared file for the routes to register in runtime
import './shared'
import {
useEffectMutation,
useEffectQuery,
useSchemaForm,
} from 'tanstack-effect/client'
import { UserSchema } from './shared'
export default function Page() {
const user = useEffectQuery(
'user',
'user',
{
path: {
username: 'test',
},
},
{
includeCredentials: true,
noCache: false,
}
)
const form = useSchemaForm({
schema: UserSchema,
initialData: user.data,
})
const updateUser = useEffectMutation('user', 'updateUser', {
onSuccess: () => {
console.log('Updated User')
},
})
const handleSubmit = () => {
if (!form.data || !user.data?.username) return
updateUser.mutate({
path: {
username: user.data.username,
},
payload: form.data,
})
}
return (
<div className="space-y-4">
<h1>User: {user.data?.username}</h1>
<h1>Update User</h1>
{/* Use form.fields, form.data, form.updateField, form.validationErrors
to build your own form UI. See https://www.npmjs.com/package/liquidcn for a FormBuilder example. */}
<button onClick={handleSubmit}>Update</button>
</div>
)
}Available client hooks:
useEffectQueryuseEffectInfiniteQueryuseEffectMutationuseSchemaForm(supports optional AI integration viaaiconfig)
Import them from tanstack-effect/client. The main entry tanstack-effect is server-safe and used to build the typed client from your HttpApi definition.
Schema-driven forms (Form Builder + Hook)
Build forms directly from your Effect Schema:
useSchemaFormhook manages form state, validation, and field updates- For a complete
FormBuilderUI implementation, see liquidcn
The hook provides form.fields, form.data, form.updateField, and form.validationErrors for building your own form UI.
AI-Powered Form Filling (Optional)
Use AI to fill forms from natural language descriptions. The AI extracts structured data from prompts and generates clarification questions for missing required fields.
Requirements: Install optional peer dependencies:
bun add ai @ai-sdk/google zodSet your Google AI API key in your environment:
export GOOGLE_GENERATIVE_AI_API_KEY="your-key"Server-side: Create an API route
// app/api/ai-form-fill/route.ts
import { createAIFormFillerHandler } from 'tanstack-effect'
const handler = createAIFormFillerHandler()
export const POST = handlerClient-side: Enable AI on useSchemaForm
Simply add the ai option to useSchemaForm to enable AI form filling:
import { useSchemaForm } from 'tanstack-effect/client'
import { Schema } from 'effect'
const ProjectSchema = Schema.Struct({
projectName: Schema.String.pipe(
Schema.annotations({ description: 'Name of the project' })
),
projectType: Schema.Literal('web', 'mobile', 'desktop').pipe(
Schema.annotations({ description: 'Type of project' })
),
teamSize: Schema.Number.pipe(
Schema.annotations({ description: 'Number of team members' })
),
})
function ProjectForm() {
const form = useSchemaForm({
schema: ProjectSchema,
// Enable AI by providing the endpoint
ai: {
endpoint: '/api/ai-form-fill',
},
})
return (
<div>
{/* AI chat input */}
<input
placeholder="Describe your project..."
onKeyDown={(e) => {
if (e.key === 'Enter' && form.ai) {
form.ai.fill(e.currentTarget.value)
}
}}
/>
{form.ai?.status === 'filling' && <p>AI is filling the form...</p>}
{/* Show AI summary when complete */}
{form.ai?.summary && <p>{form.ai.summary}</p>}
{/* Clarification questions */}
{form.ai?.clarifications.map((q) => (
<div key={q.field}>
<p>{q.question}</p>
{q.options?.map((opt) => (
<button
key={opt.value}
onClick={() => form.ai?.answer(q.field, opt.value)}
>
{opt.label}
</button>
))}
</div>
))}
{/* Form data is available in form.data */}
{form.data && <pre>{JSON.stringify(form.data, null, 2)}</pre>}
</div>
)
}AI state and actions
When ai is configured, form.ai provides:
| Property | Type | Description |
|----------|------|-------------|
| status | 'idle' \| 'filling' \| 'clarifying' \| 'complete' \| 'error' | Current AI status |
| messages | AIFormMessage[] | Conversation history |
| clarifications | ClarificationQuestion[] | Pending questions for user |
| summary | string \| null | Human-readable summary (e.g. "Filled 3 fields: projectName, projectType, teamSize") |
| fill(prompt) | (prompt: string) => Promise<void> | Fill form from natural language |
| answer(field, value) | (field: string, value: unknown) => Promise<void> | Answer a clarification |
| ask(question) | (question: string) => Promise<string> | Ask a follow-up question |
| reset() | () => void | Reset AI state and messages |
How it works
- User provides a natural language prompt: "Building a mobile app called TravelBuddy with 5 developers"
- AI extracts structured data:
{ projectName: "TravelBuddy", projectType: "mobile", teamSize: 5 } form.datais updated automatically with the filled values- If required fields are missing,
form.ai.clarificationscontains questions - User answers clarifications, AI fills remaining fields
form.ai.summaryshows what was filled (e.g. "Filled 3 fields: projectName, projectType, teamSize")
The AI respects your Effect Schema:
- Enum/literal types are constrained to valid options
- Field descriptions help the AI understand what data to extract
- Required vs optional fields determine when clarifications are needed
Important: Schema Annotations with optionalWith
When using Schema.optionalWith() for optional fields with defaults, annotations must be placed on the inner Schema type, not on the optionalWith result. This is due to how Effect Schema handles PropertySignature annotations internally.
Correct pattern (annotations preserved):
import { Schema } from 'effect'
const MySchema = Schema.Struct({
// ✅ Annotations on the inner Schema type - WORKS
maxItems: Schema.optionalWith(
Schema.Number.annotations({
title: 'Max Items',
description: 'Maximum number of items to process',
}),
{ default: () => 50 }
),
// ✅ Using a pre-defined annotated Schema - WORKS
logLevel: Schema.optionalWith(LogLevel, { default: () => 'info' }),
})Incorrect pattern (annotations lost):
const MySchema = Schema.Struct({
// ❌ Annotations on optionalWith result - DOES NOT WORK
maxItems: Schema.optionalWith(Schema.Number, {
default: () => 50,
}).annotations({
title: 'Max Items',
description: 'Maximum number of items to process',
}),
})This limitation exists because Schema.optionalWith() returns a PropertySignature, and calling .annotations() on a PropertySignature doesn't store annotations in the AST in an accessible way. By placing annotations on the inner Schema type, the annotations are preserved and can be extracted by the form builder.
- Define your API on the shared file and generate the client type
import { HttpApi, HttpApiEndpoint, HttpApiGroup } from '@effect/platform'
import { Schema } from 'effect'
import { getTanstackEffectClient } from 'tanstack-effect'
// Base user schema - i.e. the schema which would be in the database
export const UserSchema = Schema.Struct({
username: Schema.String,
name: Schema.String,
surname: Schema.String,
email: Schema.String,
})
// Path params for the user endpoint
export const GetUserPathParams = Schema.Struct({
username: Schema.String,
})
// Update user request - i.e. the schema which would be sent to the server
export const UpdateUserRequest = Schema.partial(UserSchema)
export const userGroup = HttpApiGroup.make('user')
.add(
HttpApiEndpoint.get('user', '/:username')
.setPath(GetUserPathParams)
.addSuccess(UserSchema)
)
.add(
HttpApiEndpoint.put('updateUser', '/:username')
.setPath(GetUserPathParams)
.setPayload(UpdateUserRequest)
.addSuccess(UserSchema)
)
.prefix('/user')
const ErrorResponse = Schema.Struct({
message: Schema.String,
})
// Define the API contract
export const Api = HttpApi.make('Api')
// Define global errors that apply to all endpoints
.addError(ErrorResponse, { status: 400 }) // Bad Request
.addError(ErrorResponse, { status: 401 }) // Unauthorized
.addError(ErrorResponse, { status: 403 }) // Forbidden
.addError(ErrorResponse, { status: 404 }) // Not Found
.addError(ErrorResponse, { status: 500 }) // Internal Server Error
.addError(ErrorResponse, { status: 503 }) // Service Unavailable
.addError(ErrorResponse, { status: 504 }) // Gateway Timeout
.addError(ErrorResponse, { status: 429 }) // Too Many Requests
.addError(ErrorResponse, { status: 405 }) // Method Not Allowed
.addError(ErrorResponse, { status: 406 }) // Not Acceptable
.addError(ErrorResponse, { status: 408 }) // Request Timeout
.addError(ErrorResponse, { status: 409 }) // Conflict
.add(userGroup)
export class TanstackEffectClient extends getTanstackEffectClient(Api) {}
export type TTanstackEffectClient = TanstackEffectClient['client']- Augment the
tanstack-effectclient interface in a.d.ts
Place a declaration file accessible to your app (e.g. src/types/tanstack-effect.d.ts) and ensure your tsconfig.json includes it.
import type { TTanstackEffectClient as Client } from './shared'
declare module 'tanstack-effect' {
interface TTanstackEffectClient extends Client {}
}- Set up the route for the API (required)
import type { GetCleanSuccessType, GetRequestParams } from 'tanstack-effect'
// Mock Hono class / for demonstration purposes
class Hono {
[key: string]: any
}
// Minimal mock server to serve the OpenAPI spec
const app = new Hono()
// Get user route like its defined in the schema
app.get('/user/:username', async (c: any) => {
const { username } = c.req.param()
// Some function to get the user
const request = async (
params: GetRequestParams<'user', 'user'>
): Promise<GetCleanSuccessType<'user', 'user'>> => {
// Some logic to get the user
return {} as any
}
const user = await request({
path: {
username,
},
})
return c.json(user)
})
// Update user route like its defined in the schema
app.put('/user/:username', async (c: any) => {
const { username } = c.req.param()
const body: GetRequestParams<'user', 'updateUser'>['payload'] =
await c.req.json()
// Some function to update the user
const request = async (
params: GetRequestParams<'user', 'updateUser'>
): Promise<GetCleanSuccessType<'user', 'updateUser'>> => {
// Some logic to update the user
return {} as any
}
const updatedUser = await request({
path: {
username,
},
payload: body,
})
return c.json(updatedUser)
})
export default app- Set up OpenAPI documentation (optional)
import { OpenApi } from '@effect/platform'
// Importing TanstackEffectClient to mirror real-world usage where this is the API import equivalent
import { Api } from './shared'
// Mock Hono class / for demonstration purposes
class Hono {
[key: string]: any
}
// Minimal mock server to serve the OpenAPI spec
const app = new Hono()
app.get('/docs/openapi.json', (c: any) => {
const spec = OpenApi.fromApi(Api)
return c.json(spec)
})
app.get('/docs', (c: any) =>
c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '/docs/openapi.json',
dom_id: '#swagger-ui',
});
};
</script>
</body>
</html>`)
)
export default {
port: 8080,
fetch: app.fetch,
}Developing
Install Dependencies:
bun iWatching TS Problems:
bun watchFormat / Lint / Type Check:
bun format
bun lint
bun type-checkHow to make a release
For the Maintainer: Add NPM_TOKEN to the GitHub Secrets.
- PR with changes
- Merge PR into main
- Checkout main
git pullbun release: '' | alpha | betaoptionally add-- --release-as minor | major | 0.0.1- Make sure everything looks good (e.g. in CHANGELOG.md)
- Lastly run
bun release:pub - Done
License
This package is licensed - see the LICENSE file for details.
