WHAT: Access native iOS/Android repository data with TanStack Query. WHEN: fetching auth tokens, app config, plan data from native container...
Use native data access ONLY for observables from the iOS/Android container. For server data, use GraphQL or REST patterns. Native repositories provide a bridge between native modules and React Query, with event-driven updates and fallback handling.
Why: Native data access is specialized for container-provided observables (auth, config, plan). Using it for server data would bypass proper API patterns and lose features like request cancellation, retry logic, and error handling.
Use these patterns when:
ā ļø DO NOT USE for:
Each native repository follows this domain-based pattern:
data-access/native/[domain]/
āāā constants.ts # Query keys and constants
āāā repository.ts # fetchRepository implementation
āāā schema.ts # TypeScript schema
āāā queries.ts # TanStack Query hooks
āāā events.ts # Event emitters (optional)
āāā types.ts # Additional types (optional)
Why: Domain-based organization keeps related code together, makes repositories easy to find, and enforces consistent structure.
Production Example: git-resources/shared-mobile-modules/src/data-access/native/plan/
All native repositories use consistent query key structure:
// constants.ts
export const NATIVE_MODULES_REPOSITORY_QUERY_KEY = 'nativeRepositories';
export const PLAN_QUERY_KEY = 'plan';
// queries.ts
import { NATIVE_MODULES_REPOSITORY_QUERY_KEY } from '../constants';
import { PLAN_QUERY_KEY } from './constants';
export const usePlanId = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, PLAN_QUERY_KEY],
queryFn: fetchPlanRepository,
select: (data) => data.planId,
});
Pattern: ['nativeRepositories', domain]
Why: Consistent query keys enable:
Production Example: git-resources/shared-mobile-modules/src/data-access/native/plan/queries.ts:13
// repository.ts
import { fetchRepository } from '@libs/query';
import { PLAN_QUERY_KEY } from './constants';
import type { PlanRepositorySchema } from './schema';
const initialState: PlanRepositorySchema = {
subscriptionId: undefined,
planId: undefined,
};
export const fetchPlanRepository = async () =>
fetchRepository<PlanRepositorySchema>(PLAN_QUERY_KEY, initialState);
Key elements:
fetchRepository from @libs/queryinitialState matching schemaWhy: fetchRepository handles:
Production Example: git-resources/shared-mobile-modules/src/data-access/native/plan/repository.ts:1
// @libs/query/client.ts
export const fetchRepository = async <T>(
repositoryName: RepositoryName,
initialState: T
): Promise<T> => {
return new Promise(async (resolve) => {
console.debug(`[fetchRepository] Requesting repository: ${repositoryName}`);
// Send event to native to start fetching the repository
sendEvent(QueryEvents.getRepository, {
repository: repositoryName,
});
// Listen for the setRepository event from native
const subscription = SharedModulesEventEmitter.addListener(
'setRepository',
(payload) => {
try {
const { data, repository } = SetRepositoryPayload.parse(payload);
if (repository !== repositoryName) {
return;
}
console.debug(
`[fetchRepository] Received repository data for: ${repository}`
);
const parsed = JSON.parse(data);
subscription.remove();
resolve(parsed as T);
} catch (error) {
console.error('Error handling setRepository payload:', error);
subscription.remove();
resolve(initialState);
}
}
);
// Fallback timeout in case native never responds
setTimeout(() => {
subscription.remove();
console.warn(
`[fetchRepository] Timeout waiting for repository: ${repositoryName}. Falling back to cached data or initial state.`
);
const cached = queryClient.getQueryData([
NATIVE_MODULES_REPOSITORY_QUERY_KEY,
repositoryName,
]);
if (cached) {
console.debug(
`[fetchRepository] Using cached data for: ${repositoryName}`
);
resolve(cached as T);
} else {
console.debug(
`[fetchRepository] No cached data, using initial state for: ${repositoryName}`
);
resolve(initialState);
}
}, 2000);
});
};
Key patterns:
Why: Handles unreliable native communication gracefully without blocking UI.
Production Example: git-resources/shared-mobile-modules/src/libs/query/client.ts:29
// schema.ts
export interface PlanRepositorySchema {
planId?: string;
subscriptionId?: string;
}
Patterns:
Why: Schema documents expected data shape and enables TypeScript validation throughout the application.
Production Example: git-resources/shared-mobile-modules/src/data-access/native/plan/schema.ts:1
// queries.ts
import { useQuery } from '@tanstack/react-query';
import { NATIVE_MODULES_REPOSITORY_QUERY_KEY } from '../constants';
import { PLAN_QUERY_KEY } from './constants';
import { fetchPlanRepository } from './repository';
/**
* Get the planId from the plan repository.
*/
export const usePlanId = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, PLAN_QUERY_KEY],
queryFn: fetchPlanRepository,
select: (data) => data.planId,
});
/**
* Get the subscriptionId from the plan repository.
*/
export const useSubscriptionId = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, PLAN_QUERY_KEY],
queryFn: fetchPlanRepository,
select: (data) => data.subscriptionId,
});
Patterns:
selectuse{Property} patternWhy: Multiple components can select different properties without duplicate network requests. TanStack Query caches the full repository and computes selectors efficiently.
Production Example: git-resources/shared-mobile-modules/src/data-access/native/plan/queries.ts:1
// queries.ts
import { useQuery } from '@tanstack/react-query';
import { isCustomerAuth } from '@libs/networking-client';
import { localStorage } from '@libs/persistent-storage';
const customerToken = localStorage.getString(LOCALSTORE_CUSTOMER_TOKEN)
? JSON.parse(localStorage.getString(LOCALSTORE_CUSTOMER_TOKEN))
: undefined;
export const useAuthState = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, AUTH_QUERY_KEY],
queryFn: fetchAuthRepository,
select: (data) => data.authToken || customerToken,
});
export const useIsSignedInState = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, AUTH_QUERY_KEY],
queryFn: fetchAuthRepository,
select: (data) =>
Boolean(data.authToken && isCustomerAuth(data.authToken)) ||
Boolean(customerToken && isCustomerAuth(customerToken)),
});
Patterns:
Why: Keeps complex logic in queries layer, not components. Components get computed values directly.
Production Example: git-resources/shared-mobile-modules/src/data-access/native/auth/queries.ts:46
// events.ts
import { sendEvent } from '@libs/native-modules/events';
import type { UpdatePlanEventData } from './types';
export enum PlanEvents {
updatePlan = 'updatePlan',
}
export type PlanEventNames = PlanEvents.updatePlan;
/**
* Emits an update event to the native layer.
*/
export const updatePlan = async (data: UpdatePlanEventData) =>
sendEvent(PlanEvents.updatePlan, {
payload: JSON.stringify(data),
});
Patterns:
Why: Type-safe event communication with native layer. Enum prevents typos, types ensure correct data structure.
Production Example: git-resources/shared-mobile-modules/src/data-access/native/plan/events.ts:1
// index.ts
import { REPOSITORY_KEYS } from '@data-access/native/constants';
import * as AppConfigEvents from './app-config/events';
import * as AppConfigQueries from './app-config/queries';
import { fetchAppConfigRepository } from './app-config/repository';
import * as AuthEvents from './auth/events';
import * as AuthQueries from './auth/queries';
import { fetchAuthRepository } from './auth/repository';
import * as PlanEvents from './plan/events';
import * as PlanQueries from './plan/queries';
import { fetchPlanRepository } from './plan/repository';
export const AuthDataAccess = {
events: AuthEvents,
queries: AuthQueries,
fetch: fetchAuthRepository,
repositoryKey: REPOSITORY_KEYS.auth,
} as const;
export const AppConfigDataAccess = {
events: AppConfigEvents,
queries: AppConfigQueries,
fetch: fetchAppConfigRepository,
repositoryKey: REPOSITORY_KEYS.appConfig,
} as const;
export const PlanDataAccess = {
events: PlanEvents,
queries: PlanQueries,
fetch: fetchPlanRepository,
repositoryKey: REPOSITORY_KEYS.plan,
} as const;
Structure:
events: Event emitters namespacequeries: Query hooks namespacefetch: Repository fetch functionrepositoryKey: Constant for REPOSITORY_KEYS mappingWhy: DataAccess objects provide:
as constProduction Example: git-resources/shared-mobile-modules/src/data-access/native/index.ts:34
// constants.ts
export const NATIVE_MODULES_REPOSITORY_QUERY_KEY = 'nativeRepositories';
export const REPOSITORY_KEYS = {
auth: AUTH_QUERY_KEY,
appConfig: APP_CONFIG_QUERY_KEY,
plan: PLAN_QUERY_KEY,
navigationBar: NAVIGATION_BAR_QUERY_KEY,
nativeNavigation: NATIVE_NAVIGATION_RESULT_QUERY_KEY,
loyaltyBanner: LOYALTY_BANNER_QUERY_KEY,
inboxSalesforce: INBOX_SALESFORCE_QUERY_KEY,
loyaltyProgramState: LOYALTY_PROGRAM_STATE_QUERY_KEY,
} as const;
Patterns:
as const for type safetyWhy: Centralized keys enable:
Production Example: git-resources/shared-mobile-modules/src/data-access/native/constants.ts:1
import { PlanDataAccess } from '@data-access/native';
export const CheckoutScreen = () => {
const { data: planId, isLoading } = PlanDataAccess.queries.usePlanId();
if (isLoading) return <LoadingSpinner />;
if (!planId) return <ErrorMessage />;
return <CheckoutFlow planId={planId} />;
};
Patterns:
Why: Components get clean API without knowing about query keys or fetch functions.
import { AppConfigDataAccess } from '@data-access/native';
export const LocalizedScreen = () => {
const { data: locale } = AppConfigDataAccess.queries.useLocale();
const { data: country } = AppConfigDataAccess.queries.useCountry();
const { data: brand } = AppConfigDataAccess.queries.useBrand();
// All three hooks share same query - no duplicate requests
return (
<LocalizedContent
locale={locale}
country={country}
brand={brand}
/>
);
};
Why: TanStack Query deduplicates requests automatically. Multiple hooks selecting different properties is efficient.
ā Don't use for server data:
// ā Wrong - Native data access is NOT for API calls
export const fetchProductsRepository = async () =>
fetchRepository<Product[]>('products', []);
// ā
Correct - Use GraphQL or REST
export const useGetProductsQuery = () =>
useQuery({
queryKey: ['products'],
queryFn: () => apiClient.get('/products'),
});
ā Don't skip initial state:
// ā Missing initial state - will be undefined on timeout
export const fetchPlanRepository = async () =>
fetchRepository<PlanRepositorySchema>(PLAN_QUERY_KEY);
// ā
Always provide initial state
const initialState: PlanRepositorySchema = {
subscriptionId: undefined,
planId: undefined,
};
export const fetchPlanRepository = async () =>
fetchRepository<PlanRepositorySchema>(PLAN_QUERY_KEY, initialState);
ā Don't use different query keys:
// ā Inconsistent query keys
export const usePlanId = () =>
useQuery({
queryKey: ['plan'], // Wrong!
queryFn: fetchPlanRepository,
select: (data) => data.planId,
});
// ā
Always use NATIVE_MODULES_REPOSITORY_QUERY_KEY
export const usePlanId = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, PLAN_QUERY_KEY],
queryFn: fetchPlanRepository,
select: (data) => data.planId,
});
ā Don't create duplicate queries:
// ā Multiple hooks with different query keys
export const usePlanId = () =>
useQuery({
queryKey: ['planId'],
queryFn: async () => {
const data = await fetchPlanRepository();
return data.planId;
},
});
// ā
Share same query with select
export const usePlanId = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, PLAN_QUERY_KEY],
queryFn: fetchPlanRepository,
select: (data) => data.planId,
});
ā Do follow domain-based structure:
// ā
Correct structure
data-access/native/plan/
āāā constants.ts
āāā repository.ts
āāā schema.ts
āāā queries.ts
āāā events.ts
ā Do use DataAccess objects:
// ā
Centralized export
export const PlanDataAccess = {
events: PlanEvents,
queries: PlanQueries,
fetch: fetchPlanRepository,
repositoryKey: REPOSITORY_KEYS.plan,
} as const;
ā Do provide fallback logic:
// ā
Fallback to cached data or initial state
setTimeout(() => {
const cached = queryClient.getQueryData([
NATIVE_MODULES_REPOSITORY_QUERY_KEY,
repositoryName,
]);
if (cached) {
resolve(cached as T);
} else {
resolve(initialState);
}
}, 2000);
ā Do use select for property access:
// ā
Efficient property selection
export const usePlanId = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, PLAN_QUERY_KEY],
queryFn: fetchPlanRepository,
select: (data) => data.planId,
});
import { fetchRepository } from '@libs/query';
jest.mock('@libs/query', () => ({
fetchRepository: jest.fn(),
queryClient: {
getQueryData: jest.fn(),
},
}));
describe('fetchPlanRepository', () => {
it('returns plan data from native', async () => {
const mockData = {
planId: 'plan-123',
subscriptionId: 'sub-456',
};
(fetchRepository as jest.Mock).mockResolvedValue(mockData);
const result = await fetchPlanRepository();
expect(fetchRepository).toHaveBeenCalledWith(
PLAN_QUERY_KEY,
expect.objectContaining({
planId: undefined,
subscriptionId: undefined,
})
);
expect(result).toEqual(mockData);
});
it('returns initial state on timeout', async () => {
(fetchRepository as jest.Mock).mockResolvedValue({
planId: undefined,
subscriptionId: undefined,
});
const result = await fetchPlanRepository();
expect(result.planId).toBeUndefined();
expect(result.subscriptionId).toBeUndefined();
});
});
Patterns:
fetchRepository from @libs/queryWhy: Testing mocked fetchRepository ensures repository functions call it correctly without depending on native layer.
import { renderHook } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { usePlanId } from './queries';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('usePlanId', () => {
it('returns planId from repository', async () => {
(fetchRepository as jest.Mock).mockResolvedValue({
planId: 'plan-123',
subscriptionId: 'sub-456',
});
const { result } = renderHook(() => usePlanId(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toBe('plan-123');
});
});
Why: Tests verify hooks correctly select properties from repository data.
File Structure:
constants.ts: Query keys and constantsrepository.ts: fetchRepository implementationschema.ts: TypeScript interfacequeries.ts: TanStack Query hooksevents.ts: Event emitters (optional)index.ts: DataAccess object exportQuery Key Pattern:
[NATIVE_MODULES_REPOSITORY_QUERY_KEY, DOMAIN_QUERY_KEY]
// Example: ['nativeRepositories', 'plan']
Repository Pattern:
const initialState: Schema = { /* defaults */ };
export const fetch{Domain}Repository = async () =>
fetchRepository<Schema>(DOMAIN_QUERY_KEY, initialState);
Query Hook Pattern:
export const use{Property} = () =>
useQuery({
queryKey: [NATIVE_MODULES_REPOSITORY_QUERY_KEY, DOMAIN_QUERY_KEY],
queryFn: fetch{Domain}Repository,
select: (data) => data.property,
});
DataAccess Object Pattern:
export const {Domain}DataAccess = {
events: {Domain}Events,
queries: {Domain}Queries,
fetch: fetch{Domain}Repository,
repositoryKey: REPOSITORY_KEYS.{domain},
} as const;
Key Libraries:
ā ļø Remember: Native data access is ONLY for native observables. Use GraphQL or REST for server data.
For production examples, see references/examples.md.