T
TanStack3y ago
eastern-cyan

"Clean" way to centralize API calls in RQ while still keeping some typesafety?

Currently I have something like this (using ky which is kinda like axios)
export const api = {
useGetPlaylists: () => {
return useQuery(['playlists'], async () => {
const res = await ky.get('playlists');
const playlistsResponse: Response_Query_GetPlaylists = await res.json();
return playlistsResponse;
});
},
useGetPlaylist: (playlistId: string | number) => {
return useQuery(['playlists', playlistId], async () => {
const res = await ky.get(`playlists/${playlistId}`);
const playlistResponse: Response_Query_GetPlaylist = await res.json();
return playlistResponse;
});
},
};
export const api = {
useGetPlaylists: () => {
return useQuery(['playlists'], async () => {
const res = await ky.get('playlists');
const playlistsResponse: Response_Query_GetPlaylists = await res.json();
return playlistsResponse;
});
},
useGetPlaylist: (playlistId: string | number) => {
return useQuery(['playlists', playlistId], async () => {
const res = await ky.get(`playlists/${playlistId}`);
const playlistResponse: Response_Query_GetPlaylist = await res.json();
return playlistResponse;
});
},
};
And usage would be:
export const PlaylistsPage = () => {
const query_GetPlaylists = api.useGetPlaylists();
// `query_GetPlaylists.data` is properly typed as `Response_Query_GetPlaylists` which is, for simplicity, a `Playlist[]`

return /*... some TSX */
export const PlaylistsPage = () => {
const query_GetPlaylists = api.useGetPlaylists();
// `query_GetPlaylists.data` is properly typed as `Response_Query_GetPlaylists` which is, for simplicity, a `Playlist[]`

return /*... some TSX */
But this looks kind of ugly to me, though I have no idea how it's usually implemented. This is basically just writing 1 custom hook for every single kind of API call I would have, which doesn't seem right. I tried one other version with a single custom hook called useApi() which accepted all kinds of props, and returns a specific useQuery() call for that query only (though this looks like it violates the rules of hooks), however the problem with this is that const { data } = useApi() would type data as a union of all possible return types instead, ruining any typesafety I'd have.
5 Replies
quickest-silver
quickest-silver3y ago
Not entirely sure what the question is. The first implementation looks good to me, although I would personally not wrap it in the api abstraction but rather call useGetPlaylists directly. For your question about data being a union, you might find a solution in using discriminated unions as return type, like https://twitter.com/mattpocockuk/status/1679433277730353152
Matt Pocock (@mattpocockuk)
A lot of people talk about generic React components. But I think the MUCH more useful tip (and often easier to grok) is using discriminated unions in React props. Here, we make a version of the 'as' prop to choose between the 'button' and 'a' tags, with different props on both.
Likes
1210
Retweets
106
From Matt Pocock (@mattpocockuk)
Twitter
eastern-cyan
eastern-cyanOP3y ago
Thanks for replying! First implementation I forgot to mention that I would wrap them in separate [something]Api like playlistApi or videoApi and put each of them in the feature's folder (vertical slice ish) rather than a single src/api/api.ts file. What I was thinking about is that it looks like I'd have a separate custom hook for every single endpoint in my API and I didn't think that's very realistic in real systems with hundreds of endpoints. Which leads me to the 2nd question I had, where I did try implementing a single useApi() hook with specific props returning a specific useQuery() and its related query keys, data fetching callback and other settings. But I couldn't get it to type the return value properly.
export const useGetPlaylistApi = (playlistId?: string | number) => {
if (playlistId) {
return useQuery(['playlists', playlistId], async () => {
const res = await ky.get(`playlists/${playlistId}`);
const playlistResponse: Response_Query_GetPlaylist = await res.json();
return playlistResponse;
});
} else {
return useQuery(['playlists'], async () => {
const res = await ky.get('playlists');
const playlistsResponse: Response_Query_GetPlaylists = await res.json();
return playlistsResponse;
});
}
};

// Usage
export const Component = () => {
const res = useGetPlaylistApi();
res.data; // <-- this is `Response_Query_GetPlaylists | Response_Query_GetPlaylist | undefined`

const res2 = useGetPlaylistApi(3);
res.data; // <-- also `Response_Query_GetPlaylists | Response_Query_GetPlaylist | undefined`
}
export const useGetPlaylistApi = (playlistId?: string | number) => {
if (playlistId) {
return useQuery(['playlists', playlistId], async () => {
const res = await ky.get(`playlists/${playlistId}`);
const playlistResponse: Response_Query_GetPlaylist = await res.json();
return playlistResponse;
});
} else {
return useQuery(['playlists'], async () => {
const res = await ky.get('playlists');
const playlistsResponse: Response_Query_GetPlaylists = await res.json();
return playlistsResponse;
});
}
};

// Usage
export const Component = () => {
const res = useGetPlaylistApi();
res.data; // <-- this is `Response_Query_GetPlaylists | Response_Query_GetPlaylist | undefined`

const res2 = useGetPlaylistApi(3);
res.data; // <-- also `Response_Query_GetPlaylists | Response_Query_GetPlaylist | undefined`
}
What I want is the hook to be able to infer what the return type is, depending on its props (in this example, calling useGetPlaylistApi() should infer that res.data is Response_Query_GetPlaylists | undefined, while useGetPlaylistApi(3) should infer that res.data is Response_Query_GetPlaylist | undefined.
inland-turquoise
inland-turquoise3y ago
That'll require some generics and you'll probably lose type inference from react query. Having one hook per API is way simpler to me, or you can also go with the query key factory pattern: https://tanstack.com/query/v4/docs/react/community/lukemorales-query-key-factory
Query Key Factory | TanStack Query Docs
Typesafe query key management with auto-completion features. Focus on writing and invalidating queries without the hassle of remembering how you've set up a key for a specific query!
quickest-silver
quickest-silver3y ago
Agree with @julien . Like he said, you should overwrite inference and use your own generics if you want to achieve something similar. Wouldn't recommend personally
import { useQuery, UseQueryResult } from '@tanstack/react-query';

type Response_Query_GetPlaylist = {
type: 'single'
};

type Response_Query_GetPlaylists = {
type: 'multiple'
};

type ReturnType<T> =
T extends number ? Response_Query_GetPlaylist :
T extends undefined ? Response_Query_GetPlaylists :
never;

function useGetPlaylistApi<T extends number | undefined>(playlistId?: T): UseQueryResult<ReturnType<T>, unknown> {
if (playlistId) {
return useQuery({
queryKey: ['playlists', playlistId],
queryFn: async () => {
const res = await fetch(`playlists/${playlistId}`, {
method: 'GET',
});
const playlistResponse: Response_Query_GetPlaylist = await res.json();
return playlistResponse;
},
});
}

return useQuery({
queryKey: ['playlists'],
queryFn: async () => {
const res = await fetch(`playlists`, {
method: 'GET',
});
const playlistsResponse: Response_Query_GetPlaylists = await res.json();
return playlistsResponse;
},
});
};

export const Component = () => {
const res = useGetPlaylistApi(undefined);
res.data;

const res2 = useGetPlaylistApi(3);
res2.data;
}
import { useQuery, UseQueryResult } from '@tanstack/react-query';

type Response_Query_GetPlaylist = {
type: 'single'
};

type Response_Query_GetPlaylists = {
type: 'multiple'
};

type ReturnType<T> =
T extends number ? Response_Query_GetPlaylist :
T extends undefined ? Response_Query_GetPlaylists :
never;

function useGetPlaylistApi<T extends number | undefined>(playlistId?: T): UseQueryResult<ReturnType<T>, unknown> {
if (playlistId) {
return useQuery({
queryKey: ['playlists', playlistId],
queryFn: async () => {
const res = await fetch(`playlists/${playlistId}`, {
method: 'GET',
});
const playlistResponse: Response_Query_GetPlaylist = await res.json();
return playlistResponse;
},
});
}

return useQuery({
queryKey: ['playlists'],
queryFn: async () => {
const res = await fetch(`playlists`, {
method: 'GET',
});
const playlistsResponse: Response_Query_GetPlaylists = await res.json();
return playlistsResponse;
},
});
};

export const Component = () => {
const res = useGetPlaylistApi(undefined);
res.data;

const res2 = useGetPlaylistApi(3);
res2.data;
}
eastern-cyan
eastern-cyanOP3y ago
Oh wow, that looks way more complicated than I expected, and I don't even have a justifiable reason to do that ("what I currently do doesn't look pretty when there's lots of endpoints" isn't really valid) Thanks everyone, guess I'll just stick to simple, separate hooks for now

Did you find this page helpful?