```json //[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](https://abp.io/pricing) to be able to create a mobile application. - This tutorial assumes you have completed the [Web Application Development tutorial](../../book-store/part-01.md) and built an ABP based application named `Acme.BookStore` with [React Native](../../../framework/ui/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.BookStore` application. It implements the CRUD operations for `Books` and `Authors`, 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`). The `connectToRedux` HOC, the `DrawerNavigator`, and the legacy `DataList`/`AbpSelect` components 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](../../../framework/ui/react-native/index.md) 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: - [Acme.BookStore](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-react-native-mongodb) > If you encounter the "filename too long" or "unzip" error on Windows, please see [this guide](../../../kb/windows-path-too-long-fix.md). The downloaded sample contains: - `src/` — ABP backend (`Acme.BookStore.*` projects). It already exposes `BookAppService` and `AuthorAppService` with 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 the `BookStore` feature 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 (returns `items` with `id`, `name`, `type`, `publishDate`, `price`, `authorName`) - `GET /api/app/book/{id}` — single book - `POST /api/app/book` — create - `PUT /api/app/book/{id}` — update - `DELETE /api/app/book/{id}` — delete - `GET /api/app/book/author-lookup` — `{ items: [{ id, name }] }` for the author dropdown - `GET /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.Delete` - `BookStore.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](../../book-store/part-01.md). 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`. ```ts // ./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](#author). - `api` is the shared `axios` instance (`./src/api/API.ts`) that injects the access token via the request interceptor in `./src/interceptors/APIInterceptor.ts`. - `getList` accepts a paging payload (`maxResultCount`, `skipCount`, `sorting`) so it can be plugged into the `DataList` component 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`. ```tsx // ./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 { 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({ fetchFn, render, trigger, pageSize = 20, }: DataListProps) { const { t } = useContext(LocalizationContext); const { accentColor } = useThemeColors(); const [items, setItems] = useState([]); 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 ( item?.id?.toString() ?? index.toString()} renderItem={render} contentContainerStyle={{ flexGrow: 1, paddingBottom: 96 }} refreshControl={} onEndReached={onEndReached} onEndReachedThreshold={0.4} ItemSeparatorComponent={() => ( )} ListEmptyComponent={ loading ? null : ( {t('AbpUi::NoData')} ) } ListFooterComponent={ loading && items.length > 0 ? ( ) : null } /> ); } export default DataList; ``` - `fetchFn` is any function that accepts `{ maxResultCount, skipCount }` and returns `{ items, totalCount }` — the shape of every ABP `ICrudAppService.GetListAsync` response. - `trigger` is 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`. ```tsx // ./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 ( {}} 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"> {title} String(item.id)} style={{ maxHeight: 360 }} ItemSeparatorComponent={() => ( )} renderItem={({ item }) => { const isSelected = String(item.id) === String(selectedItem ?? ''); return ( { setSelectedItem(item.id); hideModalFn(); }} className={`px-5 py-3.5 flex-row items-center justify-between ${ isSelected ? 'bg-secondary dark:bg-secondary-dark' : '' }`}> {item.displayName} {isSelected ? : null} ); }} /> ); } export default AbpSelect; ``` Now expose the two new components from the barrel file so screens can import them with a single statement: ```ts // ./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. ```ts // ./src/navigators/types.ts (additions) export type BookStoreStackParamList = { BookStore: undefined; CreateUpdateBook: { bookId?: string } | undefined; CreateUpdateAuthor: { authorId?: string } | undefined; }; export type BookStoreScreenProps = NativeStackScreenProps; export type CreateUpdateBookScreenProps = NativeStackScreenProps; export type CreateUpdateAuthorScreenProps = NativeStackScreenProps; ``` Also extend `BottomTabParamList`: ```ts export type BottomTabParamList = { HomeTab: undefined; BookStoreTab: undefined; SettingsTab: undefined; AccountTab: undefined; }; ``` Then create the stack navigator: ```tsx // ./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(); export default function BookStoreStackNavigator() { const { headerBg, headerText, accentColor } = useThemeColors(); const { t } = useContext(LocalizationContext); return ( ({ title: t(route.params?.bookId ? 'BookStore::Edit' : 'BookStore::NewBook'), headerStyle: { backgroundColor: headerBg }, headerTintColor: headerText, headerShadowVisible: false, headerRight: () => ( navigation.goBack()} hitSlop={8}> {t('AbpUi::Cancel')} ), })} /> ({ title: t(route.params?.authorId ? 'BookStore::Edit' : 'BookStore::NewAuthor'), headerStyle: { backgroundColor: headerBg }, headerTintColor: headerText, headerShadowVisible: false, headerRight: () => ( navigation.goBack()} hitSlop={8}> {t('AbpUi::Cancel')} ), })} /> ); } ``` 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: ```tsx // ./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 ( {showBookStore ? ( ( ), }} /> ) : null} ); } ``` > Earlier versions of the template used a `DrawerNavigator`. The 2026 template defaults to `bottom-tab` instead. If your project still uses the drawer (the optional `navigation_type = "drawer"` configuration), add the same conditional `Drawer.Screen` to `DrawerNavigator.tsx` instead. ![Book Store Tab](../../../images/book-store-menu-item.png) ## 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. ```tsx // ./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(() => { 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(tabs[0]?.key); useEffect(() => { if (!tabs.find(tab => tab.key === activeKey)) setActiveKey(tabs[0]?.key); }, [tabs, activeKey]); if (tabs.length === 0) { return ( {t('BookStore::NoAccess')} ); } return ( {tabs.map(tab => { const isActive = activeKey === tab.key; return ( setActiveKey(tab.key)} className={`flex-1 py-3 items-center border-b-2 ${ isActive ? 'border-accent dark:border-accent-dark' : 'border-transparent' }`}> {tab.label} ); })} {activeKey === 'books' ? : null} {activeKey === 'authors' ? : null} ); } export default BookStoreScreen; ``` The previous template used `react-native-paper`'s `BottomNavigation` for this. Building the tab strip with two `Pressable`s 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`. 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`. ```tsx // ./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 ( fetchFn={getList as any} trigger={refresh} render={({ item }) => ( (canEdit || canDelete) && openContextMenu(item)} className="px-4 py-3.5 active:bg-secondary dark:active:bg-secondary-dark"> {item.name} {item.authorName} · {t(`BookStore::Enum:BookType:${item.type}`)} {(canEdit || canDelete) ? ( ) : null} )} /> {canCreate ? ( 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"> {t('BookStore::NewBook')} ) : null} ); } export default BooksScreen; ``` - Reading `appConfigSelector` from the Redux store (no `connectToRedux` HOC) is the new pattern. The selector lives in `./src/store/selectors/AppSelectors.ts` and returns the `appConfig` object that the template populates after login (`AppActions.fetchAppConfigAsync`). - `useActionSheet` is 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. ![Book List Page](../../../images/book-list.png) ## Creating a New Book The book form needs `@react-native-community/datetimepicker` for the publish-date field. Install it: ```bash 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. ```tsx // ./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(null); const [authors, setAuthors] = useState([]); 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 ; } export default CreateUpdateBookScreen; ``` - `LoadingActions.start({ key })` and `LoadingActions.clear()` drive the global `` overlay rendered in `AppContent.tsx` via the Redux loading reducer. No extra wiring is needed in this screen. - `getAuthorLookup` lives in `BookAPI.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. ```tsx // ./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; 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(new Date()); const bookTypes = useMemo( () => 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({ 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) ? {form.errors[field] as string} : null; return ( {/* Name */} {renderError('name')} {/* Author dropdown — populated in the "Adding Author Relation to Book" section */} setAuthorModalVisible(true)}> } style={{ backgroundColor: primaryContainer }} /> {renderError('authorId')} {/* Type dropdown */} setTypeModalVisible(true)}> } style={{ backgroundColor: primaryContainer }} /> {renderError('type')} {/* Publish date picker */} { setTempDate(form.values.publishDate ?? new Date()); setDateModalVisible(true); }}> } style={{ backgroundColor: primaryContainer }} /> {renderError('publishDate')} {/* Price */} {renderError('price')} form.handleSubmit()} isSubmitDisabled={form.isSubmitting} /> {/* Type modal */} 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 */} 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 */} setDateModalVisible(false)}> setDateModalVisible(false)} className="flex-1 bg-black/50 items-center justify-center px-6"> {}} 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"> {t('BookStore::PublishDate')} { if (Platform.OS !== 'ios') { setDateModalVisible(false); if (event?.type !== 'dismissed' && selectedDate) { form.setFieldValue('publishDate', selectedDate, true); } } else if (selectedDate) { setTempDate(selectedDate); } }} maximumDate={new Date()} /> {Platform.OS === 'ios' ? ( setDateModalVisible(false)} className="px-4 py-2 rounded-md"> {t('AbpUi::Cancel')} { form.setFieldValue('publishDate', tempDate, true); setDateModalVisible(false); }} className="px-4 py-2 rounded-md bg-accent dark:bg-accent-dark"> {t('AbpUi::Ok')} ) : null} ); } export default CreateUpdateBookForm; ``` - The Android date picker is dismissed automatically once the user picks a date. On iOS we keep the date in `tempDate` and apply it only when the user taps **OK**, so the spinner feels natural. - `AbpSelect` is fed both for the **Type** dropdown (8 enum entries from the localization namespace) and for the **Author** dropdown (filled from the `authors` prop forwarded by the screen). ![Create New Book](../../../images/create-book.png) ## 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: ```ts 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. ![Update Book Page](../../../images/update-book.png) ## Deleting a Book The action-sheet handler in `BooksScreen` already implements deletion: ```ts 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. ![Delete Book Alert](../../../images/delete-book-alert.png) ## Authorization We gate the UI in four places, all driven by `useSelector(appConfigSelector)?.auth?.grantedPolicies`: 1. **The Bottom Tab itself.** In `BottomTabNavigator.tsx` we render the `BookStoreTab` only when `BookStore.Books` or `BookStore.Authors` is granted (already shown above). 2. **The Books / Authors tab strip.** `BookStoreScreen.tsx` builds its `tabs` array conditionally so a user with only `BookStore.Books` doesn't see an empty Authors tab. 3. **The "+ New Book" button.** Inside `BooksScreen.tsx` the FAB is rendered only when `BookStore.Books.Create` is granted. 4. **Edit / Delete entries in the action sheet.** Same screen — each entry is added based on `BookStore.Books.Edit` and `BookStore.Books.Delete` respectively. The same four-layer pattern applies to the Authors tab, using `BookStore.Authors.*` keys. ## Author Section ### Author API Proxy ```ts // ./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. ```tsx // ./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. ```tsx // ./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(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 ; } 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`. ![Author Create Page](../../../images/create-author.png) ## 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.tsx` renders `${item.authorName} · ${t('BookStore::Enum:BookType:${item.type}')}` for each row. The backend's `BookAppService.GetListAsync` joins the `Authors` collection so `authorName` is part of every item. - `CreateUpdateBookScreen.tsx` calls `getAuthorLookup` on mount and passes the result to the form as the `authors` prop. The form's `Author` field is an `AbpSelect` that reads from that prop and writes the chosen id (and display name) back to Formik state. If you want to verify the relation visually: 1. Start the backend (`Acme.BookStore.HttpApi.Host`). 2. Run the React Native app (`npm start` in `react-native/`). 3. Log in as `admin` / `1q2w3E*`. The seeder created three sample authors and six sample books. 4. Open the **Book Store** tab. The book list shows entries like `The Hobbit · J.R.R. Tolkien · Fantastic`. 5. Tap **+ New Book** and confirm the **Author** dropdown lists the three seeded authors. ![Book List with Author](../../../images/book-list-with-author.png) ![Authors in Book Form](../../../images/authors-in-book-form.png) ## Where to go next - The drawer-only template variant (`navigation_type = "drawer"`) follows the same flow — replace the `BottomTabNavigator` step with the equivalent `Drawer.Screen` in `DrawerNavigator.tsx`. - Localization for the `BookStore::*` namespace is in `react-native/src/locales/{en,tr}.json` and the matching backend resource in `src/Acme.BookStore.Domain.Shared/Localization/BookStore/`. Adding more languages is a matter of registering them in `LocalizationService.ts` and 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-mongodb` solution.