tayori

便り · news from afar

An opinionated React client-side data fetching stack built on top of SWR, for Hey API and ConnectRPC backends.

SWRHey APIConnectRPCFully type-safeIDE autocompletionSSR-ready
Hey APIuseData
const { data, error } = useData(
  getAllPlanets,
  {
    query: { page: 1, per_page: 20 }
  }
);
Hey APIuseData · conditional
const { data, error } = useData(
  getAllPlanets,
  searchQuery
    ? { query: { q: searchQuery } }
    : null
);
Hey APIuseInfinite
const { data, setSize } = useInfinite(
  getAllPlanets,
  (i, prev) => (i > 0 && !prev?.cursor
    ? null // reached the end
    : { query: { cursor: prev?.cursor } })
);
ConnectRPCuseData
const { data, error, isLoading } = useData(
  ElizaService.method.say,
  { message: { sentence: 'Hello from tayori' } }
);

// data is typed as SayResponse | undefined
ConnectRPCuseMutation
const { 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.

Getting Started

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.

Installation

npm install tayori
pnpm install tayori
yarn add tayori

react (19.2 or later) and swr (2.6 or later) are peer dependencies.

Code Generation

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.
  • Enable 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
  ]
});

Create tayori provider and hooks

// 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.

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

initClient only runs once per <TayoriProvider /> instance. If a part of your app talks to a different API, or needs a differently configured client (another baseUrl, 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.

Signing Out and Switching Accounts

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():

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 });
  };
}
  • Use 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.
  • Take unload from useSWRConfig(). The unload exported by swr only clears SWR's default cache, not the cache of a custom provider.
  • Until vercel/swr#4350 is released, a request that fails after unload can still write its error into the cleared cache.

Data Fetching

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).

The response will be passed as data and the error will be passed as error, just like SWR.

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
    }
  });
};

Destructuring is safe (and recommended) — but DO NOT spread the return value of useData!

useData uses 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();

Conditional Fetching

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>

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 } };
  }
)

Dependent Fetching

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
);

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 } };
  }
);

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.

Request Options and SWR Options

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, Headers instances, Blobs and the like. Create them outside of your component, or memoize them with useMemo / useCallback.

Avoid per-request values in headers too (request ids, rotating tokens): every new value is a new cache entry, put them into your client configuration instead.

Disable Automatic Revalidations

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: {}
});

useDataImmutable has the same interface as useData.

SWR Middleware

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);
};

Mutation

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>
  )
}

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 mutate after trigger?

Fetching and mutating data go through different methods, so the hooks can't infer which cache entries a mutation affects. Call mutate manually to revalidate the relevant ones.

If you would rather not keep a reference to the right mutate around, tag your requests with SWR's tags option and call revalidateTag instead, see Cache Tags.

This section only applies to the Hey API backend.

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 useData cache 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);

Destructuring is safe — but DO NOT spread the return value of useMutation!

Just like useData, useMutation also 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.

Mutation Options

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 */ }
);

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.

Cache Tags

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');
    }
  });
};

Fetching within an Event Handler

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 } };
});

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}
/>;

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 });

To populate the cache of a useData hook, pass trigger the same request as that hook.

Pagination and Infinite Loading

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
  }
});

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>
  );
}

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.

useInfinite

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.

Destructuring is safe — but DO NOT spread the return value of useInfinite!

Just like useData, useInfinite also 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.

Return Values

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.

SWR Infinite Options

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
  }
);

Prefetching

Programmatic Preloading

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>
  );
}

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.

Pre-fill Data

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
});

Error Handling

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.

Server-Side Rendering and Next.js

Client Components

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();
}

Real Time Client Side Data Fetching

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.