便り · news from afar
An opinionated React client-side data fetching stack built on top of SWR, for Hey API and ConnectRPC backends.
const { data, error } = useData(
getAllPlanets,
{
query: { page: 1, per_page: 20 }
}
);const { data, error } = useData(
getAllPlanets,
searchQuery
? { query: { q: searchQuery } }
: null
);const { data, setSize } = useInfinite(
getAllPlanets,
(i, prev) => (i > 0 && !prev?.cursor
? null // reached the end
: { query: { cursor: prev?.cursor } })
);const { data, error, isLoading } = useData(
ElizaService.method.say,
{ message: { sentence: 'Hello from tayori' } }
);
// data is typed as SayResponse | undefinedconst { trigger, isMutating } = useMutation(
PlanetService.method.createPlanet
);
await trigger({
message: { name: 'Mars' },
headers: { 'x-request-id': requestId }
});LLM friendly version of the documentation (both modes) can be found at /llms-full.txt.
tayori comes with two backends: tayori for the SDK that Hey API generates from your OpenAPI specification, and tayori-connect for ConnectRPC services. Pick the one that matches your API with the switch at the top of this page, and every tab below follows your choice.
npm install tayori
pnpm install tayori
yarn add tayorinpm install tayori-connect @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf
pnpm install tayori-connect @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf
yarn add tayori-connect @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuftayori-connect is built for @connectrpc/connect v2 and @bufbuild/protobuf v2. @connectrpc/connect-web provides the transports for browsers (Connect protocol and gRPC-web).
react (19.2 or later) and swr (2.6 or later) are peer dependencies.
In your Hey API configuration file (openapi-ts.config.ts), modify a few settings:
responseStyle of the @hey-api/sdk plugin can be either fields (the default) or data (Hey API only supports it with @hey-api/client-fetch). The hooks are typed the same in both styles.throwOnError and includeInEntry in your chosen Hey API client plugin options (e.g. @hey-api/client-ky, @hey-api/client-fetch, etc.)import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
// other options...
plugins: [
{
name: '@hey-api/sdk',
// ... other options
responseStyle: 'fields' // or 'data', both work with tayori
},
{
name: '@hey-api/client-ky',
// or any of your chosen Hey API client plugin
// ...other options
throwOnError: true,
includeInEntry: true
},
// ...other plugins
]
});tayori-connect works with the service descriptors generated by @bufbuild/protoc-gen-es v2. Install @bufbuild/buf and @bufbuild/protoc-gen-es as dev dependencies and add a buf.gen.yaml to your project:
# buf.gen.yaml
version: v2
inputs:
- directory: proto
plugins:
- local: protoc-gen-es
out: src/gen
opt: target=tsThen run buf generate (or npx buf generate buf.build/connectrpc/eliza to try things out with the Eliza demo service from the Buf Schema Registry). You do not need the protoc-gen-connect-es plugin: since v2, protoc-gen-es generates the service descriptors itself, so ElizaService.method.say is available right out of the generated eliza_pb.ts file.
Throughout this documentation, the examples use connectrpc.eliza.v1.ElizaService (its Say method takes a SayRequest { sentence } and returns a SayResponse { sentence }, and a public demo server is available at https://demo.connectrpc.com), plus an invented PlanetService for pagination and mutation examples:
// proto/planets/v1/planets.proto
syntax = "proto3";
package planets.v1;
service PlanetService {
rpc GetPlanet(GetPlanetRequest) returns (Planet);
rpc ListPlanets(ListPlanetsRequest) returns (ListPlanetsResponse);
rpc CreatePlanet(CreatePlanetRequest) returns (Planet);
rpc UpdatePlanet(UpdatePlanetRequest) returns (Planet);
}
message Planet {
string id = 1;
string name = 2;
}
message GetPlanetRequest { string id = 1; }
message ListPlanetsRequest {
int32 page_size = 1;
string page_token = 2;
}
message ListPlanetsResponse {
repeated Planet planets = 1;
string next_page_token = 2;
}
message CreatePlanetRequest { string name = 1; }
message UpdatePlanetRequest {
string id = 1;
string name = 2;
}// src/lib/tayori.ts
'use client';
import { tayori } from 'tayori';
import { SWRConfig } from 'swr';
import { createClient } from 'path/to/hey-api-generated-sdk/client';
import type { Options } from 'path/to/hey-api-generated-sdk';
import type { RequestResult } from 'path/to/hey-api-generated-sdk/client';
export const {
TayoriProvider,
useData,
useDataImmutable,
useInfinite,
useMutation,
usePreload
} = tayori<Options, RequestResult>();
export function DataFetchingProvider({ children }: React.PropsWithChildren) {
// Since you are initializing the Hey API client within React, you have access
// to React context and hooks and inject them into your client, e.g. auth:
const { getAccessTokenSilently } = useAuth0();
return (
<TayoriProvider
// initClient is an function that only runs once to initialize your Hey API
// client instance across your app.
initClient={() => createClient({
// ensure `throwOnError` is enabled in your Hey API client
throwOnError: true,
// you can inject auth from React into your client here
async auth() {
return getAccessTokenSilently();
},
// other Hey API client options...
baseUrl: API_URL,
})}
>
<SWRConfig
// you can also include an optional <SWRConfig /> to configure your own
// SWR options here, e.g. global error handling, custom cache provider, etc.
value={{}}
>
{children}
</SWRConfig>
</TayoriProvider>
)
}tayori() optionally accepts your generated Options and RequestResult types: every hook infers its request options and its response type from the SDK function you pass to it, so tayori() without type arguments gives you the same hooks.
By initializing the Hey API client within React through <TayoriProvider />, you get access to React context and hooks within your Hey API client, which provides great flexibility for handling auth and other dynamic configurations.
// src/lib/tayori.ts
'use client';
import { tayoriConnect } from 'tayori-connect';
import { createConnectTransport } from '@connectrpc/connect-web';
import { SWRConfig } from 'swr';
export const {
TayoriProvider,
useData,
useDataImmutable,
useInfinite,
useMutation,
usePreload,
useTransport
} = tayoriConnect();
export function DataFetchingProvider({ children }: React.PropsWithChildren) {
// Since you are initializing the Connect transport within React, you have access
// to React context and hooks and can inject them into your transport, e.g. auth:
const { getAccessTokenSilently } = useAuth0();
return (
<TayoriProvider
// initTransport is a function that only runs once to create the Connect
// transport shared by every tayori-connect hook in this subtree.
initTransport={() => createConnectTransport({
baseUrl: API_URL,
interceptors: [
// interceptors run on every request, so the token is always fresh
(next) => async (req) => {
req.header.set('Authorization', `Bearer ${await getAccessTokenSilently()}`);
return next(req);
}
]
// other Connect transport options, e.g. useBinaryFormat, jsonOptions...
})}
>
<SWRConfig
// you can also include an optional <SWRConfig /> to configure your own
// SWR options here, e.g. global error handling, custom cache provider, etc.
value={{}}
>
{children}
</SWRConfig>
</TayoriProvider>
);
}tayoriConnect() creates an isolated set of hooks plus the matching <TayoriProvider />. Call it once at module level and export the result. The transport can be anything that implements Connect's Transport interface: createConnectTransport or createGrpcWebTransport from @connectrpc/connect-web, the Node.js transports from @connectrpc/connect-node, or a transport you wrote yourself.
By creating the transport within React through <TayoriProvider />, you get access to React context and hooks within your interceptors, which provides great flexibility for handling auth and other dynamic configurations.
Wrap your app with the DataFetchingProvider you just created. You don't have to wrap your entire app with it, just make sure all your components that are fetching data are wrapped.
import { DataFetchingProvider } from '../lib/tayori';
export default function DashboardLayout({ children }: React.PropsWithChildren) {
return (
<DataFetchingProvider>
{children}
</DataFetchingProvider>
);
}Here is an example file structure for Next.js App Router:
app/
├── (marketing) ← route group w/o data fetching
│ ├── page.tsx
│ └── blog/
│ └── page.tsx
│
├── (dashboard) ← route group w/ data fetching
│ ├── layout.tsx ← wrap with <DataFetchingProvider /> here
│ └── page.tsx
│
└── layout.tsx ← your root layout with <html /> and <body />Using more than one client
initClientonly runs once per<TayoriProvider />instance. If a part of your app talks to a different API, or needs a differently configured client (anotherbaseUrl, another auth scheme, ...), nest another<TayoriProvider initClient={...} />around that subtree. Requests made through different providers get their own cache entries and never collide, even when they call the same SDK method with the same request options.
Using more than one transport
initTransportonly runs once per<TayoriProvider />instance. If a part of your app talks to a different Connect server, or needs a differently configured transport (gRPC-web instead of the Connect protocol, anotherbaseUrl, ...), nest another<TayoriProvider initTransport={...} />around that subtree. Requests made through different providers get their own cache entries and never collide, even when they call the same method with the same request.
When one client serves every account, because its auth reads the current credential, the responses of the previous account stay cached. Clear the SWR cache whenever the account changes, with unload from useSWRConfig():
When one transport serves every account, because its interceptor reads the current credential, the responses of the previous account stay cached. Clear the SWR cache whenever the account changes, with unload from useSWRConfig():
import { useSWRConfig } from 'swr';
function useSignOut() {
const { unload } = useSWRConfig();
return async () => {
await signOut();
// drop every cached response of the previous account, useInfinite lists included
unload({ revalidate: false });
};
}unload rather than mutate(() => true, undefined). A filter mutate skips the entries of useInfinite (and useSWRSubscription), so the previous account's lists would stay in the cache. unload drops every entry, and stops requests that are still in flight from writing their results back.unload from useSWRConfig(). The unload exported by swr only clears SWR's default cache, not the cache of a custom provider.unload can still write its error into the cleared cache.import { useData, usePreload } from './lib/tayori';
import { getAllPlanets } from 'path/to/hey-api-generated-sdk';
const preload = usePreload();
const { data, error, isLoading, mutate } = useData(
getAllPlanets,
{
// Hey API request options, with type safety and IDE autocompletion!
query: {}
}
);This is the very fundamental API of tayori. It accepts a Hey API generated SDK method (e.g. getAllPlanets, getPlanetById, etc.) and the corresponding request options (the arguments of that SDK method).
import { useData } from './lib/tayori';
import { ElizaService } from './gen/connectrpc/eliza/v1/eliza_pb';
const { data, error, isLoading, mutate } = useData(
ElizaService.method.say,
{
// the request message, with type safety and IDE autocompletion!
message: { sentence: 'Hello' }
}
);
data; // SayResponse | undefinedThis is the very fundamental API of tayori-connect. It mirrors how you would call a method with a Connect client (client.say({ sentence: 'Hello' }, { headers })), with two differences. First, the method is passed explicitly: the first argument is a unary method descriptor from your generated code (ElizaService.method.say, PlanetService.method.listPlanets, etc.). Second, the request message and Connect's per-call options are gathered into a single request object, the second argument: message is the request message, a plain object exactly like what you would pass to create(SayRequestSchema, ...) (there is no need to construct the message yourself; message is also Connect's own name for it, see UnaryRequest.message), and the other fields (headers, timeoutMs, ..., see Request Options and SWR Options below) are Connect's per-call options. For methods whose request message has no fields, pass { message: {} }: message is required on purpose, so that a request object that forgot its message cannot silently send an empty one.
The response will be passed as data and the error will be passed as error, just like SWR.
Equivalent requests share one cache entry: fields left at their default value don't matter. For a request message with a pageToken string field, { message: { pageSize: 20 } } and { message: { pageSize: 20, pageToken: '' } } are the same request.
We recommend you not to use useData directly in your application, instead wrap useData with your own custom hooks for better reusability, and consistent request/SWR options across your app.
export const usePlanets = (pageIndex?: number, perPage?: number) => {
return useData(getAllPlanets, {
query: {
page: pageIndex,
per_page: perPage
}
});
};export const usePlanets = (pageSize = 20, pageToken?: string) => {
return useData(PlanetService.method.listPlanets, {
message: { pageSize, pageToken }
});
};Destructuring is safe (and recommended) — but DO NOT spread the return value of
useData!
useDatauses a re-render reduction optimization technique: each field of the return value (data,error,isLoading, etc.) only becomes a re-render dependency once you actually access it, so your component only re-renders when the fields it actually reads change.Destructuring works perfectly with this optimization — it only accesses the fields you name:
// ✅ SAFE — only `data` and `isLoading` are accessed, so the // component only re-renders when `data` or `isLoading` change const { data, isLoading } = usePlanets();Spreading (
...), on the other hand, eagerly accesses every field at once, so every field becomes a re-render dependency — breaking the optimization and causing unnecessary re-renders:// ❌ DON'T DO THIS — spreading eagerly reads every field, causing // re-renders whenever ANY field changes, even the ones you never use export const usePlanets = () => { return { ...useData(/* ... */), someOtherField: 'someValue' }; };If your custom hook needs to return extra values alongside
useData's result, "proxy" the fields with getters instead of spreading. A getter defers the field access to the call site, so a field still only becomes a re-render dependency when the consumer actually reads it:// ✅ SAFE — getters keep the field accesses lazy, so the optimization // stays intact while callers can still destructure a flat object export const usePlanets = () => { const result = useData(/* ... */); return { get data() { return result.data; }, get error() { return result.error; }, get isLoading() { return result.isLoading; }, get mutate() { return result.mutate; }, someOtherField: 'someValue' }; }; const { data, isLoading, someOtherField } = usePlanets();
You can pass a falsy value (false | null | undefined | 0) as the second argument to conditionally disable the request:
const [searchQuery, setSearchQuery] = useState('');
const { data, error, isLoading } = useData(
getAllPlanets,
// disable the request when searchQuery is empty
searchQuery ? { query: { q: searchQuery } } : null
)const [userInitiatedLoading, setUserInitiatedLoading] = useState(false);
const { data, error, isLoading } = useData(
getAllPlanets,
userInitiatedLoading ? { query: { q: searchQuery } } : null
);
<button onClick={() => setUserInitiatedLoading(true)}>Load</button>const [sentence, setSentence] = useState('');
const { data, error, isLoading } = useData(
ElizaService.method.say,
// disable the request when the sentence is empty
sentence ? { message: { sentence } } : null
);const [userInitiatedLoading, setUserInitiatedLoading] = useState(false);
const { data, error, isLoading } = useData(
PlanetService.method.listPlanets,
userInitiatedLoading ? { message: { pageSize: 20 } } : null
);
<button onClick={() => setUserInitiatedLoading(true)}>Load</button>You can also pass a function to the second argument for more complex conditional logic:
const { data, error, isLoading } = useData(
getAllPlanets,
() => {
if (searchQuery.trim().length === 0) return null;
// you still get type safe and with IDE autocompletion for request options here!
return { query: { q: searchQuery } };
}
)const { data, error, isLoading } = useData(
ElizaService.method.say,
() => {
if (sentence.trim().length === 0) return null;
// you still get type safety and IDE autocompletion for the request message here!
return { message: { sentence } };
}
);useData also allows you to fetch data that depends on the result of another request.
// The dependency request
const { data: user } = useData(getCurrentUser, {});
// The second request
const { data: userProjects } = useData(
getUserProjects,
user ? { query: { uid: user.id } } : null
);// The dependency request
const { data: list } = useData(PlanetService.method.listPlanets, { message: { pageSize: 1 } });
// The second request
const { data: planet } = useData(
PlanetService.method.getPlanet,
list?.planets[0] ? { message: { id: list.planets[0].id } } : null
);You can also simplify the second request with the function form of the second argument:
const { data: userProjects } = useData(
getUserProjects,
() => {
return { query: { uid: user!.id } };
}
);const { data: planet } = useData(
PlanetService.method.getPlanet,
() => {
return { message: { id: list!.planets[0].id } };
}
);When the function throws an error (e.g., when the data it depends on hasn't loaded yet and is undefined, accessing it will throw), useData will also disable the request (just as if you returned a falsy value) until the next re-render.
You can pass SWR options as the third argument of useData:
useData(
getAllPlanets,
{ /* Hey API request options */ },
{
onSuccess(data) {
console.log('Data fetched successfully:', data);
},
onError(error) {
console.error('Error fetching data:', error);
},
fallbackData: {}, // when provided, the returned `data` will never be `undefined`
// ...other SWR options
}
)Which request options identify a cache entry?
All of them. The SDK method and the whole request options identify a cache entry. Two hooks that differ in any of them, say another header or another query parameter, get their own cache entries.
Plain values compare by value. Everything else compares by identity, so when it is created during render, every render is a new request: functions,
Headersinstances,Blobs and the like. Create them outside of your component, or memoize them withuseMemo/useCallback.Avoid per-request values in
headerstoo (request ids, rotating tokens): every new value is a new cache entry, put them into your client configuration instead.
The second argument of useData describes the request. Next to message, it takes Connect's per-call options. The third argument takes SWR options, and nothing else:
// Callbacks and context values compare by identity, so create them once (or memoize them)
// instead of inline, see "Which request options identify a cache entry?"
const contextValues = useMemo(() => createContextValues().set(kTenant, tenant), [tenant]);
const onHeader = useCallback((headers: Headers) => {
// response headers
}, []);
useData(
ElizaService.method.say,
{
// the request message
message: { sentence: 'Hello' },
// Connect call options, forwarded to the transport
headers: { 'x-api-version': '2' },
timeoutMs: 5000,
contextValues,
onHeader
},
{
// SWR options
onSuccess(data) {
console.log('Data fetched successfully:', data);
},
onError(error) {
console.error('Error fetching data:', error);
},
fallbackData: { sentence: '...' }, // when provided, the returned `data` will never be `undefined`
revalidateOnFocus: false
}
);The request accepts the headers, timeoutMs, contextValues, onHeader and onTrailer call options: exactly what you would pass as the second argument of a Connect client method, just flattened next to message (signal is only available on useMutation's trigger, SWR manages the lifecycle of useData requests itself). The shape is exported as TayoriConnectRequest (and TayoriConnectMutationRequest for trigger) if you need to build requests outside of a hook.
Which request options identify a cache entry?
All of them. The transport, the method and the whole request (
message,headers,timeoutMs,contextValues,onHeader,onTrailer) identify a cache entry. Two hooks that differ in any of them, say anotherAccept-Languageheader or anothertimeoutMs, get their own cache entries.Plain values compare by value, and header names are case-insensitive. Everything else compares by identity, so when it is created during render, every render is a new request:
bytesfields (Uint8Array, including thevalueofanyPack()results)onHeader/onTrailercallbackscontextValues(createContextValues())Create them outside of your component, or memoize them with
useMemo/useCallback. Avoid per-request values inheaderstoo (request ids, rotating tokens): every new value is a new cache entry, put them into a transport interceptor instead.If something changes the response of your server, it belongs into
message. Anything that should apply to every request (auth, tracing, locale) belongs into a transport interceptor.
Sometimes, you might want to fetch data only once and never revalidate it, you can replace useData with useDataImmutable for this use case. Once the data is cached, it will never be requested again.
import { useDataImmutable } from './lib/tayori';
const { data, error, isLoading } = useDataImmutable(getAllPlanets, {
query: {}
});import { useDataImmutable } from './lib/tayori';
const { data, error, isLoading } = useDataImmutable(PlanetService.method.getPlanet, {
message: { id: 'earth' }
});useDataImmutable has the same interface as useData.
The requests of the hooks go through SWR, so your own SWR middleware sees them next to every other SWR request in your app. isTayoriKey tells them apart:
import type { Middleware } from 'swr';
import { isTayoriKey } from 'tayori';
const logger: Middleware = (useSWRNext) => (key, fetcher, config) => {
if (isTayoriKey(key)) {
// a request made by one of the hooks
}
return useSWRNext(key, fetcher, config);
};import type { Middleware } from 'swr';
import { isTayoriKey } from 'tayori-connect';
const logger: Middleware = (useSWRNext) => (key, fetcher, config) => {
if (isTayoriKey(key)) {
// a request made by one of the hooks
}
return useSWRNext(key, fetcher, config);
};You will need to use useMutation for requests that change data on the server, e.g. creating, updating or deleting a planet.
import { useMutation } from './lib/tayori';
import { createPlanet } from 'path/to/hey-api-generated-sdk';
function PlanetCreationForm() {
const { mutate: mutatePlanets } = usePlanets();
const { trigger, data, error, isMutating, reset } = useMutation(
createPlanet,
{/* optional mutation options */}
);
const handleSubmit = async (formData) => {
const data = await trigger({
/**
* Hey API request options for createPlanet
* with type safety and IDE autocompletion!
*/
body: formData,
query: {}
}, {
/** optional mutation options */
});
// revalidate the planets list after creation
mutatePlanets();
};
return (
<form>
<button type="submit" onClick={handleSubmit} disabled={isMutating}>
{isMutating ? 'Saving...' : 'Create Planet'}
</button>
</form>
)
}import { useMutation } from './lib/tayori';
import { PlanetService } from './gen/planets/v1/planets_pb';
function PlanetCreationForm() {
const { mutate: mutatePlanets } = usePlanets();
const { trigger, data, error, isMutating, reset } = useMutation(
PlanetService.method.createPlanet,
{/* optional mutation options */}
);
const handleSubmit = async (formData: FormData) => {
const planet = await trigger({
/**
* The CreatePlanetRequest message,
* with type safety and IDE autocompletion!
*/
message: { name: formData.get('name') as string }
}, {
/** optional mutation options */
});
// revalidate the planets list after creation
mutatePlanets();
};
return (
<form action={handleSubmit}>
<input name="name" />
<button type="submit" disabled={isMutating}>
{isMutating ? 'Saving...' : 'Create Planet'}
</button>
</form>
);
}trigger(request, options?) takes the same request object as useData's second argument: the message plus Connect's per-call options (see Mutation Options below).
trigger resolves with the response of the request and rejects when it fails, in addition to exposing the result through data / error, so wrap it in try...catch if you handle errors at the call site.
Why do I need to call
mutateaftertrigger?Fetching and mutating data go through different methods, so the hooks can't infer which cache entries a mutation affects. Call
mutatemanually to revalidate the relevant ones.If you would rather not keep a reference to the right
mutatearound, tag your requests with SWR'stagsoption and callrevalidateTaginstead, see Cache Tags.
We are working with Hey API to expose more metadata information on the SDK methods, so we might be able to automatically revalidate the proper
useDatacache in the future.
We also recommend you to wrap useMutation with your own custom hooks for better reusability, just like useData.
export const useCreatePlanet = () => useMutation(createPlanet);export const useCreatePlanet = () => useMutation(PlanetService.method.createPlanet);Destructuring is safe — but DO NOT spread the return value of
useMutation!Just like
useData,useMutationalso uses the same re-render reduction optimization technique. Destructuring (const { trigger, isMutating } = useMutation(...)) only accesses the fields you name and keeps the optimization intact, while spreading eagerly accesses every field, breaking the optimization and causing unnecessary re-renders.
The mutation options (onSuccess, onError and populateCache) can be passed either as the second argument of useMutation or the second argument of trigger (which takes priority):
const { trigger } = useMutation(createPlanet, {
/* mutation options */
});
await trigger(
{ /* Hey API request options */ },
{ /* mutation options */ }
);const { trigger } = useMutation(PlanetService.method.createPlanet, {
/* mutation options */
});
await trigger(
{ message: { /* request message */ } },
{ /* mutation options */ }
);onSuccess(data)
Callback function when a remote mutation has been finished successfully. The data argument is the response of the mutation.
onError(error)
Callback function when a remote mutation has thrown an error. The error argument is whatever the request threw, see Error Handling below.
populateCache
Write the response into the useData cache entry of the same request, see Fetching within an Event Handler below.
Connect's call options are not mutation options. Just like with useData, they describe one request, so they travel with the request you pass to trigger: headers, timeoutMs, contextValues, onHeader, onTrailer, plus signal, an AbortSignal to abort the in-flight call (an AbortSignal belongs to one call, which is why it only exists on trigger's request):
const { trigger } = useMutation(PlanetService.method.updatePlanet);
const controller = new AbortController();
await trigger({
message: { id: 'earth', name: 'Terra' },
headers: { 'x-client': 'web' },
timeoutMs: 3000,
signal: controller.signal
});There are no hook-level call options. If every call of a mutation needs the same headers or timeoutMs, add them in your custom hook (wrap trigger), or put them into a transport interceptor so they apply to every request.
Calling mutate after every trigger works, but it couples the mutation to whichever useData hook happens to be mounted nearby. SWR's cache tags (SWR 2.6+) let you revalidate requests by name instead: pass the tags SWR option when you make the requests, then call revalidateTag with the same tag after a mutation.
import { useSWRConfig } from 'swr';
export const usePlanets = (pageIndex?: number, perPage?: number) => {
return useData(
getAllPlanets,
{ query: { page: pageIndex, per_page: perPage } },
{ tags: ['planets'] }
);
};
export const useCreatePlanet = () => {
const { revalidateTag } = useSWRConfig();
return useMutation(createPlanet, {
onSuccess() {
// revalidate every mounted request tagged with "planets",
// no matter which page / perPage it was requested with
revalidateTag('planets');
}
});
};import { useSWRConfig } from 'swr';
export const usePlanets = (pageSize = 20, pageToken?: string) => {
return useData(
PlanetService.method.listPlanets,
{ message: { pageSize, pageToken } },
{ tags: ['planets'] }
);
};
export const useCreatePlanet = () => {
const { revalidateTag } = useSWRConfig();
return useMutation(PlanetService.method.createPlanet, {
onSuccess() {
// revalidate every mounted request tagged with "planets",
// no matter which pageSize / pageToken it was requested with
revalidateTag('planets');
}
});
};In most cases, you should use useData for conditional data fetching.
const [userInitiatedLoading, setUserInitiatedLoading] = useState(false);
useData(getAllPlanets, userInitiatedLoading ? {} : null);
const [searchQuery, setSearchQuery] = useState('');
useData(searchPlanets, () => {
if (searchQuery.trim().length === 0) return null;
return { query: { q: searchQuery } };
});const [userInitiatedLoading, setUserInitiatedLoading] = useState(false);
useData(PlanetService.method.listPlanets, userInitiatedLoading ? { message: { pageSize: 20 } } : null);
const [sentence, setSentence] = useState('');
useData(ElizaService.method.say, () => {
if (sentence.trim().length === 0) return null;
return { message: { sentence } };
});However, sometimes you might want to trigger a data fetch from an event handler (typically on a user interaction), and also access the response data within the same event handler (where with useData the response data will only be available in the next render). In this case, you can also use useMutation for fetching data.
const { trigger, isMutating } = useMutation(getPlanetById);
<button
onClick={async () => {
try {
const data = await trigger({ query: { id: 'earth' } });
// access data within the same event handler
console.log('Fetched planet data:', data);
} catch (error) {
console.error('Error fetching planet data:', error);
}
}}
disabled={isMutating}
/>;const { trigger, isMutating } = useMutation(PlanetService.method.getPlanet);
<button
onClick={async () => {
try {
const planet = await trigger({ message: { id: 'earth' } });
// access data within the same event handler
console.log('Fetched planet:', planet);
} catch (error) {
console.error('Error fetching planet:', error);
}
}}
disabled={isMutating}
/>;In this specific scenario, you may want to cache the response for subsequent useData hooks (since it is a read-only request without side effects). By enabling the populateCache option of useMutation, the response is written into the exact SWR cache entry that the matching useData call would use:
// you can pass `populateCache` to `useMutation`...
const { trigger, isMutating } = useMutation(getPlanetById, { populateCache: true });
// or to `trigger` (takes priority)
trigger({ query: { id: 'earth' } }, { populateCache: true });// you can pass `populateCache` to `useMutation`...
const { trigger, isMutating } = useMutation(PlanetService.method.getPlanet, { populateCache: true });
// or to `trigger` (takes priority)
trigger({ message: { id: 'earth' } }, { populateCache: true });To populate the cache of a useData hook, pass trigger the same request as that hook.
Typically, you can achieve pagination with useData by passing the parameters as part of the request:
const [pageIndex, setPageIndex] = useState(0);
const [perPage, setPerPage] = useState(20);
const { data, error, isLoading } = useData(getAllPlanets, {
query: {
page: pageIndex,
per_page: perPage
}
});const [pageToken, setPageToken] = useState<string | undefined>();
const { data, error, isLoading } = useData(PlanetService.method.listPlanets, {
message: { pageSize: 20, pageToken }
});
<button
disabled={!data?.nextPageToken}
onClick={() => setPageToken(data?.nextPageToken)}
>
Next page
</button>To keep the current page on screen until the next one arrives, pass SWR's keepPreviousData: true option (per hook, or app-wide through <SWRConfig />).
You can even preload the next page by abstracting the page as a dedicated component:
function Page({ index, perPage }) {
const { data } = usePlanets(index, perPage);
return data.map(item => <div key={item.id}>{item.name}</div>)
}
function App () {
const [pageIndex, setPageIndex] = useState(0);
const [perPage, setPerPage] = useState(20);
return (
<div>
<Page index={pageIndex} perPage={perPage}/>
{/* preload the next page data */}
<div style={{ display: 'none' }}><Page index={pageIndex + 1} perPage={perPage}/></div>
</div>
);
}You can use the same technique for simple infinite loading like "Load More" button:
function Page({ index }) {
const { data } = usePlanets(index);
return data.map(item => <div key={item.id}>{item.name}</div>)
}
function App() {
const [size, setSize] = useState(1);
const pages: React.ReactNode[] = [];
for (let i = 0; i < size; i++) {
pages.push(<Page key={i} index={i} />);
}
return (
<div>
{pages}
<button onClick={() => setSize(size + 1)}>Load More</button>
</div>
);
}function Page({ pageToken }: { pageToken?: string }) {
const { data } = usePlanets(20, pageToken);
return data?.planets.map(planet => <div key={planet.id}>{planet.name}</div>);
}
function App() {
const [pageToken, setPageToken] = useState<string | undefined>();
const { data } = usePlanets(20, pageToken);
return (
<div>
<Page pageToken={pageToken} />
{/* preload the next page */}
{data?.nextPageToken && (
<div style={{ display: 'none' }}><Page pageToken={data.nextPageToken} /></div>
)}
</div>
);
}However, there are some cases where you can't use useData, typically with cursor-based pagination where you need the previous page's response to determine the next page's request, or infinite loading that also shows how many items/pages have already been loaded (where you need to access every page that has been fetched so far). Here is when useInfinite comes in handy.
You can use useInfinite for this use case:
const { data: pages, size, setSize, isLoading, mutate } = useInfinite(
getAllData,
(pageIndex, previousPageData) => {
// stop fetching by returning a falsy value
if (previousPageData && !previousPageData.hasMore) return null;
const nextCursor = previousPageData?.meta?.nextCursor;
if (!nextCursor && pageIndex > 0) return null;
return {
/* Hey API request options, with type safety and IDE autocompletion */
query: { cursor: previousPageData?.meta?.nextCursor }
};
}
);useInfinite accepts the Hey API generated SDK method as the first argument, a "getRequestOptions" function as the second argument, and an optional SWR options as the third argument.
const { data: pages, size, setSize, isLoading, mutate } = useInfinite(
PlanetService.method.listPlanets,
(pageIndex, previousPageData) => {
// stop fetching by returning a falsy value
if (previousPageData && !previousPageData.nextPageToken) return null;
return {
// the request message, with type safety and IDE autocompletion
message: {
pageSize: 20,
pageToken: previousPageData?.nextPageToken
}
};
}
);useInfinite accepts the unary method descriptor as the first argument, a "getRequest" function as the second argument (it receives the page index and the previous page's response message, and returns the request of the next page, the same { message, headers?, ... } object as useData's second argument, or a falsy value to stop), and optional SWR Infinite options as the third argument.
Destructuring is safe — but DO NOT spread the return value of
useInfinite!Just like
useData,useInfinitealso uses the same re-render reduction optimization technique. Destructuring (const { data, size, setSize } = useInfinite(...)) only accesses the fields you name and keeps the optimization intact, while spreading eagerly accesses every field, breaking the optimization and causing unnecessary re-renders.
data: an array of responses, one for each page.
error: the latest error thrown by any request.
isLoading: same as useData
mutate: same as useData, but it will revalidate all pages
size: the number of pages that will be fetched and returned
setSize: set the number of pages that need to be fetched
Note that, useInfinite will fetch size number of pages and cache them individually. So when you call setSize(size + 1), it will fetch the next page and append it to the data array and give you all pages fetched so far.
You can pass SWR Infinite options as the third argument of useInfinite:
useInfinite(
getAllData,
getRequestOptions,
{
initialSize: 1, // the initial value of `size`
parallel: false, // whether to fetch pages in parallel or sequentially
persistSize: false, // whether NOT to reset `size` back to 1 when first page's request options change
// ... and other useSWRInfinite options
}
);useInfinite(
PlanetService.method.listPlanets,
getRequest,
{
initialSize: 1, // the initial value of `size`
parallel: false, // whether to fetch pages in parallel or sequentially
persistSize: false, // whether NOT to reset `size` back to 1 when the first page's request changes
// ... and other useSWRInfinite options
}
);Just like with useData, Connect call options are not SWR options: they belong to the request returned by getRequest, so they can even differ from page to page:
const getRequest = (pageIndex: number, previousPageData: ListPlanetsResponse | null) => {
if (previousPageData && !previousPageData.nextPageToken) return null;
return {
message: { pageSize: 20, pageToken: previousPageData?.nextPageToken },
timeoutMs: 10_000
};
};You can use the usePreload hook to get a preload function for prefilling the cache for future useData calls within React.
function App() {
const preload = usePreload();
// you can then call "preload" function within component render phase
preload(getAllPlanets, { query: { page: 0, per_page: 20 } });
// or within an effect
useEffect(() => {
preload(getAllPlanets, { query: { page: 0, per_page: 20 } });
}, [preload]);
return (
<button
// or within an event handler
onClick={() => preload(getAllPlanets, { query: { page: 0, per_page: 20 } })}
>
Preload Planets
</button>
);
}function App() {
const preload = usePreload();
// you can then call the "preload" function within the component render phase
preload(PlanetService.method.listPlanets, { message: { pageSize: 20 } });
// or within an effect
useEffect(() => {
preload(PlanetService.method.listPlanets, { message: { pageSize: 20 } });
}, [preload]);
return (
<button
// or within an event handler
onClick={() => preload(PlanetService.method.listPlanets, { message: { pageSize: 20 } })}
>
Preload Planets
</button>
);
}preload takes the same method and request as useData, but no SWR options. Pass the same request as the useData call you are preloading for.
The hooks all expose SWR options, so you can use the fallbackData option to pre-fill the data.
// `data` will never be `undefined` and will fallback to `prefetchedPlanets`
const { data } = useData(getAllPlanets, { query: { page: 0, per_page: 20 } }, {
fallbackData: prefetchedPlanets
});// `data` will never be `undefined` and will fallback to `prefetchedPlanets`
const { data } = useData(PlanetService.method.listPlanets, { message: { pageSize: 20 } }, {
fallbackData: prefetchedPlanets
});When a request fails, the error thrown by your Hey API client shows up as error in useData / useInfinite, as error in useMutation (and as the rejection of trigger), and as the first argument of your onError callbacks. What it looks like depends on the client you chose (for example an HTTPError with @hey-api/client-ky), so error is typed as unknown. When Hey API validates requests and responses with Zod, isZodError() tells those validation errors apart from the rest:
import { isZodError } from 'tayori';
const { data, error } = usePlanets();
if (isZodError(error)) {
// `error` is a ZodError here
console.error(error.issues);
}For app-wide handling (e.g. redirecting to the login page on an unauthorized response, or reporting to your error tracker), you can either use SWR's global onError through <SWRConfig />, or handle it once in your Hey API client, so that it also applies to calls made outside of tayori.
When a call fails, the transport rejects with Connect's ConnectError, which then shows up as error in useData / useInfinite, as error in useMutation (and as the rejection of trigger), and as the first argument of your onError callbacks. Because interceptors or a custom transport could throw anything, error is typed as unknown. Use ConnectError.from() to normalize it, then inspect the status code, the rawMessage, the trailing metadata or the error details:
import { Code, ConnectError } from '@connectrpc/connect';
function Eliza() {
const { data, error } = useData(ElizaService.method.say, { message: { sentence: 'Hello' } });
if (error) {
const connectError = ConnectError.from(error);
if (connectError.code === Code.Unauthenticated) {
return <LoginPrompt />;
}
return <p>{Code[connectError.code]}: {connectError.rawMessage}</p>;
}
return <p>{data?.sentence}</p>;
}For app-wide handling (e.g. redirecting to the login page on Code.Unauthenticated, or reporting to your error tracker), you can either use SWR's global onError through <SWRConfig />, or handle it once in a transport interceptor, so that it also applies to calls made outside of tayori-connect.
You can only use the hooks within Client Components. You should add the 'use client'; directive at the top of your file that uses them.
'use client';
import { useData } from './lib/tayori';
function MyComponent() {
const { data } = usePlanets();
}If you don't provide fallbackData, the initial data will be undefined and the initial isLoading will be true on the server. You can provide a loading UI for better user experience:
'use client';
function ClientComponent() {
const { data, isLoading } = usePlanets();
if (isLoading) { // also true on the server and during client hydration
return <div>Loading...</div>;
}
return <div>...</div>;
}
// Server HTML will contain `<div>Loading...</div>`When first loading the page, the user will immediately see the loading UI. After React hydration, the hooks will begin fetching data and re-render the component with the actual data accordingly.