Data Source is a simple wrapper around data fetching. It is a kind of "port" in clean architecture. It allows you to make wrappers for stuff around data fetching depending on your use cases. Data Source uses react-query under the hood.
npm install @gravity-ui/data-source @tanstack/react-query@tanstack/react-query is a peer dependency.
First, create and provide a DataManager in your application:
importReactfrom'react';import{ClientDataManager,DataSourceProvider}from'@gravity-ui/data-source';constdataManager=newClientDataManager({defaultOptions: {queries: {staleTime: 5*60*1000,// 5 minutesretry: 3,},// ... other react-query options},});functionApp(){return(<DataSourceProviderdataManager={dataManager}><YourApplication/></DataSourceProvider>);}Define a type of error and make your constructors for data sources based on default constructors:
import{makePlainQueryDataSourceasmakePlainQueryDataSourceBase}from'@gravity-ui/data-source';exportinterfaceApiError{code: number;title: string;description?: string;}exportconstmakePlainQueryDataSource=<TParams,TRequest,TResponse,TData,TError=ApiError>(config: Omit<PlainQueryDataSource<TParams,TRequest,TResponse,TData,TError>,'type'>,): PlainQueryDataSource<TParams,TRequest,TResponse,TData,TError>=>{returnmakePlainQueryDataSourceBase(config);};Write a DataLoader component based on default to define your display of loading status and errors:
import{DataLoaderasDataLoaderBase,DataLoaderPropsasDataLoaderPropsBase,ErrorViewProps,}from'@gravity-ui/data-source';exportinterfaceDataLoaderPropsextendsOmit<DataLoaderPropsBase<ApiError>,'LoadingView'|'ErrorView'>{LoadingView?: ComponentType;ErrorView?: ComponentType<ErrorViewProps<ApiError>>;}exportconstDataLoader: React.FC<DataLoaderProps>=({
LoadingView =YourLoader,// You can use your own loader component
ErrorView =YourError,// You can use your own error component
...restProps})=>{return<DataLoaderBaseLoadingView={LoadingView}ErrorView={ErrorView}{...restProps}/>;};import{skipContext}from'@gravity-ui/data-source';// Your API functionimport{fetchUser}from'./api';exportconstuserDataSource=makePlainQueryDataSource({// Keys have to be unique. Maybe you should create a helper for making names of data sourcesname: 'user',// skipContext is a helper to skip 2 first parameters in the function (context and fetchContext)fetch: skipContext(fetchUser),// Optional: generate tags for advanced cache invalidationtags: (params)=>[`user:${params.userId}`,'users'],});import{useQueryData}from'@gravity-ui/data-source';exportconstUserProfile: React.FC<{userId: number}>=({userId})=>{const{data, status, error, refetch}=useQueryData(userDataSource,{userId});return(<DataLoaderstatus={status}error={error}errorAction={refetch}>{data&&<UserCarduser={data}/>}</DataLoader>);};The library provides two main types of data sources:
For simple request/response patterns:
constuserDataSource=makePlainQueryDataSource({name: 'user',fetch: skipContext(async(params: {userId: number})=>{constresponse=awaitfetch(`/api/users/${params.userId}`);returnresponse.json();}),});For pagination and infinite scrolling:
constpostsDataSource=makeInfiniteQueryDataSource({name: 'posts',fetch: skipContext(async(params: {page: number;limit: number})=>{constresponse=awaitfetch(`/api/posts?page=${params.page}&limit=${params.limit}`);returnresponse.json();}),next: (lastPage,allPages)=>{if(lastPage.hasNext){return{page: allPages.length+1,limit: 20};}returnundefined;},});The library normalizes query states into three simple statuses:
loading- Actual data loading. The same asisLoadingin React Querysuccess- Data available (may be skipped using idle)error- Failed to fetch data
The library provides a special idle symbol for skipping query execution:
import{idle}from'@gravity-ui/data-source';constUserProfile: React.FC<{userId?: number}>=({userId})=>{// Query won't execute if userId is not definedconst{data, status}=useQueryData(userDataSource,userId ? {userId} : idle);return(<DataLoaderstatus={status}error={null}>{data&&<UserCarduser={data}/>}</DataLoader>);};When parameters equal idle:
- Query doesn't execute
- Status remains
success - Data remains
undefined - Component can safely render without loading
Benefits of idle:
- Type Safety - TypeScript correctly infers types for conditional parameters
- Performance - Avoids unnecessary server requests
- Logic Simplicity - No need to manage additional
enabledstate - Consistency - Unified approach for all conditional queries
This is especially useful for conditional queries when you want to load data only under certain conditions while maintaining type safety.
Creates a plain query data source for simple request/response patterns.
constdataSource=makePlainQueryDataSource({name: 'unique-name',fetch: skipContext(fetchFunction),transformParams: (params)=>transformedRequest,transformResponse: (response)=>transformedData,tags: (params)=>['tag1','tag2'],options: {staleTime: 60000,retry: 3,// ... other react-query options},});Parameters:
name- Unique identifier for the data sourcefetch- Function that performs the actual data fetchingtransformParams(optional) - Transform input parameters before requesttransformResponse(optional) - Transform response datatags(optional) - Generate cache tags for invalidationoptions(optional) - React Query options
Creates an infinite query data source for pagination and infinite scrolling patterns.
constinfiniteDataSource=makeInfiniteQueryDataSource({name: 'infinite-data',fetch: skipContext(fetchFunction),next: (lastPage,allPages)=>nextPageParam||undefined,prev: (firstPage,allPages)=>prevPageParam||undefined,// ... other options same as plain});Additional Parameters:
next- Function to determine next page parametersprev(optional) - Function to determine previous page parameters
Main hook for fetching data with a data source.
const{data, status, error, refetch, ...rest}=useQueryData(userDataSource,{userId: 123},{enabled: true,refetchInterval: 30000,},);Returns:
data- The fetched datastatus- Current status ('loading' | 'success' | 'error')error- Error object if request failedrefetch- Function to manually refetch data- Other React Query properties
Combines multiple query responses into a single state.
constuser=useQueryData(userDataSource,{userId});constposts=useQueryData(postsDataSource,{userId});const{status, error, refetch, refetchErrored}=useQueryResponses([user,posts]);Returns:
status- Combined status of all querieserror- First error encounteredrefetch- Function to refetch all queriesrefetchErrored- Function to refetch only failed queries
Creates a callback to refetch multiple queries.
constrefetchAll=useRefetchAll([user,posts,comments]);// refetchAll() will trigger refetch for all queriesCreates a callback to refetch only failed queries.
constrefetchErrored=useRefetchErrored([user,posts,comments]);// refetchErrored() will only refetch queries with errorsReturns the DataManager from context.
constdataManager=useDataManager();awaitdataManager.invalidateTag('users');Returns the query context (for building custom data hooks base on react-query).
Component for handling loading states and errors.
<DataLoaderstatus={status}error={error}errorAction={refetch}LoadingView={SpinnerComponent}ErrorView={ErrorComponent}loadingViewProps={{size: 'large'}}errorViewProps={{showDetails: true}}>{data&&<YourContentdata={data}/>}</DataLoader>Props:
status- Current loading statuserror- Error objecterrorAction- Function or action config for error retryLoadingView- Component to show during loadingErrorView- Component to show on errorloadingViewProps- Props passed to LoadingViewerrorViewProps- Props passed to ErrorView
Specialized component for infinite queries.
<DataInfiniteLoaderstatus={status}error={error}hasNextPage={hasNextPage}fetchNextPage={fetchNextPage}isFetchingNextPage={isFetchingNextPage}LoadingView={SpinnerComponent}ErrorView={ErrorComponent}MoreView={LoadMoreButton}>{data.map((item)=>(<Itemkey={item.id}data={item}/>))}</DataInfiniteLoader>Additional Props:
hasNextPage- Whether more pages are availablefetchNextPage- Function to fetch next pageisFetchingNextPage- Whether next page is being fetchedMoreView- Component for "load more" button
HOC that injects DataManager as a prop.
constMyComponent=withDataManager<Props>(({dataManager, ...props})=>{// Component has access to dataManagerreturn<div>...</div>;});Main class for data management.
constdataManager=newClientDataManager({defaultOptions: {queries: {staleTime: 300000,// 5 minutesretry: 3,refetchOnWindowFocus: false,},},});Methods:
Invalidate all queries with a specific tag.
awaitdataManager.invalidateTag('users');awaitdataManager.invalidateTag('posts',{repeat: {count: 3,interval: 1000},// Retry invalidation});Invalidate queries that have all specified tags.
awaitdataManager.invalidateTags(['user','profile']);Invalidate all queries for a data source.
awaitdataManager.invalidateSource(userDataSource);Invalidate a specific query with exact parameters.
awaitdataManager.invalidateParams(userDataSource,{userId: 123});Reset (clear) all cached data for a data source.
awaitdataManager.resetSource(userDataSource);Reset cached data for specific parameters.
awaitdataManager.resetParams(userDataSource,{userId: 123});Invalidate queries based on tags generated by a data source.
awaitdataManager.invalidateSourceTags(userDataSource,{userId: 123});Utility to adapt existing fetch functions to data source interface.
// Existing functionasyncfunctionfetchUser(params: {userId: number}){// ...}// Adapted for data sourceconstdataSource=makePlainQueryDataSource({name: 'user',fetch: skipContext(fetchUser),// Skips context and fetchContext params});Adds standardized error handling to fetch functions.
constsafeFetch=withCatch(fetchUser,(error)=>({error: true,message: error.message}));Adds cancellation support to fetch functions.
constcancellableFetch=withCancellation(fetchFunction);// Automatically handles AbortSignal from React QueryCreates a progressive refetch interval function.
constprogressiveRefetch=getProgressiveRefetch({minInterval: 1000,// Start with 1 secondmaxInterval: 30000,// Max 30 secondsmultiplier: 2,// Double each time});constdataSource=makePlainQueryDataSource({name: 'data',fetch: skipContext(fetchData),options: {refetchInterval: progressiveRefetch,},});Converts React Query statuses to DataLoader status.
conststatus=normalizeStatus('pending','fetching');// 'loading'// Get combined status from multiple statesconststatus=getStatus([user,posts,comments]);// Get first error from multiple statesconsterror=getError([user,posts,comments]);// Merge multiple statusesconstcombinedStatus=mergeStatuses(['loading','success','error']);// 'error'// Check if query key has a tagconsthasUserTag=hasTag(queryKey,'users');// Compose cache key for a data sourceconstkey=composeKey(userDataSource,{userId: 123});// Compose full key including tagsconstfullKey=composeFullKey(userDataSource,{userId: 123});import{idle}from'@gravity-ui/data-source';// Special symbol for skipping query executionconstparams=shouldFetch ? {userId: 123} : idle;// Type-safe alternative to enabled: false// Instead of:const{data}=useQueryData(userDataSource,{userId: userId||''},{enabled: Boolean(userId)});// Use:const{data}=useQueryData(userDataSource,userId ? {userId} : idle);// TypeScript correctly infers types for both branches// Compose React Query options for plain queriesconstplainOptions=composePlainQueryOptions(context,dataSource,params,options);// Compose React Query options for infinite queriesconstinfiniteOptions=composeInfiniteQueryOptions(context,dataSource,params,options);Note: These functions are primarily for internal use when creating custom data source implementations.
Use idle to create conditional queries:
import{idle}from'@gravity-ui/data-source';constConditionalDataComponent: React.FC<{userId?: number;shouldLoadPosts: boolean;}>=({userId, shouldLoadPosts})=>{// Load user only if userId is definedconstuser=useQueryData(userDataSource,userId ? {userId} : idle);// Load posts only if user is loaded and flag is enabledconstposts=useQueryData(userPostsDataSource,user.data&&shouldLoadPosts ? {userId: user.data.id} : idle);constcombined=useQueryResponses([user,posts]);return(<DataLoaderstatus={combined.status}error={combined.error}><div>{user.data&&<UserInfouser={user.data}/>}{posts.data&&<UserPostsposts={posts.data}/>}</div></DataLoader>);};Transform request parameters and response data:
constapiDataSource=makePlainQueryDataSource({name: 'api-data',transformParams: (params: {id: number})=>({userId: params.id,apiVersion: 'v2',format: 'json',}),transformResponse: (response: ApiResponse)=>({user: response.data.user,metadata: response.meta,}),fetch: skipContext(apiFetch),});Use tags for sophisticated cache management:
constuserDataSource=makePlainQueryDataSource({name: 'user',tags: (params)=>[`user:${params.userId}`,'users','profiles'],fetch: skipContext(fetchUser),});constuserPostsDataSource=makePlainQueryDataSource({name: 'user-posts',tags: (params)=>[`user:${params.userId}`,'posts'],fetch: skipContext(fetchUserPosts),});// Invalidate all data for specific userawaitdataManager.invalidateTag('user:123');// Invalidate all user-related dataawaitdataManager.invalidateTag('users');Create type-safe error handling:
interfaceApiError{code: number;message: string;details?: Record<string,unknown>;}constErrorView: React.FC<ErrorViewProps<ApiError>>=({error, action})=>(<divclassName="error"><h3>Error{error?.code}</h3><p>{error?.message}</p>{action&&(<buttononClick={action.handler}>{action.children||'Retry'}</button>)}</div>);Handle complex pagination scenarios:
interfacePaginationParams{cursor?: string;limit?: number;filters?: Record<string,unknown>;}interfacePaginatedResponse<T>{data: T[];nextCursor?: string;hasMore: boolean;}constinfiniteDataSource=makeInfiniteQueryDataSource({name: 'paginated-data',fetch: skipContext(async(params: PaginationParams)=>{constresponse=awaitfetch(`/api/data?${newURLSearchParams(params)}`);returnresponse.json()asPaginatedResponse<DataItem>;}),next: (lastPage)=>{if(lastPage.hasMore&&lastPage.nextCursor){return{cursor: lastPage.nextCursor,limit: 20};}returnundefined;},});Combine data from multiple sources:
constUserProfile: React.FC<{userId: number}>=({userId})=>{constuser=useQueryData(userDataSource,{userId});constposts=useQueryData(userPostsDataSource,{userId});constfollowers=useQueryData(userFollowersDataSource,{userId});constcombined=useQueryResponses([user,posts,followers]);return(<DataLoaderstatus={combined.status}error={combined.error}errorAction={combined.refetchErrored}// Only retry failed requestsLoadingView={ProfileSkeleton}ErrorView={ProfileError}>{user&&posts&&followers&&(<div><UserInfouser={user.data}/><UserPostsposts={posts.data}/><UserFollowersfollowers={followers.data}/></div>)}</DataLoader>);};The library is built with TypeScript-first approach and provides full type inference:
// Types are automatically inferredconstuserDataSource=makePlainQueryDataSource({name: 'user',fetch: skipContext(async(params: {userId: number}): Promise<User>=>{// Return type is inferred as User}),});// Hook return type is automatically typedconst{data}=useQueryData(userDataSource,{userId: 123});// data is typed as User | undefinedDefine and use custom error types:
interfaceValidationError{field: string;message: string;}interfaceApiError{type: 'network'|'validation'|'server';message: string;validation?: ValidationError[];}consttypedDataSource=makePlainQueryDataSource<{id: number},// Params type{id: number},// Request typeApiResponse,// Response typeUser,// Data typeApiError// Error type>({name: 'typed-user',fetch: skipContext(fetchUser),});Please read CONTRIBUTING.md for details on our code of conduct and the process for submitting pull requests.
MIT License. See LICENSE file for details.