Open Source Web Application Framework for ASP.NET Core
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

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.BookStore with 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 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 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. 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.

  • 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.

// ./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;
  • 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.

// ./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 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

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 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

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 }) and LoadingActions.clear() drive the global <Loading /> 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.

// ./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 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

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.

Update Book Page

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.

Delete Book Alert

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

// ./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.

Author Create Page

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 Authors in Book Form

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.