The @benefex/data package builds on the concepts behind both useApi
and our Redux sagas.
Its primary focus is centralising service creation and types. It will also provide convenience methods for React Query, though this could be expanded to more libs in the future if we migrate away from React Query (see SWR example).
Once you have read this document feel free to try the tasks here.
A service definition is a logical group of endpoints. This might be an entire API (e.g., Alerts) or a subset (e.g., WalletUser). It contains key information to facilitate the creation of data-fetching helpers.
- url: The endpoint for the API.
:userId
and:companyId
are automatically populated from the user's token unless specified inurlParams
(similar to useApi and Redux). - tags: These form part of the
queryKey
and are used for invalidation (see invalidators). - call: This defines the logic for the service. Its only requirement is a URL; however, other common properties are also typed using the
ServiceArgs
helper.
Example:
const notesService = {
get: createQueryService({
url: "/notes/:noteId",
tags: ["notes"] as const,
// urlParams only
call: (args: ServiceArgs<NoteParams>) => mockFetcher<Note>(args),
// urlParams and searchParams
// call: (args: ServiceArgs<{ urlParams: NoteParams, searchParams: NoteQuery }>) => mockFetcher<Note>(args),
}),
create: createService({
url: "/notes",
call: ({ url, body }: ServiceArgs<{ body: CreateNote }>) =>
mockFetcher<Note>({ url, options: { method: "POST", body } }),
}),
};
const query = useQuery(api.notes.get.query({ urlParams: { noteId } }));
const queryWithOptions = useQuery({
...api.notes.get.query({ urlParams: { noteId } }),
select: ({ content }) => content
});
const createNote = useMutation({
mutationFn: api.notes.create.call
});
createNote.mutate({
title: '...',
content: '...'
});
Everything is typed based on the service definitions, useQuery (including select) and useMutation (including onSuccess etc.) inherit the types automatically. No more generics 🎉
Before | After |
import { useApi } from "@benefex/components";
import createRouter from "@benefex/react/utils/api/router/createRouter";
import routes from "store/api/routes";
import type { DiscoverTopic } from "components/FollowTopic/FollowTopic.types";
import { personalisationKeys } from "store/api/queryKeys";
const router = createRouter(routes);
const {
data: topics = [],
isLoading
} = useApi<{ content: DiscoverTopic[] }, DiscoverTopic[]>({
method: "GET",
options: {
enabled: false,
queryKey: personalisationKeys.topics,
select: ({ content }) => content,
},
queryParams: {
size: "5",
},
url: router(["content", "discoverTopics"]),
}); |
import { useQuery } from "@tanstack/react-query";
import { api } from "@benefex/data";
const { data: topics = [], isLoading } = useQuery({
...api.content.discoverTopics.query({ queryParams: { size: "5" } }),
select: ({ content }) => content,
}); |
It's worth noting that currently mutations are completely untyped and in the migrated version they are fully typed.
Before | After |
import { useEffect } from "react";
import { useApi, RQC } from "@benefex/components";
import createRouter from "@benefex/react/utils/api/router/createRouter";
import routes from "store/api/routes";
import { personalisationKeys } from "store/api/queryKeys";
import type { DiscoverTopic } from "./FollowTopic.types";
const router = createRouter(routes);
const followTopic = useApi({
method: "PUT",
url: router(["follows", "topic"]),
urlParams: {
userId: userId as string,
},
});
const [isFollowing, setIsFollowing] = useState(topic.isUserFollowing);
useEffect(() => {
if (followTopic.isSuccess) {
setIsFollowing(true);
setToastText("home.feed.topics.successToast");
setShowToast(true);
RQC.setQueriesData(personalisationKeys.topics(), (previous: any) => ({
...previous,
content: previous?.content?.map((prevTopic: DiscoverTopic) =>
prevTopic.id === topic.id
? {
...topic,
isUserFollowing: true,
}
: prevTopic,
),
}));
}
}, [followTopic.isSuccess]);
useEffect(() => {
if (followTopic.isError) {
setToastText("home.feed.topics.errorToast");
setShowToast(true);
}
}, [followTopic.isError]);
const handleClickSubscribe = () => {
followTopic.callEndpoint({
id: topic.id,
name: topic.name,
});
};
{isFollowing ? <Button>Unfollow</Button> : ...} |
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, invalidateByTags } from "@benefex/data";
const queryClient = useQueryClient();
const followTopic = useMutation({
mutationFn: () => api.topics.follow.call({
urlParams: { userId }
}),
onSuccess() {
setIsFollowing(true);
setToastText("home.feed.topics.successToast");
setShowToast(true);
// Return invalidation so isPending stays true until the topic is refetched
return queryClient.invalidateQueries({
predicate: invalidateByTags(api.topics.all.tags)
});
},
onError() {
setToastText("home.feed.topics.errorToast");
setShowToast(true);
},
});
const isFollowing = followTopic.isPending || topic.isFollowing;
const handleClickSubscribe = () => {
followTopic.mutate({ id: topic.id, name: topic.name });
};
{isFollowing ? <Button>Unfollow</Button> : ...} |
- useQuery
- useMutation with invalidate by tags (similar to queryKeyFactory)
invalidateByTags(api.notes.getAll.tags)
- Form with invalidation
- Tests using MSW
- useSuspenseQuery - Example, Error handling, Global Loader
- useMutation with single endpoint invalidation
-
- Example 1: Refetch
-
- Example 2: Invalidate by params
invalidateByUrlParams(api.notes.get, { noteId: id! })
- Example 2: Invalidate by params
-
- Example 3: Invalidate by url
invalidateByUrl(api.notes.get, { noteId: id! })
- Example 3: Invalidate by url
-
- Example 4: Invalidate by queryKey
api.notes.get.query({ noteId: id! }).queryKey
- Example 4: Invalidate by queryKey
- useMutation with typesafe onSuccess redirect
- Form with toast
- Tests using MSW with a mutation
- useQuery with typed select
Service definitions are not tied to a specific data fetching library. This means they can be used by Redux, RTK, React Query, useSWR or any other lib.
Each service should expose mock factories to help with testing
Contains MSW helpers built using the api service