enlace
v0.0.1-beta.4
Published
Type-safe API client with React hooks and Next.js integration.
Readme
enlace
Type-safe API client with React hooks and Next.js integration.
Installation
npm install enlaceQuick Start
import { createEnlaceHook, Endpoint } from "enlace";
type ApiSchema = {
posts: {
$get: Endpoint<Post[], ApiError>;
$post: Endpoint<Post, ApiError, CreatePost>;
_: {
$get: Endpoint<Post, ApiError>;
$delete: Endpoint<void, ApiError>;
};
};
};
const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");Schema Conventions
Defining a schema is recommended for full type safety, but optional. You can go without types:
// Without schema (untyped, but still works!)
const useAPI = createEnlaceHook("https://api.example.com");
const { data } = useAPI((api) => api.any.path.you.want.get());// With schema (recommended for type safety)
const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");Schema Structure
$get,$post,$put,$patch,$delete— HTTP method endpoints_— Dynamic path segment (e.g.,/users/:id)
import { Endpoint } from "enlace";
type ApiSchema = {
users: {
$get: Endpoint<User[], ApiError>; // GET /users
$post: Endpoint<User, ApiError>; // POST /users
_: { // /users/:id
$get: Endpoint<User, ApiError>; // GET /users/:id
$put: Endpoint<User, ApiError>; // PUT /users/:id
$delete: Endpoint<void, ApiError>; // DELETE /users/:id
profile: {
$get: Endpoint<Profile, ApiError>; // GET /users/:id/profile
};
};
};
};
// Usage
api.users.get(); // GET /users
api.users[123].get(); // GET /users/123
api.users[123].profile.get(); // GET /users/123/profileEndpoint Type
type Endpoint<TData, TError, TBody = never> = {
data: TData; // Response data type
error: TError; // Error response type (required)
body: TBody; // Request body type (optional)
};
// Examples
type GetUsers = Endpoint<User[], ApiError>; // GET, no body
type CreateUser = Endpoint<User, ApiError, CreateUserInput>; // POST with body
type DeleteUser = Endpoint<void, NotFoundError>; // DELETE, no response dataReact Hooks
Query Mode (Auto-Fetch)
For GET requests that fetch data automatically:
function Posts({ page, limit }: { page: number; limit: number }) {
const { data, loading, error, ok } = useAPI((api) =>
api.posts.get({ query: { page, limit, published: true } })
);
if (loading) return <div>Loading...</div>;
if (!ok) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Features:
- Auto-fetches on mount
- Re-fetches when dependencies change (no deps array needed!)
- Returns cached data while revalidating
- Request deduplication — identical requests from multiple components trigger only one fetch
Conditional Fetching
Skip fetching with the enabled option:
function ProductForm({ id }: { id: string | "new" }) {
// Skip fetching when creating a new product
const { data, loading } = useAPI(
(api) => api.products[id].get(),
{ enabled: id !== "new" }
);
if (id === "new") return <CreateProductForm />;
if (loading) return <div>Loading...</div>;
return <EditProductForm product={data} />;
}// Also useful when waiting for a dependency
function UserPosts({ userId }: { userId: string | undefined }) {
const { data } = useAPI(
(api) => api.users[userId!].posts.get(),
{ enabled: userId !== undefined }
);
}function Post({ id }: { id: number }) {
// Automatically re-fetches when `id` or query values change
const { data } = useAPI((api) => api.posts[id].get({ query: { include: "author" } }));
return <div>{data?.title}</div>;
}Request Deduplication
Multiple components requesting the same data will share a single network request:
// Both components render at the same time
function PostTitle({ id }: { id: number }) {
const { data } = useAPI((api) => api.posts[id].get());
return <h1>{data?.title}</h1>;
}
function PostBody({ id }: { id: number }) {
const { data } = useAPI((api) => api.posts[id].get());
return <p>{data?.body}</p>;
}
// Only ONE fetch request is made to GET /posts/123
// Both components share the same cached result
function PostPage() {
return (
<>
<PostTitle id={123} />
<PostBody id={123} />
</>
);
}Selector Mode (Manual Trigger)
For mutations or lazy-loaded requests:
function DeleteButton({ id }: { id: number }) {
const { trigger, loading } = useAPI((api) => api.posts[id].delete);
return (
<button onClick={() => trigger()} disabled={loading}>
{loading ? "Deleting..." : "Delete"}
</button>
);
}With request body:
function CreatePost() {
const { trigger, loading, data } = useAPI((api) => api.posts.post);
const handleSubmit = async (title: string) => {
const result = await trigger({ body: { title } });
if (result.ok) {
console.log("Created:", result.data);
}
};
return <button onClick={() => handleSubmit("New Post")}>Create</button>;
}Dynamic Path Parameters
Use :paramName syntax for dynamic IDs passed at trigger time:
function PostList({ posts }: { posts: Post[] }) {
// Define once with :id placeholder
const { trigger, loading } = useAPI((api) => api.posts[":id"].delete);
const handleDelete = (postId: number) => {
// Pass the actual ID when triggering
trigger({ pathParams: { id: postId } });
};
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<button onClick={() => handleDelete(post.id)} disabled={loading}>
Delete
</button>
</li>
))}
</ul>
);
}Multiple path parameters:
const { trigger } = useAPI((api) => api.users[":userId"].posts[":postId"].delete);
trigger({ pathParams: { userId: "1", postId: "42" } });
// → DELETE /users/1/posts/42With request body:
const { trigger } = useAPI((api) => api.products[":id"].patch);
trigger({
pathParams: { id: "123" },
body: { name: "Updated Product" },
});
// → PATCH /products/123 with bodyCaching & Auto-Revalidation
Automatic Cache Tags (Zero Config)
Tags are automatically generated from URL paths — no manual configuration needed:
// GET /posts → tags: ['posts']
// GET /posts/123 → tags: ['posts', 'posts/123']
// GET /users/5/posts → tags: ['users', 'users/5', 'users/5/posts']Mutations automatically revalidate matching tags:
const { trigger } = useAPI((api) => api.posts.post);
// POST /posts automatically revalidates 'posts' tag
// All queries with 'posts' tag will refetch!
trigger({ body: { title: "New Post" } });This means in most cases, you don't need to specify any tags manually. The cache just works.
How It Works
- Queries automatically cache with tags derived from the URL
- Mutations automatically revalidate tags derived from the URL
- All queries matching those tags refetch automatically
// Component A: fetches posts (cached with tag 'posts')
const { data } = useAPI((api) => api.posts.get());
// Component B: creates a post
const { trigger } = useAPI((api) => api.posts.post);
trigger({ body: { title: "New" } });
// → Automatically revalidates 'posts' tag
// → Component A refetches automatically!Stale Time
Control how long cached data is considered fresh:
const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com", {}, {
staleTime: 5000, // 5 seconds
});staleTime: 0(default) — Always revalidate on mountstaleTime: 5000— Data is fresh for 5 secondsstaleTime: Infinity— Never revalidate automatically
Manual Tag Override (Optional)
Override auto-generated tags when needed:
// Custom cache tags
const { data } = useAPI((api) => api.posts.get({ tags: ["my-custom-tag"] }));
// Custom revalidation tags
trigger({
body: { title: "New" },
revalidateTags: ["posts", "dashboard"], // Override auto-generated
});Disable Auto-Revalidation
const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com", {}, {
autoGenerateTags: false, // Disable auto tag generation
autoRevalidateTags: false, // Disable auto revalidation
});Hook Options
const useAPI = createEnlaceHook<ApiSchema>(
"https://api.example.com",
{
// Default fetch options
headers: { Authorization: "Bearer token" },
},
{
// Hook options
autoGenerateTags: true, // Auto-generate cache tags from URL
autoRevalidateTags: true, // Auto-revalidate after mutations
staleTime: 0, // Cache freshness duration (ms)
}
);Return Types
Query Mode
// Basic usage
const result = useAPI((api) => api.posts.get());
// With options
const result = useAPI(
(api) => api.posts.get(),
{ enabled: true } // Skip fetching when false
);
type UseEnlaceQueryResult<TData, TError> = {
loading: boolean; // No cached data and fetching
fetching: boolean; // Request in progress
ok: boolean | undefined;
data: TData | undefined;
error: TError | undefined;
};Selector Mode
type UseEnlaceSelectorResult<TMethod> = {
trigger: TMethod; // Function to trigger the request
loading: boolean;
fetching: boolean;
ok: boolean | undefined;
data: TData | undefined;
error: TError | undefined;
};Request Options
type RequestOptions = {
query?: Record<string, unknown>; // Query parameters
body?: TBody; // Request body
tags?: string[]; // Cache tags (GET only)
revalidateTags?: string[]; // Tags to invalidate after mutation
pathParams?: Record<string, string | number>; // Dynamic path parameters
};Next.js Integration
Server Components
Use createEnlace from enlace/next for server components:
import { createEnlace } from "enlace/next";
const api = createEnlace<ApiSchema>("https://api.example.com", {}, {
autoGenerateTags: true,
});
export default async function Page() {
const { data } = await api.posts.get({
revalidate: 60, // ISR: revalidate every 60 seconds
});
return <PostList posts={data} />;
}Client Components
Use createEnlaceHook from enlace/next/hook for client components:
"use client";
import { createEnlaceHook } from "enlace/next/hook";
const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");Server-Side Revalidation
Trigger Next.js cache revalidation after mutations:
// actions.ts
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function revalidateAction(tags: string[], paths: string[]) {
for (const tag of tags) {
revalidateTag(tag);
}
for (const path of paths) {
revalidatePath(path);
}
}// useAPI.ts
import { createEnlaceHook } from "enlace/next/hook";
import { revalidateAction } from "./actions";
const useAPI = createEnlaceHook<ApiSchema>("/api", {}, {
revalidator: revalidateAction,
});In components:
function CreatePost() {
const { trigger } = useAPI((api) => api.posts.post);
const handleCreate = () => {
trigger({
body: { title: "New Post" },
revalidateTags: ["posts"], // Passed to revalidator
revalidatePaths: ["/posts"], // Passed to revalidator
});
};
}Next.js Request Options
api.posts.get({
tags: ["posts"], // Next.js cache tags
revalidate: 60, // ISR revalidation (seconds)
revalidateTags: ["posts"], // Tags to invalidate after mutation
revalidatePaths: ["/"], // Paths to revalidate after mutation
skipRevalidator: false, // Skip server-side revalidation
});Relative URLs
Works with Next.js API routes:
// Client component calling /api/posts
const useAPI = createEnlaceHook<ApiSchema>("/api");API Reference
createEnlaceHook<TSchema>(baseUrl, options?, hookOptions?)
Creates a React hook for making API calls.
Parameters:
baseUrl— Base URL for requestsoptions— Default fetch options (headers, cache, etc.)hookOptions— Hook configuration
Hook Options:
type EnlaceHookOptions = {
autoGenerateTags?: boolean; // default: true
autoRevalidateTags?: boolean; // default: true
staleTime?: number; // default: 0
};Re-exports from enlace-core
Endpoint— Type helper for schema definitionEnlaceResponse— Response typeEnlaceOptions— Fetch options type
License
MIT
