50 KiB
//[doc-seo]
{
"Description": "Learn how to develop a mobile application using React Native with the ABP Framework. Build the Acme.BookStore mobile UI on top of the modernized ABP React Native template (NativeWind v4 + Bottom Tab navigation)."
}
Mobile Application Development Tutorial - React Native
The React Native mobile option is available for Team or higher licenses. If you don't have a commercial license, follow this article by downloading the source code of the sample application linked below.
About This Tutorial
You must have an ABP Team or a higher license to be able to create a mobile application.
- This tutorial assumes you have completed the Web Application Development tutorial and built an ABP based application named
Acme.BookStorewith React Native as the mobile option. If you haven't completed it, you can either complete it first or download the source code below and follow this tutorial. - This tutorial only focuses on the React Native UI side of the
Acme.BookStoreapplication. It implements the CRUD operations forBooksandAuthors, plus the relation between them. The backend (entities, application services, permissions, seeder) is already in place in the downloadable sample. - The mobile template was modernized in 2026: it now uses NativeWind v4 (Tailwind CSS for React Native) for styling, Bottom Tab navigation by default, and the Redux Toolkit store with hook-based access (
useSelector/useDispatch). TheconnectToReduxHOC, theDrawerNavigator, and the legacyDataList/AbpSelectcomponents from earlier versions no longer ship with the template — this tutorial walks through building the new equivalents. - Before starting, please make sure that the React Native Development Environment is ready on your machine.
Download the Source Code
You can use the following link to download the source code of the application described in this article:
If you encounter the "filename too long" or "unzip" error on Windows, please see this guide.
The downloaded sample contains:
src/— ABP backend (Acme.BookStore.*projects). It already exposesBookAppServiceandAuthorAppServicewith the CRUD endpoints we will consume.react-native/— the React Native client. The auth, profile and settings flows ship out of the box. Throughout this tutorial we will add theBookStorefeature to it.
Backend Setup (Quick Reference)
The backend ships ready-to-run. The relevant pieces consumed from React Native are:
- Endpoints
GET /api/app/book— paged list (returnsitemswithid,name,type,publishDate,price,authorName)GET /api/app/book/{id}— single bookPOST /api/app/book— createPUT /api/app/book/{id}— updateDELETE /api/app/book/{id}— deleteGET /api/app/book/author-lookup—{ items: [{ id, name }] }for the author dropdownGET /api/app/author— paged list (items: [{ id, name, birthDate, shortBio }])GET /api/app/author/{id},POST /api/app/author,PUT /api/app/author/{id},DELETE /api/app/author/{id}
- Permissions (use these names from the React Native side to gate UI):
BookStore.Books,BookStore.Books.Create,BookStore.Books.Edit,BookStore.Books.DeleteBookStore.Authors,BookStore.Authors.Create,BookStore.Authors.Edit,BookStore.Authors.Delete
To run the backend, start Acme.BookStore.DbMigrator once (it seeds three sample authors and six sample books), then run Acme.BookStore.HttpApi.Host. The default BookStore permissions need to be granted to the admin user via the Identity → Roles → admin → Permissions screen of the web UI before testing on mobile.
If you want to follow the backend implementation step by step instead, read the Web Application Development tutorial. The mobile-side code below works against the API surface listed above regardless of how you produced it.
Adding the Book API Proxy
There is no dynamic proxy generation for the React Native application, so we create the BookAPI proxy manually under ./src/api.
// ./src/api/BookAPI.ts
import api from './API';
export const getList = (params: { maxResultCount?: number; skipCount?: number; sorting?: string } = {}) =>
api.get('/api/app/book', { params }).then(({ data }) => data);
export const get = (id: string) =>
api.get(`/api/app/book/${id}`).then(({ data }) => data);
export const create = (input: any) =>
api.post('/api/app/book', input).then(({ data }) => data);
export const update = (input: any, id: string) =>
api.put(`/api/app/book/${id}`, input).then(({ data }) => data);
export const remove = (id: string) =>
api.delete(`/api/app/book/${id}`).then(({ data }) => data);
export const getAuthorLookup = () =>
api.get('/api/app/book/author-lookup').then(({ data }) => data);
We will create ./src/api/AuthorAPI.ts later in the Author Section.
apiis the sharedaxiosinstance (./src/api/API.ts) that injects the access token via the request interceptor in./src/interceptors/APIInterceptor.ts.getListaccepts a paging payload (maxResultCount,skipCount,sorting) so it can be plugged into theDataListcomponent we build next.
Building the DataList Component
The earlier React Native template shipped a DataList component on top of React Native Paper. The new template only ships the essentials (FormButtons, Loading, ValidationMessage), so we add a NativeWind-based equivalent under ./src/components/DataList.
// ./src/components/DataList/DataList.tsx
import { useCallback, useContext, useEffect, useState } from 'react';
import { View, Text, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
import { LocalizationContext } from '../../contexts/LocalizationContext';
import { useThemeColors } from '../../hooks';
interface DataListProps<T> {
fetchFn: (params: { maxResultCount: number; skipCount: number }) => Promise<{ items: T[]; totalCount: number }>;
render: (info: { item: T; index: number }) => React.ReactElement;
trigger?: any;
pageSize?: number;
}
function DataList<T extends { id: string }>({
fetchFn,
render,
trigger,
pageSize = 20,
}: DataListProps<T>) {
const { t } = useContext(LocalizationContext);
const { accentColor } = useThemeColors();
const [items, setItems] = useState<T[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [skipCount, setSkipCount] = useState(0);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const loadPage = useCallback(
async (skip: number, append: boolean) => {
if (loading) return;
setLoading(true);
try {
const result = await fetchFn({ maxResultCount: pageSize, skipCount: skip });
const fetched = result?.items ?? [];
setTotalCount(result?.totalCount ?? 0);
setItems(prev => (append ? [...prev, ...fetched] : fetched));
setSkipCount(skip + fetched.length);
} catch (e) {
if (!append) setItems([]);
} finally {
setLoading(false);
}
},
[fetchFn, pageSize, loading],
);
useEffect(() => {
setSkipCount(0);
loadPage(0, false);
}, [trigger]);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await loadPage(0, false);
setRefreshing(false);
}, [loadPage]);
const onEndReached = useCallback(() => {
if (loading || refreshing) return;
if (items.length >= totalCount) return;
loadPage(skipCount, true);
}, [loading, refreshing, items.length, totalCount, skipCount, loadPage]);
return (
<FlatList
data={items}
keyExtractor={(item, index) => item?.id?.toString() ?? index.toString()}
renderItem={render}
contentContainerStyle={{ flexGrow: 1, paddingBottom: 96 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={accentColor} />}
onEndReached={onEndReached}
onEndReachedThreshold={0.4}
ItemSeparatorComponent={() => (
<View className="h-px bg-border dark:bg-border-dark mx-4" />
)}
ListEmptyComponent={
loading ? null : (
<View className="flex-1 items-center justify-center py-10">
<Text className="text-muted-foreground dark:text-muted-dark-foreground">
{t('AbpUi::NoData')}
</Text>
</View>
)
}
ListFooterComponent={
loading && items.length > 0 ? (
<View className="py-4 items-center">
<ActivityIndicator color={accentColor} />
</View>
) : null
}
/>
);
}
export default DataList;
fetchFnis any function that accepts{ maxResultCount, skipCount }and returns{ items, totalCount }— the shape of every ABPICrudAppService.GetListAsyncresponse.triggeris an arbitrary value: pass a counter that you increment (setRefresh(r => r + 1)) after a delete or save and the list re-fetches from page zero.- The pull-to-refresh and the lazy "load more on end reached" behavior are built in.
Building the AbpSelect Component
For dropdowns (book type, author selection) we build a small modal-based picker, also under ./src/components.
// ./src/components/AbpSelect/AbpSelect.tsx
import { useContext } from 'react';
import { Modal, View, Text, Pressable, FlatList } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LocalizationContext } from '../../contexts/LocalizationContext';
import { useThemeColors } from '../../hooks';
export interface AbpSelectItem {
id: string | number;
displayName: string;
}
interface AbpSelectProps {
visible: boolean;
title: string;
items: AbpSelectItem[];
selectedItem?: string | number;
hasDefaultItem?: boolean;
hideModalFn: () => void;
setSelectedItem: (id: any) => void;
}
function AbpSelect({
visible,
title,
items,
selectedItem,
hasDefaultItem = false,
hideModalFn,
setSelectedItem,
}: AbpSelectProps) {
const { t } = useContext(LocalizationContext);
const { accentColor } = useThemeColors();
const data = hasDefaultItem
? [{ id: '', displayName: `-- ${t('AbpUi::PagerInfo:NoDataText')} --` } as AbpSelectItem, ...items]
: items;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={hideModalFn}>
<Pressable
onPress={hideModalFn}
className="flex-1 bg-black/50 items-center justify-center px-6">
<Pressable
onPress={() => {}}
className="w-full max-w-md bg-card dark:bg-card-dark rounded-2xl border border-card-border dark:border-card-border-dark shadow-lg overflow-hidden">
<View className="px-5 py-4 border-b border-card-border dark:border-card-border-dark flex-row items-center justify-between">
<Text className="text-base font-semibold text-foreground dark:text-foreground-dark">
{title}
</Text>
<Pressable onPress={hideModalFn} hitSlop={8}>
<Ionicons name="close" size={22} color={accentColor} />
</Pressable>
</View>
<FlatList
data={data}
keyExtractor={item => String(item.id)}
style={{ maxHeight: 360 }}
ItemSeparatorComponent={() => (
<View className="h-px bg-border dark:bg-border-dark mx-4" />
)}
renderItem={({ item }) => {
const isSelected = String(item.id) === String(selectedItem ?? '');
return (
<Pressable
onPress={() => {
setSelectedItem(item.id);
hideModalFn();
}}
className={`px-5 py-3.5 flex-row items-center justify-between ${
isSelected ? 'bg-secondary dark:bg-secondary-dark' : ''
}`}>
<Text
className={`flex-1 text-[15px] ${
isSelected
? 'text-foreground dark:text-foreground-dark font-semibold'
: 'text-foreground dark:text-foreground-dark'
}`}>
{item.displayName}
</Text>
{isSelected ? <Ionicons name="checkmark" size={20} color={accentColor} /> : null}
</Pressable>
);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
}
export default AbpSelect;
Now expose the two new components from the barrel file so screens can import them with a single statement:
// ./src/components/index.ts
export { default as FormButtons } from './FormButtons/FormButtons';
export { default as ValidationMessage } from './ValidationMessage/ValidationMessage';
export { default as DataList } from './DataList/DataList';
export { default as AbpSelect } from './AbpSelect/AbpSelect';
export type { AbpSelectItem } from './AbpSelect/AbpSelect';
Creating the BookStoreNavigator
The BookStore feature has three screens that share a stack: the list root (BookStore), CreateUpdateBook, and CreateUpdateAuthor. Add the route names to the typed navigator definitions first.
// ./src/navigators/types.ts (additions)
export type BookStoreStackParamList = {
BookStore: undefined;
CreateUpdateBook: { bookId?: string } | undefined;
CreateUpdateAuthor: { authorId?: string } | undefined;
};
export type BookStoreScreenProps = NativeStackScreenProps<BookStoreStackParamList, 'BookStore'>;
export type CreateUpdateBookScreenProps = NativeStackScreenProps<BookStoreStackParamList, 'CreateUpdateBook'>;
export type CreateUpdateAuthorScreenProps = NativeStackScreenProps<BookStoreStackParamList, 'CreateUpdateAuthor'>;
Also extend BottomTabParamList:
export type BottomTabParamList = {
HomeTab: undefined;
BookStoreTab: undefined;
SettingsTab: undefined;
AccountTab: undefined;
};
Then create the stack navigator:
// ./src/navigators/BookStoreNavigator.tsx
import { useContext } from 'react';
import { Pressable, Text } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useThemeColors } from '../hooks';
import { LocalizationContext } from '../contexts/LocalizationContext';
import {
BookStoreScreen,
CreateUpdateBookScreen,
CreateUpdateAuthorScreen,
} from '../screens';
import type { BookStoreStackParamList } from './types';
const Stack = createNativeStackNavigator<BookStoreStackParamList>();
export default function BookStoreStackNavigator() {
const { headerBg, headerText, accentColor } = useThemeColors();
const { t } = useContext(LocalizationContext);
return (
<Stack.Navigator id="BookStoreStack" initialRouteName="BookStore">
<Stack.Screen
name="BookStore"
component={BookStoreScreen}
options={{
title: t('BookStore::Menu:BookStore'),
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerText,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="CreateUpdateBook"
component={CreateUpdateBookScreen}
options={({ route, navigation }) => ({
title: t(route.params?.bookId ? 'BookStore::Edit' : 'BookStore::NewBook'),
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerText,
headerShadowVisible: false,
headerRight: () => (
<Pressable onPress={() => navigation.goBack()} hitSlop={8}>
<Text style={{ color: accentColor, fontWeight: '600' }}>{t('AbpUi::Cancel')}</Text>
</Pressable>
),
})}
/>
<Stack.Screen
name="CreateUpdateAuthor"
component={CreateUpdateAuthorScreen}
options={({ route, navigation }) => ({
title: t(route.params?.authorId ? 'BookStore::Edit' : 'BookStore::NewAuthor'),
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerText,
headerShadowVisible: false,
headerRight: () => (
<Pressable onPress={() => navigation.goBack()} hitSlop={8}>
<Text style={{ color: accentColor, fontWeight: '600' }}>{t('AbpUi::Cancel')}</Text>
</Pressable>
),
})}
/>
</Stack.Navigator>
);
}
The screens referenced in the imports above will be created in the next sections.
Adding BookStore to the BottomTabNavigator
Open ./src/navigators/BottomTabNavigator.tsx and add a BookStoreTab between HomeTab and SettingsTab. The tab is shown only when the user has at least one of the BookStore permissions:
// ./src/navigators/BottomTabNavigator.tsx
import { useContext } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { useSelector } from 'react-redux';
import { useThemeColors } from '../hooks';
import { LocalizationContext } from '../contexts/LocalizationContext';
import { appConfigSelector } from '../store/selectors/AppSelectors';
import HomeStackNavigator from './HomeNavigator';
import SettingsStackNavigator from './SettingsNavigator';
import AccountStackNavigator from './AccountNavigator';
import BookStoreStackNavigator from './BookStoreNavigator';
const Tab = createBottomTabNavigator();
export default function BottomTabNavigator() {
const { headerBg, accentColor, iconColor } = useThemeColors();
const { t } = useContext(LocalizationContext);
const policies = useSelector(appConfigSelector)?.auth?.grantedPolicies ?? {};
const showBookStore = !!policies['BookStore.Books'] || !!policies['BookStore.Authors'];
return (
<Tab.Navigator
id="BottomTab"
initialRouteName="HomeTab"
screenOptions={{ /* ... existing screen options ... */ }}>
<Tab.Screen name="HomeTab" component={HomeStackNavigator} options={/* ... */} />
{showBookStore ? (
<Tab.Screen
name="BookStoreTab"
component={BookStoreStackNavigator}
options={{
title: t('BookStore::Menu:BookStore'),
tabBarIcon: ({ focused, color, size }) => (
<Ionicons name={focused ? 'book' : 'book-outline'} size={size} color={color} />
),
}}
/>
) : null}
<Tab.Screen name="SettingsTab" component={SettingsStackNavigator} options={/* ... */} />
<Tab.Screen name="AccountTab" component={AccountStackNavigator} options={/* ... */} />
</Tab.Navigator>
);
}
Earlier versions of the template used a
DrawerNavigator. The 2026 template defaults tobottom-tabinstead. If your project still uses the drawer (the optionalnavigation_type = "drawer"configuration), add the same conditionalDrawer.ScreentoDrawerNavigator.tsxinstead.
Creating the BookStoreScreen
BookStoreScreen is the root of the stack. It hosts a small NativeWind-based tab header that switches between the Books and Authors lists. Each tab is rendered only if the user has the corresponding permission.
// ./src/screens/BookStore/BookStoreScreen.tsx
import { useContext, useEffect, useMemo, useState } from 'react';
import { View, Text, Pressable } from 'react-native';
import { useSelector } from 'react-redux';
import { LocalizationContext } from '../../contexts/LocalizationContext';
import { appConfigSelector } from '../../store/selectors/AppSelectors';
import type { BookStoreScreenProps } from '../../navigators/types';
import BooksScreen from './Books/BooksScreen';
import AuthorsScreen from './Authors/AuthorsScreen';
type TabKey = 'books' | 'authors';
interface TabDef { key: TabKey; label: string; }
function BookStoreScreen({ navigation }: BookStoreScreenProps) {
const { t } = useContext(LocalizationContext);
const policies = useSelector(appConfigSelector)?.auth?.grantedPolicies ?? {};
const tabs = useMemo<TabDef[]>(() => {
const list: TabDef[] = [];
if (policies['BookStore.Books']) list.push({ key: 'books', label: t('BookStore::Menu:Books') });
if (policies['BookStore.Authors']) list.push({ key: 'authors', label: t('BookStore::Menu:Authors') });
return list;
}, [policies, t]);
const [activeKey, setActiveKey] = useState<TabKey | undefined>(tabs[0]?.key);
useEffect(() => {
if (!tabs.find(tab => tab.key === activeKey)) setActiveKey(tabs[0]?.key);
}, [tabs, activeKey]);
if (tabs.length === 0) {
return (
<View className="flex-1 bg-background dark:bg-background-dark items-center justify-center px-6">
<Text className="text-muted-foreground dark:text-muted-dark-foreground text-center">
{t('BookStore::NoAccess')}
</Text>
</View>
);
}
return (
<View className="flex-1 bg-background dark:bg-background-dark">
<View className="flex-row bg-background dark:bg-background-dark border-b border-card-border dark:border-card-border-dark">
{tabs.map(tab => {
const isActive = activeKey === tab.key;
return (
<Pressable
key={tab.key}
onPress={() => setActiveKey(tab.key)}
className={`flex-1 py-3 items-center border-b-2 ${
isActive ? 'border-accent dark:border-accent-dark' : 'border-transparent'
}`}>
<Text
className={`text-[14px] ${
isActive
? 'text-foreground dark:text-foreground-dark font-semibold'
: 'text-muted-foreground dark:text-muted-dark-foreground'
}`}>
{tab.label}
</Text>
</Pressable>
);
})}
</View>
<View className="flex-1">
{activeKey === 'books' ? <BooksScreen navigation={navigation} /> : null}
{activeKey === 'authors' ? <AuthorsScreen navigation={navigation} /> : null}
</View>
</View>
);
}
export default BookStoreScreen;
The previous template used react-native-paper's BottomNavigation for this. Building the tab strip with two Pressables and NativeWind classes keeps the rest of the screen consistent with the modernized look and avoids paying for an extra Paper component in the bundle.
The Book List Page
Create ./src/screens/BookStore/Books/BooksScreen.tsx. The list itself is a single DataList<BookListItem>. Each row is a Pressable that opens an action sheet with Edit and Delete entries — both gated by the corresponding permission. The floating "+" button at the bottom right is rendered only when the user has BookStore.Books.Create.
// ./src/screens/BookStore/Books/BooksScreen.tsx
import { useContext, useState } from 'react';
import { Alert, View, Text, Pressable } from 'react-native';
import { useSelector } from 'react-redux';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { Ionicons } from '@expo/vector-icons';
import { LocalizationContext } from '../../../contexts/LocalizationContext';
import { appConfigSelector } from '../../../store/selectors/AppSelectors';
import { useThemeColors } from '../../../hooks';
import { DataList } from '../../../components';
import { getList, remove } from '../../../api/BookAPI';
import type { BookStoreScreenProps } from '../../../navigators/types';
interface BookListItem {
id: string;
name: string;
authorName: string;
type: number;
}
interface BooksScreenInnerProps { navigation: BookStoreScreenProps['navigation']; }
function BooksScreen({ navigation }: BooksScreenInnerProps) {
const { t } = useContext(LocalizationContext);
const { accentColor, iconColor } = useThemeColors();
const policies = useSelector(appConfigSelector)?.auth?.grantedPolicies ?? {};
const [refresh, setRefresh] = useState(0);
const { showActionSheetWithOptions } = useActionSheet();
const canCreate = !!policies['BookStore.Books.Create'];
const canEdit = !!policies['BookStore.Books.Edit'];
const canDelete = !!policies['BookStore.Books.Delete'];
const openContextMenu = (item: BookListItem) => {
const options: string[] = [];
if (canEdit) options.push(t('BookStore::Edit'));
if (canDelete) options.push(t('AbpUi::Delete'));
options.push(t('AbpUi::Cancel'));
showActionSheetWithOptions(
{
options,
cancelButtonIndex: options.length - 1,
destructiveButtonIndex: canDelete ? options.indexOf(t('AbpUi::Delete')) : undefined,
},
(index?: number) => {
if (index === undefined) return;
const selected = options[index];
if (selected === t('BookStore::Edit')) navigation.navigate('CreateUpdateBook', { bookId: item.id });
else if (selected === t('AbpUi::Delete')) confirmDelete(item);
},
);
};
const confirmDelete = (item: BookListItem) => {
Alert.alert(t('AbpUi::AreYouSure'), t('BookStore::AreYouSureToDelete'), [
{ text: t('AbpUi::Cancel'), style: 'cancel' },
{
text: t('AbpUi::Ok'),
style: 'destructive',
onPress: async () => {
await remove(item.id);
setRefresh(prev => prev + 1);
},
},
]);
};
return (
<View className="flex-1 bg-background dark:bg-background-dark">
<DataList<BookListItem>
fetchFn={getList as any}
trigger={refresh}
render={({ item }) => (
<Pressable
onPress={() => (canEdit || canDelete) && openContextMenu(item)}
className="px-4 py-3.5 active:bg-secondary dark:active:bg-secondary-dark">
<View className="flex-row items-center">
<View className="flex-1">
<Text className="text-[15px] font-semibold text-foreground dark:text-foreground-dark">
{item.name}
</Text>
<Text className="text-xs text-muted-foreground dark:text-muted-dark-foreground mt-1">
{item.authorName} · {t(`BookStore::Enum:BookType:${item.type}`)}
</Text>
</View>
{(canEdit || canDelete) ? (
<Ionicons name="ellipsis-vertical" size={18} color={iconColor} />
) : null}
</View>
</Pressable>
)}
/>
{canCreate ? (
<Pressable
onPress={() => navigation.navigate('CreateUpdateBook')}
className="absolute right-5 bottom-5 rounded-full px-5 py-3.5 flex-row items-center shadow-lg bg-accent dark:bg-accent-dark active:opacity-90">
<Ionicons
name="add"
size={20}
color={accentColor === '#fafafa' ? '#18181b' : '#fafafa'}
/>
<Text className="ml-1.5 font-semibold text-[14px] text-accent-foreground dark:text-accent-dark-foreground">
{t('BookStore::NewBook')}
</Text>
</Pressable>
) : null}
</View>
);
}
export default BooksScreen;
- Reading
appConfigSelectorfrom the Redux store (noconnectToReduxHOC) is the new pattern. The selector lives in./src/store/selectors/AppSelectors.tsand returns theappConfigobject that the template populates after login (AppActions.fetchAppConfigAsync). useActionSheetis provided by@expo/react-native-action-sheet, already wrapped around the app in./src/AppContent.tsx, so we don't need to add a provider here.
Creating a New Book
The book form needs @react-native-community/datetimepicker for the publish-date field. Install it:
npx expo install @react-native-community/datetimepicker
Then create the screen + form pair under ./src/screens/BookStore/Books/CreateUpdateBook/.
CreateUpdateBookScreen
This component wires Redux loading + API calls and forwards data to the form.
// ./src/screens/BookStore/Books/CreateUpdateBook/CreateUpdateBookScreen.tsx
import { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { get, create, update, getAuthorLookup } from '../../../../api/BookAPI';
import LoadingActions from '../../../../store/actions/LoadingActions';
import type { CreateUpdateBookScreenProps } from '../../../../navigators/types';
import type { AbpSelectItem } from '../../../../components';
import CreateUpdateBookForm, { type BookFormValues } from './CreateUpdateBookForm';
function CreateUpdateBookScreen({ navigation, route }: CreateUpdateBookScreenProps) {
const { bookId } = route.params || {};
const dispatch = useDispatch();
const [book, setBook] = useState<any | null>(null);
const [authors, setAuthors] = useState<AbpSelectItem[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
dispatch(LoadingActions.start({ key: 'fetchAuthorLookup' }));
try {
const result = await getAuthorLookup();
if (cancelled) return;
setAuthors((result?.items ?? []).map((a: any) => ({ id: a.id, displayName: a.name })));
} finally {
dispatch(LoadingActions.clear());
}
})();
return () => { cancelled = true; };
}, [dispatch]);
useEffect(() => {
if (!bookId) return;
let cancelled = false;
(async () => {
dispatch(LoadingActions.start({ key: 'fetchBookDetail' }));
try {
const detail = await get(bookId);
if (!cancelled) setBook(detail);
} finally {
dispatch(LoadingActions.clear());
}
})();
return () => { cancelled = true; };
}, [bookId, dispatch]);
const submit = async (data: BookFormValues) => {
dispatch(LoadingActions.start({ key: 'save' }));
try {
const payload = {
authorId: data.authorId,
name: data.name,
type: Number(data.type),
publishDate: data.publishDate ? new Date(data.publishDate).toISOString() : new Date().toISOString(),
price: Number(data.price),
};
if (bookId) await update(payload, bookId);
else await create(payload);
navigation.goBack();
} finally {
dispatch(LoadingActions.clear());
}
};
return <CreateUpdateBookForm submit={submit} book={book} authors={authors} />;
}
export default CreateUpdateBookScreen;
LoadingActions.start({ key })andLoadingActions.clear()drive the global<Loading />overlay rendered inAppContent.tsxvia the Redux loading reducer. No extra wiring is needed in this screen.getAuthorLookuplives inBookAPI.ts(we added it earlier). It returns{ items: [{ id, name }] }, which the form turns into a dropdown.
CreateUpdateBookForm
The form is a Formik form. We keep react-native-paper's TextInput for the input fields (the only Paper component the template still uses) and rely on our new AbpSelect for the type and author pickers, plus DateTimePicker for the publish date.
// ./src/screens/BookStore/Books/CreateUpdateBook/CreateUpdateBookForm.tsx
import * as Yup from 'yup';
import { useContext, useMemo, useState } from 'react';
import { View, Text, ScrollView, KeyboardAvoidingView, Platform, Pressable, Modal } from 'react-native';
import { useFormik } from 'formik';
import { TextInput } from 'react-native-paper';
import DateTimePicker from '@react-native-community/datetimepicker';
import { useThemeColors } from '../../../../hooks';
import { LocalizationContext } from '../../../../contexts/LocalizationContext';
import { AbpSelect, FormButtons, ValidationMessage } from '../../../../components';
import type { AbpSelectItem } from '../../../../components';
export interface BookFormValues {
authorId: string;
authorName: string;
name: string;
type: string;
typeDisplayName: string;
publishDate: Date | null;
price: string;
}
interface CreateUpdateBookFormProps {
submit: (values: BookFormValues) => Promise<void> | void;
book?: any | null;
authors: AbpSelectItem[];
}
const validationSchema = Yup.object().shape({
name: Yup.string().required('AbpValidation::ThisFieldIsRequired'),
price: Yup.number().typeError('AbpValidation::ThisFieldIsRequired').required('AbpValidation::ThisFieldIsRequired'),
type: Yup.string().required('AbpValidation::ThisFieldIsRequired'),
authorId: Yup.string().required('AbpValidation::ThisFieldIsRequired'),
publishDate: Yup.date().typeError('AbpValidation::ThisFieldIsRequired').required('AbpValidation::ThisFieldIsRequired').nullable(),
});
const formatDate = (value: Date | null) => (value ? new Date(value).toLocaleDateString() : '');
function CreateUpdateBookForm({ submit, book, authors }: CreateUpdateBookFormProps) {
const { t } = useContext(LocalizationContext);
const { primaryContainer, accentColor, headerBg } = useThemeColors();
const [typeModalVisible, setTypeModalVisible] = useState(false);
const [authorModalVisible, setAuthorModalVisible] = useState(false);
const [dateModalVisible, setDateModalVisible] = useState(false);
const [tempDate, setTempDate] = useState<Date>(new Date());
const bookTypes = useMemo<AbpSelectItem[]>(
() => Array.from({ length: 8 }, (_, i) => ({
id: String(i + 1),
displayName: t(`BookStore::Enum:BookType:${i + 1}`),
})),
[t],
);
const initialValues: BookFormValues = useMemo(() => {
const typeIdStr = book?.type ? String(book.type) : '';
return {
authorId: book?.authorId ?? '',
authorName: authors.find(a => String(a.id) === String(book?.authorId))?.displayName ?? '',
name: book?.name ?? '',
type: typeIdStr,
typeDisplayName: typeIdStr ? t(`BookStore::Enum:BookType:${typeIdStr}`) : '',
publishDate: book?.publishDate ? new Date(book.publishDate) : null,
price: book?.price !== undefined ? String(book.price) : '',
};
}, [book, authors, t]);
const form = useFormik<BookFormValues>({
enableReinitialize: true,
initialValues,
validateOnChange: false,
validateOnBlur: true,
validationSchema,
onSubmit: values => submit(values),
});
const showError = (field: keyof BookFormValues) =>
(form.submitCount > 0 || !!form.touched[field]) && !!form.errors[field];
const renderError = (field: keyof BookFormValues) =>
showError(field) ? <ValidationMessage>{form.errors[field] as string}</ValidationMessage> : null;
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1 bg-background dark:bg-background-dark">
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
<View className="bg-card dark:bg-card-dark rounded-2xl border border-card-border dark:border-card-border-dark shadow-sm p-4">
{/* Name */}
<View className="mb-3">
<TextInput
mode="outlined"
label={t('BookStore::Name')}
value={form.values.name}
onChangeText={form.handleChange('name')}
onBlur={form.handleBlur('name')}
error={showError('name')}
autoCapitalize="sentences"
style={{ backgroundColor: primaryContainer }}
/>
{renderError('name')}
</View>
{/* Author dropdown — populated in the "Adding Author Relation to Book" section */}
<View className="mb-3">
<Pressable onPress={() => setAuthorModalVisible(true)}>
<View pointerEvents="none">
<TextInput
mode="outlined"
label={t('BookStore::Author')}
value={form.values.authorName}
editable={false}
error={showError('authorId')}
right={<TextInput.Icon icon="menu-down" />}
style={{ backgroundColor: primaryContainer }}
/>
</View>
</Pressable>
{renderError('authorId')}
</View>
{/* Type dropdown */}
<View className="mb-3">
<Pressable onPress={() => setTypeModalVisible(true)}>
<View pointerEvents="none">
<TextInput
mode="outlined"
label={t('BookStore::Type')}
value={form.values.typeDisplayName}
editable={false}
error={showError('type')}
right={<TextInput.Icon icon="menu-down" />}
style={{ backgroundColor: primaryContainer }}
/>
</View>
</Pressable>
{renderError('type')}
</View>
{/* Publish date picker */}
<View className="mb-3">
<Pressable
onPress={() => {
setTempDate(form.values.publishDate ?? new Date());
setDateModalVisible(true);
}}>
<View pointerEvents="none">
<TextInput
mode="outlined"
label={t('BookStore::PublishDate')}
value={formatDate(form.values.publishDate)}
editable={false}
error={showError('publishDate')}
right={<TextInput.Icon icon="calendar" />}
style={{ backgroundColor: primaryContainer }}
/>
</View>
</Pressable>
{renderError('publishDate')}
</View>
{/* Price */}
<View className="mb-2">
<TextInput
mode="outlined"
label={t('BookStore::Price')}
value={form.values.price}
onChangeText={form.handleChange('price')}
onBlur={form.handleBlur('price')}
error={showError('price')}
keyboardType="decimal-pad"
style={{ backgroundColor: primaryContainer }}
/>
{renderError('price')}
</View>
</View>
<View className="mt-4">
<FormButtons submit={() => form.handleSubmit()} isSubmitDisabled={form.isSubmitting} />
</View>
</ScrollView>
{/* Type modal */}
<AbpSelect
visible={typeModalVisible}
title={t('BookStore::Type')}
items={bookTypes}
hasDefaultItem
selectedItem={form.values.type}
hideModalFn={() => setTypeModalVisible(false)}
setSelectedItem={(id: any) => {
const idStr = String(id ?? '');
form.setFieldValue('type', idStr, true);
form.setFieldValue('typeDisplayName',
bookTypes.find(item => item.id === idStr)?.displayName ?? '', false);
}}
/>
{/* Author modal */}
<AbpSelect
visible={authorModalVisible}
title={t('BookStore::Author')}
items={authors}
hasDefaultItem
selectedItem={form.values.authorId}
hideModalFn={() => setAuthorModalVisible(false)}
setSelectedItem={(id: any) => {
const idStr = String(id ?? '');
form.setFieldValue('authorId', idStr, true);
form.setFieldValue('authorName',
authors.find(item => String(item.id) === idStr)?.displayName ?? '', false);
}}
/>
{/* Publish date modal */}
<Modal visible={dateModalVisible} transparent animationType="fade" onRequestClose={() => setDateModalVisible(false)}>
<Pressable onPress={() => setDateModalVisible(false)} className="flex-1 bg-black/50 items-center justify-center px-6">
<Pressable onPress={() => {}} className="w-full max-w-md bg-card dark:bg-card-dark rounded-2xl border border-card-border dark:border-card-border-dark shadow-lg overflow-hidden">
<View className="px-5 py-4 border-b border-card-border dark:border-card-border-dark">
<Text className="text-base font-semibold text-foreground dark:text-foreground-dark">
{t('BookStore::PublishDate')}
</Text>
</View>
<View style={{ backgroundColor: headerBg }}>
<DateTimePicker
value={tempDate}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(event: any, selectedDate?: Date) => {
if (Platform.OS !== 'ios') {
setDateModalVisible(false);
if (event?.type !== 'dismissed' && selectedDate) {
form.setFieldValue('publishDate', selectedDate, true);
}
} else if (selectedDate) {
setTempDate(selectedDate);
}
}}
maximumDate={new Date()}
/>
</View>
{Platform.OS === 'ios' ? (
<View className="flex-row justify-end px-4 py-3 border-t border-card-border dark:border-card-border-dark gap-x-2">
<Pressable onPress={() => setDateModalVisible(false)} className="px-4 py-2 rounded-md">
<Text style={{ color: accentColor, fontWeight: '600' }}>{t('AbpUi::Cancel')}</Text>
</Pressable>
<Pressable
onPress={() => {
form.setFieldValue('publishDate', tempDate, true);
setDateModalVisible(false);
}}
className="px-4 py-2 rounded-md bg-accent dark:bg-accent-dark">
<Text className="text-accent-foreground dark:text-accent-dark-foreground font-semibold">
{t('AbpUi::Ok')}
</Text>
</Pressable>
</View>
) : null}
</Pressable>
</Pressable>
</Modal>
</KeyboardAvoidingView>
);
}
export default CreateUpdateBookForm;
- The Android date picker is dismissed automatically once the user picks a date. On iOS we keep the date in
tempDateand apply it only when the user taps OK, so the spinner feels natural. AbpSelectis fed both for the Type dropdown (8 enum entries from the localization namespace) and for the Author dropdown (filled from theauthorsprop forwarded by the screen).
Updating a Book
There is no separate "edit" form. CreateUpdateBookScreen already accepts a bookId route param: when it is set, the screen calls BookAPI.get(bookId) and forwards the result as the book prop. The form picks the existing values up via enableReinitialize: true. The corresponding navigation call in BooksScreen passes the id when the user taps Edit in the action sheet:
navigation.navigate('CreateUpdateBook', { bookId: item.id });
When the form submits, the screen branches between update(payload, bookId) and create(payload) based on whether bookId is set.
Deleting a Book
The action-sheet handler in BooksScreen already implements deletion:
const confirmDelete = (item: BookListItem) => {
Alert.alert(t('AbpUi::AreYouSure'), t('BookStore::AreYouSureToDelete'), [
{ text: t('AbpUi::Cancel'), style: 'cancel' },
{
text: t('AbpUi::Ok'),
style: 'destructive',
onPress: async () => {
await remove(item.id);
setRefresh(prev => prev + 1);
},
},
]);
};
Incrementing refresh causes DataList to re-fetch from page zero, so the deleted row disappears as soon as the API call returns.
Authorization
We gate the UI in four places, all driven by useSelector(appConfigSelector)?.auth?.grantedPolicies:
- The Bottom Tab itself. In
BottomTabNavigator.tsxwe render theBookStoreTabonly whenBookStore.BooksorBookStore.Authorsis granted (already shown above). - The Books / Authors tab strip.
BookStoreScreen.tsxbuilds itstabsarray conditionally so a user with onlyBookStore.Booksdoesn't see an empty Authors tab. - The "+ New Book" button. Inside
BooksScreen.tsxthe FAB is rendered only whenBookStore.Books.Createis granted. - Edit / Delete entries in the action sheet. Same screen — each entry is added based on
BookStore.Books.EditandBookStore.Books.Deleterespectively.
The same four-layer pattern applies to the Authors tab, using BookStore.Authors.* keys.
Author Section
Author API Proxy
// ./src/api/AuthorAPI.ts
import api from './API';
export const getList = (params: { maxResultCount?: number; skipCount?: number; sorting?: string; filter?: string } = {}) =>
api.get('/api/app/author', { params }).then(({ data }) => data);
export const get = (id: string) =>
api.get(`/api/app/author/${id}`).then(({ data }) => data);
export const create = (input: any) =>
api.post('/api/app/author', input).then(({ data }) => data);
export const update = (input: any, id: string) =>
api.put(`/api/app/author/${id}`, input).then(({ data }) => data);
export const remove = (id: string) =>
api.delete(`/api/app/author/${id}`).then(({ data }) => data);
AuthorsScreen
The list mirrors BooksScreen — same DataList + action sheet + FAB pattern, with BookStore.Authors.* permissions and CreateUpdateAuthor as the navigation target.
// ./src/screens/BookStore/Authors/AuthorsScreen.tsx
// (Same shape as BooksScreen — replace the imports of BookAPI with AuthorAPI,
// swap BookStore.Books.* permissions with BookStore.Authors.*, and route
// navigation calls to 'CreateUpdateAuthor' with { authorId } instead of { bookId }.)
The full source ships with the sample app under the path above.
CreateUpdateAuthor
The screen pair is simpler than for books — there is no author lookup and no enum dropdown. Just a name field, a birth-date picker, and a multi-line short-bio field.
// ./src/screens/BookStore/Authors/CreateUpdateAuthor/CreateUpdateAuthorScreen.tsx
import { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { get, create, update } from '../../../../api/AuthorAPI';
import LoadingActions from '../../../../store/actions/LoadingActions';
import type { CreateUpdateAuthorScreenProps } from '../../../../navigators/types';
import CreateUpdateAuthorForm, { type AuthorFormValues } from './CreateUpdateAuthorForm';
function CreateUpdateAuthorScreen({ navigation, route }: CreateUpdateAuthorScreenProps) {
const { authorId } = route.params || {};
const dispatch = useDispatch();
const [author, setAuthor] = useState<any | null>(null);
useEffect(() => {
if (!authorId) return;
let cancelled = false;
(async () => {
dispatch(LoadingActions.start({ key: 'fetchAuthorDetail' }));
try {
const detail = await get(authorId);
if (!cancelled) setAuthor(detail);
} finally {
dispatch(LoadingActions.clear());
}
})();
return () => { cancelled = true; };
}, [authorId, dispatch]);
const submit = async (data: AuthorFormValues) => {
dispatch(LoadingActions.start({ key: 'save' }));
try {
const payload = {
name: data.name,
birthDate: data.birthDate ? new Date(data.birthDate).toISOString() : new Date().toISOString(),
shortBio: data.shortBio?.trim() ? data.shortBio : null,
};
if (authorId) await update(payload, authorId);
else await create(payload);
navigation.goBack();
} finally {
dispatch(LoadingActions.clear());
}
};
return <CreateUpdateAuthorForm submit={submit} author={author} />;
}
export default CreateUpdateAuthorScreen;
The form (CreateUpdateAuthorForm.tsx) is a stripped-down version of the book form — only the name, birth-date and short-bio fields, no AbpSelect. Refer to the sample source for the full file; it follows the exact same NativeWind layout used in CreateUpdateBookForm.tsx.
Adding the Author Relation to Books
This is the part that ties everything together: the book list shows the author name beside the book type, and the create/edit form lets the user choose the author from a dropdown filled by the getAuthorLookup endpoint.
Both pieces are already wired in the code we wrote earlier:
BooksScreen.tsxrenders${item.authorName} · ${t('BookStore::Enum:BookType:${item.type}')}for each row. The backend'sBookAppService.GetListAsyncjoins theAuthorscollection soauthorNameis part of every item.CreateUpdateBookScreen.tsxcallsgetAuthorLookupon mount and passes the result to the form as theauthorsprop. The form'sAuthorfield is anAbpSelectthat reads from that prop and writes the chosen id (and display name) back to Formik state.
If you want to verify the relation visually:
- Start the backend (
Acme.BookStore.HttpApi.Host). - Run the React Native app (
npm startinreact-native/). - Log in as
admin/1q2w3E*. The seeder created three sample authors and six sample books. - Open the Book Store tab. The book list shows entries like
The Hobbit · J.R.R. Tolkien · Fantastic. - Tap + New Book and confirm the Author dropdown lists the three seeded authors.
Where to go next
- The drawer-only template variant (
navigation_type = "drawer") follows the same flow — replace theBottomTabNavigatorstep with the equivalentDrawer.ScreeninDrawerNavigator.tsx. - Localization for the
BookStore::*namespace is inreact-native/src/locales/{en,tr}.jsonand the matching backend resource insrc/Acme.BookStore.Domain.Shared/Localization/BookStore/. Adding more languages is a matter of registering them inLocalizationService.tsand creating the corresponding JSON files. - The full sample is the source of truth: any time the snippets here look incomplete, open the same path inside the downloaded
bookstore-react-native-mongodbsolution.







