
Building React Native Application
- 52 installs
- Updated August 4, 2026
- cedmandocdoc/awesome-skills
Guides building Expo/React Native apps in TypeScript with a fixed stack of NativeWind, React Navigation, TanStack Query, Zustand, and Axios.
About
Provides an opinionated React Native (Expo) architecture and library stack covering screens, navigation, forms, state, styling, bottom sheets, and theming. A developer uses it for any feature, screen, or UI work in an Expo app that should follow these conventions.
- Fixed stack: Expo, NativeWind, React Navigation, TanStack Query, Zustand, Axios
- Task-to-docs table mapping screens, forms, APIs, and theming to reference guides
Building React Native Application by the numbers
- 52 all-time installs (skills.sh)
- Ranked #592 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cedmandocdoc/awesome-skills --skill building-react-native-applicationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52 |
|---|---|
| Last updated | August 4, 2026 |
| Repository | cedmandocdoc/awesome-skills ↗ |
What it does
Guides building Expo/React Native apps in TypeScript with a fixed stack of NativeWind, React Navigation, TanStack Query, Zustand, and Axios.
Files
React Native
Opinionated ecosystem for building React Native apps with a consistent architecture, library stack, and UI system.
Tech stack
| Layer | Choice |
|---|---|
| Runtime | Expo |
| Language | TypeScript |
| Styling | NativeWind, Tailwind CSS, class-variance-authority |
| Routing | React Navigation |
| Server state | TanStack Query |
| Client global state | Zustand |
| HTTP | Axios |
| Presentational UI | React Native Reusables-style primitives in src/ui/ |
When to use
Follow this skill for every task that touches this project's React Native (Expo) app: features, screens, navigation, APIs, state, styling, forms, async and list UI, bottom sheets, config, theming, layout, and tooling choices for the app.
Match the work to every Task type that applies — many tasks span multiple rows (e.g. new screen + form). Open every link in the Docs column from each matching row before coding.
| Task type | Docs |
|---|---|
| New screen / feature | managing-project-structure, creating-feature, creating-route-component, creating-screen-component, creating-component, React Navigation — Hello (static) |
UI primitive (src/ui/) | creating-component, creating-ui-component, managing-wrapper-components, add-registry-component.cjs |
| Feature component | creating-component, creating-feature-component, managing-wrapper-components |
| Bottom sheet | creating-component, creating-bottom-sheet-component, managing-wrapper-components |
| Async / list UI | creating-component, creating-async-component, creating-api, managing-state |
| Form (single step) | creating-form-component, managing-form-error, managing-state, TanStack Form — Basic concepts |
| Multi-step form | managing-stepper-hook, managing-stepper-form, creating-form-component, managing-form-error, TanStack Form — Basic concepts, TanStack Form — Form composition |
| API + data hooks | creating-api, setting-up-axios, managing-api-error, managing-state |
| Refactor / move component | creating-component |
| Styling / theme | styling, styling-preference, setting-up-theming, setting-up-tailwind-theme, setting-up-navigation-theme, overriding-classname, NativeWind — Installation |
| Navigation components & backgrounds | creating-navigation-component, setting-up-navigation-theme, reusing-navigation-background, managing-screen-background, React Navigation — Hello (static), Native stack, Bottom tabs, Drawer |
| Keyboard | Keyboard controller — Components |
| Fonts | Expo — Fonts, setting-up-theming, setting-up-tailwind-theme |
| Splash screen | Expo — SplashScreen |
| Project bootstrap | managing-project-structure, managing-environment, linting, setting-up-registry-components, NativeWind — Installation |
| Routing only | creating-route-component, creating-navigation-component, React Navigation — Hello (static), Native stack, Bottom tabs, Drawer |
Creating API
Overview
Use this guide to keep API code small, typed, and independent from React. Put HTTP clients and request functions in src/api/, then call them from feature hooks.
The transport is not fixed: use Axios, fetch, Supabase, or another client. This doc defines layer boundaries and a default structure; client-specific setup lives in client.ts and optional companion references (for example setting-up-axios.md).
Prerequisites
- managing-api-error.md
Guidelines
Structure
The layout below is the default starting point, not a closed set. Add files or folders when multiple pieces share the same role (for example interceptors/ for auth refresh logic, or schemas/ for request validation).
- Use hyphen-case backend folders under
src/api/. - Keep one backend per folder.
- Group request functions by domain.
src/libs/
└── ApiError.ts
src/api/<backend-name>/
├── client.ts # create and export the configured HTTP client
├── env.ts # parsed env vars for this backend
├── utils.ts # shared helpers when small (< ~200 lines total)
├── utils/ # one file per helper when utils grow (e.g. toApiError.ts)
├── models/
│ └── Workshop.ts
└── modules/
└── workshops.ts| File / folder | Role |
|---|---|
env.ts | Parse and export environment values for this backend (see managing-environment.md). |
client.ts | Create and export one shared client instance for the backend; read options from env.ts. |
utils.ts / utils/ | Shared helpers such as toApiError and FALLBACK_MESSAGE. |
models/ | Request/response types and domain error enums. |
modules/ | Typed functions per domain; import and use the shared client from client.ts. |
Layout rules
- Start shared helpers in
utils.ts(error mappers, response parsers). Split intoutils/<helperName>.tswhen the file exceeds ~200 lines or helpers are easier to find by name — same rule as creating-feature.md. - Start shared types in
models/<Domain>.ts; add amodels/subfolder or split files when types grow. - Add role-based folders (
interceptors/,schemas/,mappers/, etc.) when grouping improves clarity.
Client rules
- Do not import React, features, or stores inside
src/api/. - Configure the HTTP client in
client.ts; export one sharedclientinstance per backend (see managing-environment.md). - Module functions import
clientfromclient.ts—do not accept the client as a parameter. - Use explicit return types on exported functions.
Error handling
- Import `ApiError` from
@/libs/ApiError; map failures to it insrc/api/(see managing-api-error.md). - Map transport-specific errors in
utils.ts,utils/, or modulecatchblocks—not in feature hooks or components. - Do not invent user-facing copy in feature hooks or components.
Examples
Example: Axios module function
When using Axios, see setting-up-axios.md for createClient and responseData.
import type { Workshop } from "../models/Workshop";
import { client, responseData } from "../client";
import { toApiError } from "../utils";
export async function getWorkshops(): Promise<Workshop[]> {
try {
return await responseData(client.get<Workshop[]>("/workshops"));
} catch (err) {
throw toApiError(err);
}
}Example: Supabase module function
When using Supabase, configure and export the shared client in client.ts; module functions import it the same way.
import type { Workshop } from "../models/Workshop";
import { client } from "../client";
import { toApiError } from "../utils";
export async function getWorkshops(): Promise<Workshop[]> {
try {
const { data, error } = await client.from("workshops").select("*");
if (error) throw error;
return data ?? [];
} catch (err) {
throw toApiError(err);
}
}Use API code from a feature hook
Feature hooks call module functions directly—the shared client stays inside src/api/.
import { useQuery } from "@tanstack/react-query";
import { getWorkshops } from "@/api/app-api/modules/workshops";
export function useWorkshops() {
return useQuery({
queryKey: ["app-api", "workshops", "list"],
queryFn: getWorkshops,
});
}Keep components unaware of the HTTP client
- Let components use feature hooks.
- Do not call the transport client directly from presentational components.
Creating Async Component
Overview
Create and use server-backed UI wrappers — AsyncView, AsyncScrollView, and AsyncFlatList — that share the same state machine and differ only in scroll refresh and list pagination. Keep all async wrappers under `src/ui/Async/` and export them from `src/ui/Async/index.tsx` so screens import `@/ui/Async`. Feature screens compose them around TanStack Query results.
Start from creating-component.md. For fetching and hooks, see managing-state.md. For error copy, see managing-api-error.md.
Prerequisites
- creating-component.md
- managing-state.md
- managing-api-error.md
Naming
- Wrapper exports:
AsyncView,AsyncScrollView,AsyncFlatList. - Internal helpers alongside wrappers:
ErrorMessage(not exported from the public barrel unless needed). - Feature screens compose wrappers — do not fork the state machine per screen.
Guidelines
Folder placement
- Keep AsyncView, AsyncScrollView, AsyncFlatList, and shared helpers (for example ErrorMessage) under `src/ui/Async/`.
- Export the public API from `src/ui/Async/index.tsx` so features import `@/ui/Async` — not individual files under the folder.
- Put one wrapper per file when it grows beyond a few lines. Keep small shared pieces such as `ErrorMessage.tsx` alongside the wrappers.
Expected layout:
src/ui/Async/
ErrorMessage.tsx — shared error copy helper (internal to Async)
AsyncView.tsx
AsyncScrollView.tsx
AsyncFlatList.tsx
index.tsx — re-exports AsyncView, AsyncScrollView, AsyncFlatList| Area | Typical location |
|---|---|
| Barrel exports | src/ui/Async/index.tsx |
| Non-scroll wrapper | src/ui/Async/AsyncView.tsx |
| Scroll + refresh | src/ui/Async/AsyncScrollView.tsx |
| Virtualized list + pagination | src/ui/Async/AsyncFlatList.tsx |
| Error copy helper | src/ui/Async/ErrorMessage.tsx |
UI states
| State | Meaning | Presentation |
|---|---|---|
| Loading | First request in flight; no cached data | Full-area loader (or custom loader); do not render main content |
| Error | First request failed | Full-area message + Try again → reload |
| Data | Successful load (may be stale during refetch) | Render children or list items |
| Reloading | Pull-to-refresh in flight | RefreshControl; keep existing data |
| Loading more | Next page fetching (AsyncFlatList only) | Small footer spinner; keep existing items |
Treat `isReloading` and `isLoadingMore` as overlays on data, not full-screen replacements.
Shared behavior (all three)
1. Initial loading — While isLoading is true, show only the loader. Do not mount meaningful content for the main query. 2. Initial error — Show error UI with Try again wired to reload / refetch. 3. Data — Render children or list content when data is available. 4. Inherited props — Forward remaining props to View, ScrollView, or FlatList.
Suggested props
| Prop | Role |
|---|---|
isLoading | True during initial load (no data yet). |
error | Truthy when initial load failed; pass query.error (ApiError — use error.message). |
reload | Retry after error; RefreshControl onRefresh on scroll/list variants. |
isReloading | Pull-to-refresh in flight (AsyncScrollView / AsyncFlatList). |
loader | Optional node for initial loading only. |
isLoadingMore | (AsyncFlatList only.) Next page in flight. |
loadMore | (AsyncFlatList only.) Called from onEndReached; guard in the hook (hasNextPage, in-flight flags). |
TanStack Query mapping
| Prop | Source |
|---|---|
isLoading | query.isLoading |
isReloading | query.isRefetching |
isLoadingMore | query.isFetchingNextPage (infinite query only) |
Error display
Read user-facing copy from error.message. See managing-api-error.md. If refresh fails, do not replace the data UI with the full error state.
When to use which wrapper
| Wrapper | Use when |
|---|---|
| AsyncView | Non-scroll content (forms, dashboards). No pull-to-refresh; recovery via Try again only. |
| AsyncScrollView | Scrollable content with pull-to-refresh (RefreshControl + isReloading / reload). |
| AsyncFlatList | Long lists with virtualization; optional infinite scroll via loadMore + isLoadingMore footer. |
Examples
ErrorMessage
src/ui/Async/ErrorMessage.tsx — internal helper used by all three wrappers.
import { Text } from "react-native";
import { FALLBACK_MESSAGE } from "@/api/app-api/utils";
import { ApiError } from "@/libs/ApiError";
export function ErrorMessage({ error }: { error: unknown }) {
const message =
error instanceof ApiError ? error.message : FALLBACK_MESSAGE;
return <Text className="text-center text-destructive">{message}</Text>;
}AsyncView
Use when: one-off or non-scroll content where pull-to-refresh is not desired. No isReloading UI from a user gesture; only initial error + Try again.
src/ui/Async/AsyncView.tsx
import type { ComponentProps, ReactNode } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { ErrorMessage } from "./ErrorMessage";
interface AsyncViewProps extends ComponentProps<typeof View> {
isLoading: boolean;
error: unknown;
reload: () => void;
loader?: ReactNode;
children: ReactNode;
}
export function AsyncView({
isLoading,
error,
reload,
loader,
children,
...viewProps
}: AsyncViewProps) {
if (isLoading) {
return (
<View className="flex-1 items-center justify-center" {...viewProps}>
{loader ?? <ActivityIndicator />}
</View>
);
}
if (error) {
return (
<View
className="flex-1 items-center justify-center gap-4 p-4"
{...viewProps}
>
<ErrorMessage error={error} />
<Pressable onPress={reload}>
<Text className="font-semibold text-primary">Try again</Text>
</Pressable>
</View>
);
}
return <View {...viewProps}>{children}</View>;
}AsyncScrollView
Use when: scrollable content should support pull-to-refresh. If refresh fails, keep showing the last successful children.
src/ui/Async/AsyncScrollView.tsx
import type { ComponentProps, ReactNode } from "react";
import {
ActivityIndicator,
Pressable,
RefreshControl,
ScrollView,
Text,
View,
} from "react-native";
import { ErrorMessage } from "./ErrorMessage";
interface AsyncScrollViewProps extends ComponentProps<typeof ScrollView> {
isLoading: boolean;
isReloading: boolean;
reload: () => void;
error: unknown;
loader?: ReactNode;
children: ReactNode;
}
export function AsyncScrollView({
isLoading,
isReloading,
reload,
error,
loader,
children,
refreshControl,
...scrollProps
}: AsyncScrollViewProps) {
if (isLoading) {
return (
<View className="flex-1 items-center justify-center">
{loader ?? <ActivityIndicator />}
</View>
);
}
if (error) {
return (
<View className="flex-1 items-center justify-center gap-4 p-4">
<ErrorMessage error={error} />
<Pressable onPress={reload}>
<Text className="font-semibold text-primary">Try again</Text>
</Pressable>
</View>
);
}
return (
<ScrollView
{...scrollProps}
refreshControl={
refreshControl ?? (
<RefreshControl refreshing={isReloading} onRefresh={reload} />
)
}
>
{children}
</ScrollView>
);
}AsyncFlatList
Use when: long lists, virtualization, and optional infinite scroll. Caller onEndReached runs before loadMore. Merge a custom ListFooterComponent with the isLoadingMore footer when both are needed.
src/ui/Async/AsyncFlatList.tsx
import type { ComponentProps, ReactElement, ReactNode } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
Text,
View,
} from "react-native";
import { ErrorMessage } from "./ErrorMessage";
interface AsyncFlatListProps<T>
extends Omit<
ComponentProps<typeof FlatList<T>>,
"ListFooterComponent" | "onEndReached" | "onEndReachedThreshold"
> {
isLoading: boolean;
isReloading: boolean;
isLoadingMore: boolean;
loadMore: () => void;
reload: () => void;
error: unknown;
loader?: ReactNode;
onEndReached?: ComponentProps<typeof FlatList<T>>["onEndReached"];
onEndReachedThreshold?: number;
ListFooterComponent?:
| ComponentProps<typeof FlatList<T>>["ListFooterComponent"]
| ReactElement
| null;
}
export function AsyncFlatList<T>({
isLoading,
isReloading,
isLoadingMore,
loadMore,
reload,
error,
loader,
ListFooterComponent,
refreshControl,
onEndReached,
onEndReachedThreshold = 0.2,
...flatListProps
}: AsyncFlatListProps<T>) {
if (isLoading) {
return (
<View className="flex-1 items-center justify-center">
{loader ?? <ActivityIndicator />}
</View>
);
}
if (error) {
return (
<View className="flex-1 items-center justify-center gap-4 p-4">
<ErrorMessage error={error} />
<Pressable onPress={reload}>
<Text className="font-semibold text-primary">Try again</Text>
</Pressable>
</View>
);
}
const footer = isLoadingMore ? (
<View className="items-center py-2">
<ActivityIndicator size="small" />
</View>
) : (
ListFooterComponent
);
return (
<FlatList
{...flatListProps}
onEndReachedThreshold={onEndReachedThreshold}
onEndReached={(info) => {
onEndReached?.(info);
loadMore();
}}
refreshControl={
refreshControl ?? (
<RefreshControl refreshing={isReloading} onRefresh={reload} />
)
}
ListFooterComponent={footer}
/>
);
}Barrel export
src/ui/Async/index.tsx
export { AsyncView } from "./AsyncView";
export { AsyncScrollView } from "./AsyncScrollView";
export { AsyncFlatList } from "./AsyncFlatList";Screen with AsyncView
import { AsyncView } from "@/ui/Async";
const workshops = useWorkshops();
return (
<AsyncView
isLoading={workshops.isLoading}
error={workshops.isError ? workshops.error : undefined}
reload={() => void workshops.refetch()}
>
<WorkshopList data={workshops.data} />
</AsyncView>
);Screen with AsyncFlatList
import { AsyncFlatList } from "@/ui/Async";
<AsyncFlatList
isLoading={query.isLoading}
isReloading={query.isRefetching}
isLoadingMore={query.isFetchingNextPage}
loadMore={() => void query.fetchNextPage()}
reload={() => void query.refetch()}
error={query.isError ? query.error : undefined}
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>Creating Bottom Sheet Component
Overview
Create bottom sheet UI using shared wrappers under src/ui/BottomSheet. Do not use raw @gorhom/bottom-sheet unless the wrapper does not expose the needed behavior. Before implementing, decide content shape (static, scrollable, or list-driven), whether the shared wrapper is enough, and any dismissal or gesture constraints.
Start from creating-component.md.
Prerequisites
- creating-component.md — placement and shared rules
- creating-ui-component.md — composition root under
src/ui/BottomSheet/ @gorhom/bottom-sheetinstalled in the app- App root wrapped once with `GestureHandlerRootView` and `BottomSheetModalProvider`
Naming
- Wrapper exports: `BottomSheetView`, `BottomSheetFlatList` from
@/ui/BottomSheet. - Internal shell: `BottomSheetModal` — not exported from the public barrel.
- Feature sheet components:
<Feature><Purpose>Sheet(for exampleJobFiltersSheet,OptionPickerSheet).
Guidelines
Default path
- Use `BottomSheetView` for static or scrollable feature sheets; set `scrollable: true` when the body can overflow.
- Use `BottomSheetFlatList` for flat-list driven sheets (for example option pickers).
- Import from `@/ui/BottomSheet`, not `@gorhom/bottom-sheet`, unless there is a documented bypass.
- Keep open state in local component state or the feature store; pass `open` and `onDismiss` into the wrapper.
App setup
Wrap the app tree once. Do not duplicate providers in features.
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { GestureHandlerRootView } from "react-native-gesture-handler";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</GestureHandlerRootView>
);
}Choose the primitive
| Content | Wrapper |
|---|---|
| Static UI, short form, grouped actions, filters | `BottomSheetView` |
| Scrollable body that can overflow | `BottomSheetView` with `scrollable: true` |
| Flat list (option pickers, long selectable lists) | `BottomSheetFlatList` |
Control pattern
- Drive visibility with a controlled boolean (
isOpenor store state). - Pass `open={isOpen}` and `onDismiss={() => setIsOpen(false)}`.
- Call `present()` when `open` becomes true; do not call `dismiss()` when `open` becomes false — gorhom closes via gesture or backdrop and fires `onDismiss`.
- Prefer `open` / `onDismiss` over imperative `present()` / `dismiss()` in feature code.
- Pass any other gorhom modal prop (for example `snapPoints`, `enablePanDownToClose`, `index`) through the wrapper unchanged.
Sheet header
When a sheet needs a title, close control, or other header UI:
| Wrapper | Header guidance |
|---|---|
| `BottomSheetView` | Default preference: render header UI inside `children`. `header` remains available through modal prop forwarding when the use case calls for it — for example a fixed toolbar while the body scrolls. |
| `BottomSheetFlatList` | Use `header` or `ListHeaderComponent`. List sheets have no other natural place for header UI. Prefer wrapper `header` (fixed above rows, forwarded to `BottomSheetModal`) when the title or toolbar should not scroll with the list; use gorhom `ListHeaderComponent` when the header should scroll away with rows. |
For `BottomSheetFlatList`, one of `header` or `ListHeaderComponent` is required whenever the sheet shows header UI.
Prop forwarding
- Derive wrapper prop types from gorhom with `Omit` — do not use `Pick` to enumerate gorhom props.
- `BottomSheetModal` owns only:
- controlled `open`
- default `backdropComponent` (via optional `backdrop` prop)
- default `backgroundStyle`
- optional wrapper-only `header` (forwarded to `BottomSheetModal`; especially useful for list sheets)
- All remaining gorhom `BottomSheetModal` props pass through unchanged.
- `BottomSheetView` and `BottomSheetFlatList` inherit the full modal prop surface and forward it to `BottomSheetModal`.
- At runtime, cast the combined props object when spreading to `BottomSheetModal`, `BottomSheetView`, `BottomSheetScrollView`, or `BottomSheetFlatList`. Gorhom and React Native ignore keys that are not part of that component's API.
Wrapper-owned keys to `Omit` from gorhom `BottomSheetModal`:
| Key | Reason |
|---|---|
| `ref` | Internal ref drives `present()` |
| `children` | View / FlatList wrappers supply body content |
| `backdropComponent` | Shell renders default backdrop; overridable via `backdrop` |
| `backgroundStyle` | Shell applies theme default; caller can override |
Optional `backdrop` type — gorhom backdrop props minus values the shell injects:
type BottomSheetBackdropConfig = Omit<
BottomSheetBackdropProps,
"animatedIndex" | "animatedPosition"
>;Wrapper vs gorhom
Use gorhom primitives directly only when the feature needs behavior the shared wrapper cannot express — for example mounted `BottomSheet`, modal hooks, or a fully custom modal tree. When bypassing the wrapper, keep visuals and dismissal aligned with the rest of the app.
Folder layout
Keep wrappers under `src/ui/BottomSheet/`. Export only `BottomSheetView` and `BottomSheetFlatList` from the barrel.
src/ui/BottomSheet/
BottomSheetModal.tsx — internal gorhom modal shell
BottomSheetView.tsx — static + scrollable bodies
BottomSheetFlatList.tsx — list body + optional header
index.ts — public exports| Area | Location |
|---|---|
| Public barrel | src/ui/BottomSheet/index.ts |
| Modal shell (internal) | src/ui/BottomSheet/BottomSheetModal.tsx |
| View wrapper | src/ui/BottomSheet/BottomSheetView.tsx |
| Flat list wrapper | src/ui/BottomSheet/BottomSheetFlatList.tsx |
Gorhom documentation
Use when the shared wrapper is insufficient.
| Topic | Doc |
|---|---|
| Modal | Usage, Props, Methods, Hooks |
| Bottom sheet | Usage, Props, Methods, Hooks |
| Components | BottomSheetView, BottomSheetScrollView, BottomSheetFlatList, BottomSheetBackdrop, BottomSheetFooter |
Examples
BottomSheetModal (internal shell)
Owns: controlled `open`, default backdrop, default background. Everything else is gorhom defaults plus caller props.
src/ui/BottomSheet/BottomSheetModal.tsx
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal as GorhomBottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import {
useColorScheme,
type ComponentProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { THEME } from "@/theme";
export type BottomSheetBackdropConfig = Omit<
BottomSheetBackdropProps,
"animatedIndex" | "animatedPosition"
>;
export type BottomSheetModalProps = Omit<
ComponentProps<typeof GorhomBottomSheetModal>,
"ref" | "children" | "backdropComponent" | "backgroundStyle"
> & {
open: boolean;
header?: ReactNode;
backdrop?: BottomSheetBackdropConfig;
backgroundStyle?: StyleProp<ViewStyle>;
children: ReactNode;
};
export function BottomSheetModal({
open,
onDismiss,
header,
backdrop,
backgroundStyle,
children,
...gorhomProps
}: BottomSheetModalProps) {
const colorScheme = useColorScheme();
const palette = THEME[colorScheme === "dark" ? "dark" : "light"];
const sheetRef = useRef<GorhomBottomSheetModal>(null);
useEffect(() => {
if (open) {
sheetRef.current?.present();
}
}, [open]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...props} {...backdrop} />
),
[backdrop],
);
return (
<GorhomBottomSheetModal
ref={sheetRef}
onDismiss={onDismiss}
backdropComponent={renderBackdrop}
backgroundStyle={backgroundStyle ?? { backgroundColor: palette.wash }}
{...gorhomProps}
>
{header}
{children}
</GorhomBottomSheetModal>
);
}BottomSheetView
Use when: static or scrollable non-list content. By default, put header UI in `children`; pass `header` when a fixed modal-level header fits the layout better.
src/ui/BottomSheet/BottomSheetView.tsx
import {
BottomSheetScrollView,
BottomSheetView as GorhomBottomSheetView,
} from "@gorhom/bottom-sheet";
import type { ComponentProps, ReactNode } from "react";
import {
BottomSheetModal,
type BottomSheetModalProps,
} from "./BottomSheetModal";
type BottomSheetViewBodyProps =
| ({ scrollable?: false } & Omit<
ComponentProps<typeof GorhomBottomSheetView>,
"children"
>)
| ({ scrollable: true } & Omit<
ComponentProps<typeof BottomSheetScrollView>,
"children"
>);
export type BottomSheetViewProps = Omit<BottomSheetModalProps, "children"> &
BottomSheetViewBodyProps & {
children: ReactNode;
};
export function BottomSheetView(props: BottomSheetViewProps) {
const { scrollable, children } = props;
const modalProps = props as Omit<BottomSheetModalProps, "children">;
if (scrollable) {
return (
<BottomSheetModal {...modalProps}>
<BottomSheetScrollView
{...(props as ComponentProps<typeof BottomSheetScrollView>)}
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
return (
<BottomSheetModal {...modalProps}>
<GorhomBottomSheetView
{...(props as ComponentProps<typeof GorhomBottomSheetView>)}
>
{children}
</GorhomBottomSheetView>
</BottomSheetModal>
);
}BottomSheetFlatList
Use when: virtualized list content. When the sheet needs header UI, use wrapper `header` or gorhom `ListHeaderComponent` — prefer `header` for a fixed title or toolbar.
src/ui/BottomSheet/BottomSheetFlatList.tsx
import { BottomSheetFlatList as GorhomBottomSheetFlatList } from "@gorhom/bottom-sheet";
import type { ComponentProps } from "react";
import {
BottomSheetModal,
type BottomSheetModalProps,
} from "./BottomSheetModal";
export type BottomSheetFlatListProps<T> = Omit<
BottomSheetModalProps,
"children"
> &
Omit<ComponentProps<typeof GorhomBottomSheetFlatList<T>>, "children">;
export function BottomSheetFlatList<T>(props: BottomSheetFlatListProps<T>) {
const modalProps = props as Omit<BottomSheetModalProps, "children">;
const flatListProps = props as ComponentProps<
typeof GorhomBottomSheetFlatList<T>
>;
return (
<BottomSheetModal {...modalProps}>
<GorhomBottomSheetFlatList {...flatListProps} />
</BottomSheetModal>
);
}Modal and flat-list prop names do not overlap in gorhom; the casts document which API each spread targets.
Public barrel
src/ui/BottomSheet/index.ts
export {
BottomSheetFlatList,
type BottomSheetFlatListProps,
} from "./BottomSheetFlatList";
export { BottomSheetView, type BottomSheetViewProps } from "./BottomSheetView";Feature filter sheet with BottomSheetView
Default pattern: header markup inside `children`.
import { Pressable, Text, View } from "react-native";
import { BottomSheetView } from "@/ui/BottomSheet";
export function JobFiltersSheet() {
const isOpen = useJobStore((s) => s.isFilterOpen);
const setFilterOpen = useJobStore((s) => s.setFilterOpen);
return (
<BottomSheetView
open={isOpen}
onDismiss={() => setFilterOpen(false)}
scrollable
>
<View className="flex-row items-center justify-between pb-4">
<Text className="text-lg font-semibold">Filters</Text>
<Pressable onPress={() => setFilterOpen(false)}>
<Text className="text-primary">Close</Text>
</Pressable>
</View>
{/* filter controls */}
</BottomSheetView>
);
}Option picker with BottomSheetFlatList
List sheets need `header` or `ListHeaderComponent` for title UI. Below, `header` keeps the title fixed while rows scroll.
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import { BottomSheetFlatList } from "@/ui/BottomSheet";
export function OptionPickerSheet() {
const [isOpen, setIsOpen] = useState(false);
return (
<BottomSheetFlatList
open={isOpen}
onDismiss={() => setIsOpen(false)}
header={
<View className="flex-row items-center justify-between px-4 pb-4">
<Text className="text-lg font-semibold">Choose option</Text>
<Pressable onPress={() => setIsOpen(false)}>
<Text className="text-primary">Close</Text>
</Pressable>
</View>
}
data={options}
renderItem={renderOption}
keyExtractor={(item) => item.id}
/>
);
}Backdrop overrides
Pass gorhom backdrop props through `backdrop` without flattening them on the wrapper.
<BottomSheetFlatList
open={isOpen}
onDismiss={() => setIsOpen(false)}
backdrop={{ appearsOnIndex: 0, disappearsOnIndex: -1, opacity: 0.6 }}
data={options}
renderItem={renderOption}
keyExtractor={(item) => item.id}
/>Creating Component
Overview
Start here for any component work. This guide routes you to the right deep-dive doc. Read the decision tree first, then open only the linked creation guide for your case.
Decision tree
| You are building… | Go to |
|---|---|
Shared UI primitive (Button, Input, Dialog…) | creating-ui-component.md |
Domain UI block (CartItemRow, CheckoutSummary…) | creating-feature-component.md |
| Route-facing Screen or Layout for a feature | creating-screen-component.md → register in creating-route-component.md |
Navigation component (AppHeader, tab icon renderer…) | creating-navigation-component.md → wire in creating-route-component.md |
Route layer file under src/routes/ | creating-route-component.md |
Pre-bound form field / form shell (*Field, FieldShell) | creating-form-component.md |
| Server-backed loading / error / list UI | creating-async-component.md |
| Bottom sheet UI | creating-bottom-sheet-component.md |
Already built but wrong layer? Re-run the tree in Recategorizing.
Placement
| Kind | Location |
|---|---|
| Screen component | src/features/<feature-name>/*Screen.tsx |
| Feature components | src/features/<feature-name>/components/ |
| Navigation components | src/features/navigation/components/ |
| Navigation hooks | src/features/navigation/hooks/ |
| Route layer | src/routes/ |
| Shared UI primitives | src/ui/ (flat unless a subsystem owns a folder) |
| Composition roots | src/ui/Form/, src/ui/Async/, src/ui/BottomSheet/ when multiple related files belong together |
| Tokens / theme | global.css, src/theme.css, src/theme.ts |
| Tailwind / NativeWind config | tailwind.config.js |
- Put product rules and domain behavior in
src/features/<feature-name>/— queries, stores, and domain logic inhooks/per managing-state.md. - Put reusable navigation components in
src/features/navigation/. - Put reusable, presentation-only UI primitives in
src/ui/. - Put route registration and wiring in
src/routes/. - Import primitives with
@/ui/<file>; use relative imports insidesrc/ui/.
Shared rules
- Use functional components and named exports.
- Prefer
interfacefor props. - Export one component per file; name the file after the export (
ProfileCard.tsx→ProfileCard). - Keep every component at 200 lines or fewer; split into smaller parts before implementing.
- Prefer compound parts (
Button,ButtonText,ButtonIcon) overtypeof childrenswitches. - UI primitives (
src/ui/) are presentation-only — no business logic, data fetching, mutations, or navigation decisions.
Naming (baseline)
- PascalCase exports; singular nouns (
UserCard, notUsersCard). - Match file name to export name.
- `src/ui/` — generic names:
Button,TextInput,Modal. - *`src/features//components/
** — feature-prefixed when domain-specific:CheckoutButton,CartItemRow`. - *`src/features/<feature>/Screen.tsx
** — route-facing screens:WorkshopListScreen,SettingsScreen`. - Use
<Feature><Entity><Type>for feature components (AuthLoginForm,OrderSummaryCard). - Use props or CVA variants for state — not
PrimaryButtonorDisabledInput. - Related parts share a prefix:
CartItem,CartItemImage,CartItemPrice.
Each creation guide adds type-specific naming rules.
Recategorizing an existing component
When reuse grows, re-run the decision tree:
- Presentation-only and cross-feature → move to
src/ui/per creating-ui-component.md. - Business logic, data access, or stores in `src/ui/` → extract logic to feature
hooks/per managing-state.md; leave a presentation-only primitive. - Navigation components reused across screens → move to
src/features/navigation/per creating-navigation-component.md. - Domain behavior used across screens → extract to a new feature module per creating-feature.md.
- Still tied to one screen flow → keep in the current feature.
Update folder placement and the feature barrel export contract when moving code.
Related
- managing-wrapper-components.md — flatten
Viewtrees and mergeclassName - managing-state.md — queries, stores, and where feature logic lives
- creating-feature.md — feature module structure, folder layout, and barrels
- creating-route-component.md — route layer wiring
- setting-up-registry-components.md — one-time registry shell (Lucide,
PortalHost,inlineRem)
Creating Feature Component
Overview
Create domain UI components in src/features/<feature-name>/components/. These compose @/ui/* primitives and may contain feature-specific logic, hooks, and event handlers.
Start from creating-component.md. For module barrels and exports, see creating-feature.md.
Prerequisites
- creating-component.md — placement and shared rules
- creating-ui-component.md — when you need a missing shared primitive first
Naming
- Use `<Feature><Entity><Type>` when the component carries domain meaning:
AuthLoginForm,CartItemRow,OrderSummaryCard. - Prefix with the feature when the name only makes sense in that product area:
CheckoutButton,SearchInput. - Group related parts with a shared prefix:
CartItem,CartItemImage,CartItemPrice,CartItemQuantity. - Suffixes:
Card,Item/Row,Form,Modal/Dialog— pick one list pattern and stay consistent.
Guidelines
What belongs here
- UI blocks tied to product rules or feature state.
- Event handlers and side effects specific to the feature.
- Composition of
@/ui/*primitives and other feature components. - Derived display logic that callers should not duplicate.
What does not belong here
- Reusable presentation-only primitives → creating-ui-component.md.
- Route registration →
src/routes/per creating-route-component.md. - HTTP clients and request functions →
src/api/per creating-api.md.
Composition
- Prefer smaller feature components over one large file.
- Feature components may import sibling feature components in the same module.
- Import shared primitives from
@/ui/<file>— run the UI registry path first when a primitive is missing.
Extraction heuristic
When building a screen, split recurring rendering blocks into named feature components if they clarify the tree and stay under 200 lines. If a block is presentation-only and reused across features, promote it to src/ui/ instead.
Examples
Feature component composing UI primitives
import { Button } from "@/ui/Button";
import { useWorkshopStore } from "../hooks/useWorkshopStore";
export function WorkshopEnrollCta({ workshopId }: { workshopId: string }) {
const enroll = useWorkshopStore((s) => s.enroll);
return (
<Button onPress={() => enroll(workshopId)}>
Enroll now
</Button>
);
}Grouped sub-parts
src/features/cart/components/
CartItem.tsx
CartItemImage.tsx
CartItemPrice.tsx
CartItemQuantity.tsxRelated
- creating-feature.md — feature module structure and barrels
- managing-state.md — Query, Zustand, and local state in features
Creating Feature
Overview
Use this guide to write feature modules in src/features/<feature-name>/.
A feature module packages domain logic with the feature UI, and exposes a small export surface for routes and other features — commonly a screen, plus hooks, types, helpers, and components as needed. This keeps reusable primitives in src/ui/, navigation components in src/features/navigation/, and data fetching/API code in src/api/ (usually via feature hooks).
For components inside a feature folder, see creating-feature-component.md. For route-facing screens, see creating-screen-component.md. For navigation components, see creating-navigation-component.md.
Feature folder layout
The layout below is the default starting point, not a closed set. Add folders when multiple files share the same role (for example schemas/ for Zod form schemas).
src/features/<feature-name>/
├── <Feature>Screen.tsx # route-facing screen (see creating-screen-component.md)
├── index.ts # public barrel
├── components/ # domain UI blocks
├── hooks/ # query hooks, stores, feature hooks (see managing-state.md)
├── types.ts # shared types; split to types/ when large
├── utils.ts # shared pure helpers when small (< ~200 lines total)
├── utils/ # one file per helper when utils grow (e.g. getUser.ts, formatDate.ts)
├── constants.ts # shared constants
├── env.ts # when this feature reads env (see managing-environment.md)
└── schemas/ # example extension — form-driven featuresLayout rules
- Place the route-facing screen at the feature root (
<Feature>Screen.tsx). - Place supporting UI in
components/. - Place hooks in
hooks/— including Zustand stores (use<Feature>Store.ts). - Start shared types in
types.ts; move totypes/<domain>.tsor atypes/folder when the file grows. - Start shared pure helpers in
utils.ts(formatters, getters, mappers). Split intoutils/<actionName>.tswhen the file exceeds ~200 lines or helpers are easier to find by name. - Keep shared constants in
constants.ts. - Add
env.tswhen only this feature reads those variables — see managing-environment.md. - Add role-based folders (
schemas/,mappers/, etc.) when grouping improves clarity; keep internal files off the barrel unless they are part of the public API.
Guidelines
Mental model
Feature categorization is intentionally predictable, even though real features can be grouped in different ways.
Isolated vs grouped features
- Isolated features: one complete package that typically exports one primary screen (for example,
WorkshopListScreen) plus the hooks, types, and helpers callers need. - Grouped features: a feature exports multiple related public pieces when they are meant to be used together — for example a screen, a toolbar component, and a search helper.
A feature is not limited to screen exports. The barrel may also publish components, hooks, pure functions, types, and constants that belong in the public API.
This guide favors isolated features first, but it allows grouped features when it improves clarity.
Per-route is the most common grouping
- Route modules in
src/routes/map staticscreensentries to feature exports (see creating-route-component.md). - The feature export should be route-ready (read params with React Navigation hooks when needed) so registration stays a one-line import.
- The feature folder owns the behavior so route files stay focused on registration and navigation component wiring.
Isolation is about dependency boundaries
- Callers use the module through its barrel; the folder layout can change behind that stable surface.
- Other features and route modules use the barrel as the only import path for that feature's public API.
Respect the team's categorization when it helps
Some teams categorize features based on product language (e.g. "billing", "onboarding") rather than UI structure. When that makes the app easier to maintain, this guide allows that variation.
Placement rules
- Follow Feature folder layout for internal files.
- Place shared navigation components in
src/features/navigation/— not inside domain feature folders. - Place shared presentation-only primitives in
src/ui/. - Keep HTTP clients and request functions in
src/api/and call them from feature hooks.
Grouping rules
- Prefer an isolated feature when callers can use it as a single package.
- Prefer a grouped feature when the exports are inseparable in practice.
- Prefer per-screen feature modules when the logic is primarily owned by one screen flow.
Module size heuristic
- If a feature folder becomes hard to reason about, split it into smaller feature modules and compose them from the parent feature or from route registration.
- When the same pieces are repeatedly composed across multiple screens, that repetition is usually a signal to extract a reusable feature module.
Export contract
- Each feature folder must expose a barrel (commonly
src/features/<feature-name>/index.ts). Import it from other modules (@/features/...,@/routes/..., app shells) whenever you use that feature from outside its folder. - Export only what other modules need: screens, components, hooks, pure helpers, types, and constants that form the public API.
- An isolated feature typically exports one primary screen plus supporting hooks and types.
- A grouped feature exports multiple named parts (for example a screen, a component, and a helper function).
- Keep internal implementation files off the barrel.
Examples
Isolated feature barrel
export { WorkshopListScreen } from "./WorkshopListScreen";
export { useWorkshops } from "./hooks/useWorkshops";
export type { Workshop } from "./types";Grouped feature barrel
Exports a screen plus related components and helpers — not every feature needs a primary screen:
export { WorkshopListScreen } from "./WorkshopListScreen";
export { WorkshopToolbar } from "./components/WorkshopToolbar";
export { buildWorkshopSearch } from "./search/buildWorkshopSearch";
export type { WorkshopSearchParams } from "./search/types";Creating Form Component
Overview
Create pre-bound form fields and shells under src/ui/Form/ with TanStack Form. Screens compose *Field components and stay free of repeated wiring (labels, errors, field state).
Start from creating-component.md. TanStack Form is headless — this guide covers where code lives and how to compose and reuse fields in this stack.
Prerequisites
- TanStack Form — Basic concepts — form instances, fields, meta, subscribers
- TanStack Form — Form composition —
createFormHook, pre-bound components, contexts
Guidelines
Library and folder placement
- Use `@tanstack/react-form` for form state. Bind field values and handlers to React Native controls in pre-bound components (for example `TextInput`, switches, pickers).
- Keep all pre-bound form pieces under `src/ui/Form/`: contexts,
createFormHook, shared shells, and every component passed intofieldComponents/formComponents. Export the app hook from `src/ui/Form/index.tsx` so screens import@/ui/Form— not from scattered helpers. - Put one pre-bound field per file when it grows beyond a few lines (for example
InputField.tsx). Keep small shared pieces such as `FieldShell.tsx` and `SubscribeButton.tsx` alongsideindex.tsx.
Expected layout:
src/ui/Form/
contexts.ts — createFormHookContexts (avoids circular imports with field files)
index.tsx — createFormHook, registrations, exports
FieldShell.tsx — label + children + error slot (optional if tiny → index.tsx)
InputField.tsx — pre-bound field for Input
SubscribeButton.tsx — pre-bound submit (optional if tiny → index.tsx)Pre-bound strategy
- Abstract field state in pre-bound components — each field file uses
useFieldContextand is registered infieldComponents. Call sites pass name viaform.AppFieldand domain props (for examplelabel) only; they do not reimplementuseFieldwiring per screen. - Reuse a shared field shell (
FieldShellor an existing registry `Field`) for label, layout, and the error slot. Pass the control inside the pre-bound field so style and layout stay centralized. - If no shell exists, add `FieldShell.tsx` under
Form/(see Creating field components). Do not duplicate that wrapper in every feature.
Naming
- Pre-bound fields use `NameOfControl + Field` (for example
Input→ `InputField`, registered keyInputField→ `field.InputField`). - Form-level components use a clear name (for example `SubscribeButton`, `TransientServerError`).
Submit actions
- Pre-bind the app’s submit control in
formComponents(the TanStack docs often use `SubscribeButton`). Implement it with `@/ui/Button`, or `Pressable` + `Text`, wired throughuseFormContextandform.SubscribeforisSubmittingand related state. Keep the control in theForm/folder and register it fromindex.tsx. - The key in `formComponents` becomes `form.<Key>` (for example
SubscribeButton→ `form.SubscribeButton`). Same for `fieldComponents`: `InputField` → `field.InputField` in `form.AppField` children.
Screens and features
- Screens in
src/features/<feature>/compose fields and the form hook; they do not redefinecreateFormHookor field contexts. - Keep API submission beside other server logic (TanStack Query mutations, Axios clients) per creating-api.md; use the form’s submit handler to call validated values into those layers.
Official guides (behavior and APIs)
Use these for validation timing, submit lifecycle, and fine-grained reactivity. This skill does not duplicate those pages.
| Topic | Doc |
|---|---|
| Validation | Form and field validation |
| Submission | Submission handling |
| Reactivity | Reactivity |
Setup
Install the package in the app project:
npm install @tanstack/react-formComposition shape
Build contexts in `contexts.ts`, define field and form components in sibling files, then pass them into `createFormHook` from `index.tsx`.
// src/ui/Form/contexts.ts
import { createFormHookContexts } from "@tanstack/react-form";
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts();// src/ui/Form/index.tsx — illustrative layout
import { createFormHook } from "@tanstack/react-form";
import { fieldContext, formContext } from "./contexts";
import { InputField } from "./InputField";
import { SubscribeButton } from "./SubscribeButton";
const { useAppForm, withForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: {
InputField,
},
formComponents: {
SubscribeButton,
},
});
export { useAppForm, withForm };Registered field components appear on the `field` object inside `form.AppField` (for example <field.InputField label="…" />). Registered form components appear on `form` (for example <form.SubscribeButton label="…" /> inside `form.AppForm`). See Form composition for withForm, lazy loading, and tree-shaking.
Creating field components
1) Shared field shell
Create `FieldShell.tsx` so every field gets consistent label and error rendering. Wire the error slot to accept values from field meta and server mapping (see managing-form-error.md).
// src/ui/Form/FieldShell.tsx
import type { ReactNode } from "react";
import { View } from "react-native";
import type { ApiError } from "@/libs/ApiError";
import type { ZodError } from "zod";
import { FormError } from "@/ui/FormError";
import { Label } from "@/ui/Label";
export function FieldShell({
children,
error,
label,
}: {
children: ReactNode;
error?: ApiError | ZodError | string;
label?: string;
}) {
return (
<View className="gap-2">
{label ? (
<Label className="!font-body-semibold !text-label text-foreground">
{label}
</Label>
) : null}
{children}
<FormError error={error ?? ""} />
</View>
);
}When the project already has a registry `Field` primitive with label and error slots, use that instead of FieldShell and keep the same pre-bound field pattern below.
2) Pre-bound field file
Add one file per control, for example `InputField.tsx`. Use `useFieldContext`, connect the control to field state, and wrap with `FieldShell`.
// src/ui/Form/InputField.tsx
import { useFieldContext } from "./contexts";
import { FieldShell } from "./FieldShell";
import { Input } from "@/ui/Input";
export function InputField({ label }: { label: string }) {
const field = useFieldContext<string>();
return (
<FieldShell label={label} error={field.state.meta.errors[0]}>
<Input
value={field.state.value}
onChangeText={field.handleChange}
onBlur={field.handleBlur}
/>
</FieldShell>
);
}Register `InputField` in fieldComponents inside `index.tsx`. Add Zod validators on the form so front-end validation runs automatically; error display in the shell is covered in managing-form-error.md.
Repeat for other controls using the same `NameOfControl + Field` naming.
Examples
Feature screen composes AppField and pre-bound components
Screens call `useAppForm` from @/ui/Form, then use `form.AppField` so `AppField` supplies field context to pre-bound components.
import { useAppForm } from "@/ui/Form";
export function SignInScreen() {
const form = useAppForm({
defaultValues: { email: "", password: "" },
onSubmit: async ({ value }) => {
/* call mutation / API — see submission guide */
},
});
return (
<form.AppForm>
<form.AppField
name="email"
children={(field) => <field.InputField label="Email" />}
/>
<form.AppField
name="password"
children={(field) => <field.InputField label="Password" />}
/>
<form.SubscribeButton label="Sign in" />
</form.AppForm>
);
}Use `form.AppForm` where the composition guide requires the form context wrapper (for example around `form.SubscribeButton`). Rename keys in formComponents / fieldComponents if the app prefers different *`form.** / **field.`* names.
Related
- managing-form-error.md —
onServer,onSubmit.fields, and wiring errors intoFieldShell - managing-state.md — where API and client state live relative to forms
- creating-api.md — submitting validated payloads through feature hooks
Creating Navigation Component
Overview
Create reusable navigation components in src/features/navigation/ — stack headers, bottom tab bars, drawer content, and related hooks. These components are wired from src/routes/ by default; feature screens and other features may import them when route-level wiring is impractical.
This module is navigation infrastructure, not a user-facing product feature. Import via @/features/navigation.
Start from creating-component.md. For wiring navigation components in navigator options, see creating-route-component.md.
Prerequisites
- creating-ui-component.md — when the navigation component is a reusable primitive
- creating-route-component.md — default wiring in
src/routes/ - creating-screen-component.md — route-facing screens (navigation components stay out of screen trees by default)
Naming
Derive the component name from the navigator module name in src/routes/ (see creating-route-component.md). Drop the Navigator suffix and append the navigation slot type:
| Navigator | Navigation component |
|---|---|
MainBottomNavigator | MainBottomTabBar |
ProfileStackNavigator | ProfileStackHeader |
MainDrawerNavigator | MainDrawerContent |
- Stack:
[Module]StackHeader— e.g.ProfileStackNavigator→ProfileStackHeader. - Bottom tabs:
[Module]BottomTabBar— e.g.MainBottomNavigator→MainBottomTabBar. - Drawer:
[Module]DrawerContent— e.g.MainDrawerNavigator→MainDrawerContent. - Hooks:
useProfileStackHeader,useMainBottomTabBar— live insrc/features/navigation/hooks/. - Use one navigator-scoped component per slot instead of screen-local duplicates.
Guidelines
Prefer whole navigation components
Default: replace the entire navigator navigation slot with a custom component. Keep icons, labels, and layout inside that component so navigation UI changes stay in one place.
Whether to use a custom navigation component at all depends on the prompt; when unspecified, use custom navigation components.
| Navigator | Slot | Wire via |
|---|---|---|
| Stack | [Module]StackHeader | screenOptions.header |
| Bottom tabs | [Module]BottomTabBar | tabBar |
| Drawer | [Module]DrawerContent | drawerContent |
Placement
src/features/navigation/
├── components/
│ ├── MainBottomTabBar.tsx
│ ├── ProfileStackHeader.tsx
│ └── MainDrawerContent.tsx
├── hooks/
│ └── useMainBottomTabBar.ts
└── index.ts| Piece | Location |
|---|---|
| Shared header / tab bar / drawer UI | src/features/navigation/components/ |
| Navigation-related hooks | src/features/navigation/hooks/ |
| Generic presentation-only primitives | src/ui/ when not navigation-specific |
| Screen body | src/features/<feature-name>/*Screen.tsx |
Promote a component to src/ui/ only when it is reused outside navigation and carries no route-specific wiring.
Default path — wire from routes
- Build navigation components once in
src/features/navigation/. - Plug them into navigator options in
src/routes/— see creating-route-component.md. - Keep screen components focused on feature UI.
Exception — compose in a feature screen
When route-level wiring is too complex (dynamic navigation UI driven by screen-local state, navigation components tightly coupled to screen data), import navigation components directly in the feature screen. Prefer this only when src/routes/ wiring would be harder to follow than localized composition.
Stack header
- Accept stack header props (
options,navigation,route) when wrapping the native header slot. - Register
[Module]StackHeaderviascreenOptions.headeron the stack navigator.
Bottom tab bar
- Accept bottom-tab bar props from React Navigation when implementing
[Module]BottomTabBar. - Register
[Module]BottomTabBarvia the navigatortabBaroption. - Keep tab items, icons, labels, and spacing inside the custom tab bar component.
Drawer content
- Accept drawer content props when implementing
[Module]DrawerContent. - Register
[Module]DrawerContentviadrawerContenton the drawer navigator. - Keep drawer labels, icons, and layout inside the custom drawer component.
Navigation components are usually presentation-only; navigation actions come from React Navigation props (navigation, route, state, descriptors).
What to avoid
- Copying the same header, tab bar, or drawer JSX into every screen file.
- Putting domain business logic in navigation components — navigation components are presentation and layout.
Examples
Shared stack header
src/features/navigation/components/ProfileStackHeader.tsx:
import type { NativeStackHeaderProps } from "@react-navigation/native-stack";
import { Text, View } from "react-native";
export function ProfileStackHeader({ options }: NativeStackHeaderProps) {
return (
<View className="border-b border-border bg-background px-4 py-3">
<Text className="text-lg font-semibold text-foreground">
{options.title ?? ""}
</Text>
</View>
);
}src/features/navigation/index.ts:
export { ProfileStackHeader } from "./components/ProfileStackHeader";Wire in src/routes/ProfileStackNavigator.tsx:
import { ProfileStackHeader } from "@/features/navigation";
screenOptions: {
header: (props) => <ProfileStackHeader {...props} />,
},Shared bottom tab bar
src/features/navigation/components/MainBottomTabBar.tsx:
import type { BottomTabBarProps } from "@react-navigation/bottom-tabs";
import { Pressable, Text, View } from "react-native";
export function MainBottomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
return (
<View className="flex-row border-t border-border bg-background">
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const label = options.title ?? route.name;
const isFocused = state.index === index;
return (
<Pressable
key={route.key}
className="flex-1 items-center py-3"
onPress={() => navigation.navigate(route.name)}
>
<Text className={isFocused ? "text-primary" : "text-muted-foreground"}>
{label}
</Text>
</Pressable>
);
})}
</View>
);
}Wire in src/routes/MainBottomNavigator.tsx:
import { MainBottomTabBar } from "@/features/navigation";
tabBar: (props) => <MainBottomTabBar {...props} />,Feature screen imports navigation components directly (exception)
import { WorkshopToolbar } from "@/features/navigation";
import { WorkshopListItem } from "./components/WorkshopListItem";
export function WorkshopListScreen() {
return (
<>
<WorkshopToolbar />
{/* screen content */}
</>
);
}Use when toolbar state is owned by the screen and navigator-level wiring would obscure the flow.
Related
- creating-route-component.md — register screens and wire navigation components in
src/routes/ - creating-screen-component.md — feature screen components
- setting-up-navigation-theme.md — theme colors for navigation components
- reusing-navigation-background.md — shared background patterns
Creating Route Component
Overview
Create the route layer under src/routes/: navigator modules that register screen components from features and wire navigation components from src/features/navigation/.
Each route entry configures a screen and/or navigation:
| Configures | Source | Guide |
|---|---|---|
| Screen UI | src/features/<feature-name>/*Screen.tsx | creating-screen-component.md |
| Header, tab bar, drawer navigation components | src/features/navigation/ | creating-navigation-component.md |
Keep navigator files focused on the tree, types, and options. Domain UI stays in features.
Prerequisites
- managing-project-structure.md
- creating-screen-component.md
- creating-navigation-component.md
- React Navigation — Hello React Navigation (static)
- React Navigation — Type checking with TypeScript
Guidelines
Naming
Name navigator modules `[Name][NavigatorType]` — prefix with a module or feature name, then the navigator kind:
| File | Navigator type |
|---|---|
MainBottomNavigator.tsx | Bottom tabs |
ProfileStackNavigator.tsx | Stack |
MainDrawerNavigator.tsx | Drawer |
- Use PascalCase for file and export names.
- Match navigation component names to the navigator — see creating-navigation-component.md.
- Split navigators into more files when the tree grows; keep one navigator per file when possible.
Structure
src/routes/
├── MainBottomNavigator.tsx # bottom tabs
├── ProfileStackNavigator.tsx # stack
├── MainDrawerNavigator.tsx # drawer
├── index.tsx # exports Navigation for App.tsx
└── RootStackNavigator.tsx # optional root navigator split- Prefer static navigation config for route registration:
- Static config defines routes in a config object passed to
createNativeStackNavigator/createBottomTabNavigator/ etc., then wraps the root withcreateStaticNavigation(...). - Dynamic config defines routes with
<Stack.Navigator>and<Stack.Screen>. - Register routes and route options in
src/routes/. Import feature screen exports as each route'scomponent. - Import navigation components from
@/features/navigationand wire through whole navigator slots —header,tabBar, ordrawerContent. - Split navigators into more files when the tree grows.
Route responsibilities
- Map route names to feature screen exports in static
screensconfig. - Wire shared header, tab bar, and drawer navigation components at the navigator level.
- Type route params and extend
RootNavigatorfor typeduseNavigation. - Do not embed domain UI or reusable navigation components — import from features.
Wiring navigation components
Default: plug whole navigation components from @/features/navigation into navigator options — see creating-navigation-component.md.
| Navigator | Option | Component |
|---|---|---|
| Stack | screenOptions.header | [Module]StackHeader |
| Bottom tabs | tabBar | [Module]BottomTabBar |
| Drawer | drawerContent | [Module]DrawerContent |
- Exception: when route-level wiring is too complex (per-screen dynamic navigation components tied to screen state), compose navigation components directly in the feature screen — see creating-navigation-component.md.
Prefer navigator-owned navigation UI
- Configure header, tab bar, and drawer content at the navigator level with whole custom components.
- Keep screen components focused on feature UI and behavior.
- Use shared navigator options to keep navigation components consistent.
Choosing navigators
Compose as needed (for example, a stack inside each tab).
| Pattern | Use it when |
|---|---|
| Stack | Linear flow: list → detail, auth, onboarding, anything that pushes and pops. |
| Bottom tabs | A few peer sections users switch between often. |
| Drawer | Many destinations, secondary navigation, or a slide-out menu fits the product. |
Copy setup from each doc's Usage section:
Setup
Install packages
Install @react-navigation/native and shared dependencies from React Navigation — Getting started. For each navigator in use, follow that navigator's Installation section.
TypeScript configuration (static API)
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler"
}
}strict(or at minimumstrictNullChecks) is required for route param inference.moduleResolution: "bundler"keeps TypeScript resolution aligned with Metro and React Navigation types.
Examples
Render navigation in App.tsx
import { Navigation } from "@/routes";
export default function App() {
return <Navigation />;
}Register screens and wire navigation components
src/routes/ProfileStackNavigator.tsx:
import type { StaticScreenProps } from "@react-navigation/native";
import { createStaticNavigation } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { ProfileStackHeader } from "@/features/navigation";
import { WorkshopListScreen } from "@/features/workshop-list";
import { WorkshopDetailScreen } from "@/features/workshop-detail";
const ProfileStackNavigator = createNativeStackNavigator({
screenOptions: {
header: (props) => <ProfileStackHeader {...props} />,
},
screens: {
Workshops: {
screen: WorkshopListScreen,
options: { title: "Workshops" },
},
WorkshopDetail: {
screen: WorkshopDetailScreen,
options: { title: "Workshop" },
},
},
});
type RootStackType = typeof ProfileStackNavigator;
declare module "@react-navigation/core" {
interface RootNavigator extends RootStackType {}
}
export const Navigation = createStaticNavigation(ProfileStackNavigator);src/routes/MainBottomNavigator.tsx:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { MainBottomTabBar } from "@/features/navigation";
import { HomeScreen } from "@/features/home";
import { SettingsScreen } from "@/features/settings";
export const MainBottomNavigator = createBottomTabNavigator({
tabBar: (props) => <MainBottomTabBar {...props} />,
screens: {
Home: { screen: HomeScreen, options: { title: "Home" } },
Settings: { screen: SettingsScreen, options: { title: "Settings" } },
},
});Use static config to keep route definitions declarative. Avoid dynamic <Stack.Navigator> / <Stack.Screen> registration unless runtime composition requires it.
For static TypeScript setup:
- Type each screen's
route.paramswithStaticScreenProps<...>when params are needed. - Export the root navigator type with
type RootStackType = typeof ProfileStackNavigator. - Extend
@react-navigation/coreRootNavigatorsouseNavigation, links, and refs infer from the app's root navigator.
Prefer exporting a route-ready screen component from the feature (it can call useRoute / useNavigation when it needs params). When you need a thin adapter for props or params, place it beside the navigator in src/routes/ so bridging stays next to the static screens config entry.
Navigate from a screen component
import { useNavigation } from "@react-navigation/native";
export function HomeScreen() {
const navigation = useNavigation();
const openDetail = () => {
navigation.navigate("WorkshopDetail", { workshopId: "123" });
};
return null;
}Related
- creating-screen-component.md — feature screen components
- creating-navigation-component.md — shared header, tab bar, and drawer components
- setting-up-navigation-theme.md — theme colors for navigation components
Creating Screen Component
Overview
Create route-facing screen components exported from a feature module and registered in src/routes/. Screens own feature UI composition and may read route params; route modules stay thin.
Start from creating-component.md. For feature module structure, see creating-feature.md.
Prerequisites
- creating-feature.md — barrels and export contract
- creating-feature-component.md — smaller blocks inside the screen
- creating-route-component.md — static
screensregistration and navigation component wiring
Naming
- Use the `Screen` suffix for components rendered as a route destination:
WorkshopListScreen,SettingsScreen. - Use `Layout` for structural wrappers shared across route entries when exported from a feature:
AuthLayout,MainLayout. - Screen files live at the feature root:
src/features/<feature-name>/<Feature>Screen.tsx.
Guidelines
Screen responsibilities
- Compose feature components from
components/and@/ui/*primitives for the route's UI. - Read route params with React Navigation hooks when needed (
useRoute, typed params). - Wire TanStack Query hooks, mutations, and feature stores for this flow.
- Keep route registration in
src/routes/— one-line import of the exported screen.
What to avoid
- Duplicating header, tab bar, or drawer navigation components inside the screen tree — wire from
src/routes/via@/features/navigationper creating-navigation-component.md, unless localized composition is clearer. - Defining reusable presentation-only primitives inline — extract to
src/ui/or feature components.
Size and structure
- Keep screens focused; extract sub-trees to creating-feature-component.md when the file grows.
- Supporting UI blocks belong in
src/features/<feature-name>/components/, not beside the screen file. - Async list/content wrappers: creating-async-component.md.
Examples
Screen exported from feature barrel
src/features/workshop-list/WorkshopListScreen.tsx:
import { AsyncFlatList } from "@/ui/Async";
import { useWorkshops } from "./hooks/useWorkshops";
import { WorkshopListItem } from "./components/WorkshopListItem";
export function WorkshopListScreen() {
const workshops = useWorkshops();
return (
<AsyncFlatList
isLoading={workshops.isLoading}
isReloading={workshops.isRefetching}
isLoadingMore={workshops.isFetchingNextPage}
loadMore={() => void workshops.fetchNextPage()}
reload={() => void workshops.refetch()}
error={workshops.isError ? workshops.error : undefined}
data={workshops.data?.pages.flatMap((p) => p.items) ?? []}
renderItem={({ item }) => <WorkshopListItem workshop={item} />}
keyExtractor={(item) => item.id}
/>
);
}src/features/workshop-list/index.ts:
export { WorkshopListScreen } from "./WorkshopListScreen";
export { useWorkshops } from "./hooks/useWorkshops";src/routes/MainStack.tsx:
import { WorkshopListScreen } from "@/features/workshop-list";
screens: {
Workshops: WorkshopListScreen,
},Related
- creating-route-component.md — register screens and wire navigation components in
src/routes/ - creating-navigation-component.md — shared header / tab icon components
- creating-async-component.md — loading, error, and list states in screens
Creating UI Component
Overview
Create shared presentational primitives in src/ui/. Start from the creating-component.md decision tree.
Registry-first: check src/ui/ for an existing primitive. If missing, validate with React Native Reusables via shadcn view before vendoring. Build manually only when validation fails or no registry item fits.
Prerequisites
- creating-component.md — placement and shared rules
- setting-up-registry-components.md — one-time shell (Lucide,
inlineRem,PortalHost) when the app has not been set up yet - managing-wrapper-components.md —
classNamemerging on shared components
Folder layout
src/ui/ stays flat and presentation-only — no business logic, features, API, or stores. The layout below is the default; group files when a subsystem owns multiple related pieces.
src/ui/
├── Button.tsx # single primitive — one export per file
├── ButtonText.tsx # compound part (sibling file)
├── hooks/ # reusable UI-only hooks (e.g. useMediaQuery)
├── Form/ # composition root — see creating-form-component.md
│ ├── index.tsx # public barrel for the group
│ └── InputField.tsx
├── Async/ # composition root — see creating-async-component.md
└── BottomSheet/ # composition root — see creating-bottom-sheet-component.mdLayout rules
- Prefer
src/ui/<Component>.tsxfor standalone primitives; import with@/ui/<Component>. - Group related files under
src/ui/<GroupName>/when the subsystem has multiple files; export the public API fromindex.tsxorindex.ts. - Put reusable UI-only hooks in
src/ui/hooks/— not feature or data hooks. - Named composition roots (
Form/,Async/,BottomSheet/) follow the same group + barrel pattern.
Naming
- Generic, unprefixed names:
Button,TextInput,Modal,Card. - Compound parts share the root prefix:
Button,ButtonText,ButtonIcon— one export per file. - Do not encode variant state in the name (
PrimaryButton→Buttonwithtoneprop).
Validate with shadcn view
Before running the add script, confirm the registry entry resolves:
1. Run (replace url with the React Native Reusables registry URL):
npx shadcn@latest view "${url}"2. Confirm exit code 0.
3. Parse stdout as JSON (strip markdown code fences if present).
4. Expect a JSON array with at least one object containing:
"$schema": "https://ui.shadcn.com/schema/registry-item.json"
If validation fails, do not run the add script. Build manually per Manual primitive below.
Run the add script
From the app project root:
npx shadcn@latest view "https://reactnativereusables.com/r/nativewind/button.json"
node path/to/building-react-native-application/scripts/add-registry-component.cjs "https://reactnativereusables.com/r/nativewind/button.json"Use --root <project-dir> when the cwd is not the app root.
The script vendors files into src/ui/ (for example Button.tsx), rewrites cn → cx, and fixes import paths. Import with @/ui/Button.
When the script cannot resolve a dependency, add the component by hand and keep it presentation-only.
Guidelines
- Use React Native primitives or
@/ui/*when composing. - Keep components presentation-only — props in, UI out.
- Normalize `cn` → `cx`: import `cx` from `class-variance-authority` when editing registry output by hand.
Examples
Use a vendored primitive in a feature
import { Button } from "@/ui/Button";
export function WorkshopCta() {
return <Button>Join workshop</Button>;
}Add a custom src/ui/ primitive (no registry item)
import type { ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { buttonStyles } from "./button.styles";
interface ButtonProps {
label: string;
tone?: "primary" | "secondary";
className?: string;
children?: ReactNode;
}
export function Button({ tone, className, label }: ButtonProps) {
return (
<Pressable className={buttonStyles({ tone, className })}>
<Text>{label}</Text>
</Pressable>
);
}Split complex controls into parts
Put each part in its own file under src/ui/:
src/ui/Button.tsx:
import type { ReactNode } from "react";
import { Pressable } from "react-native";
import { buttonStyles } from "./button.styles";
export function Button({ children, className }: { children: ReactNode; className?: string }) {
return <Pressable className={buttonStyles({ className })}>{children}</Pressable>;
}src/ui/ButtonText.tsx / src/ui/ButtonIcon.tsx — same pattern; compose in features:
<Button>
<ButtonIcon>{/* icon */}</ButtonIcon>
<ButtonText>Save</ButtonText>
</Button>Related
- add-registry-component.cjs — vendoring script
- styling.md — NativeWind utilities and CVA patterns
- overriding-classname.md — targeted
!overrides on shared components
Linting
Overview
Use this guide to set up ESLint and Prettier for Expo + React Native TypeScript projects with flat config.
Guidelines
Tool ownership
- Let Prettier own formatting.
- Let ESLint own correctness, TypeScript rules, and React Native rules.
File-type scoped linting
- Use one config block for TypeScript source files (
**/*.{ts,tsx}) with@typescript-eslint/parser. - Use a separate block for JavaScript and config files (
**/*.{js,cjs,mjs,jsx}) with lighter rules. - Keep React Native UI rules primarily in TypeScript app code.
Setup
Install dependencies
npm install --save-dev eslint prettier eslint-plugin-prettier eslint-config-prettier eslint-config-expo @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-nativeAdd a Prettier config
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}Add ESLint rules (example)
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
const eslintPluginPrettierRecommended = require("eslint-plugin-prettier/recommended");
const reactPlugin = require("eslint-plugin-react");
const reactNativePlugin = require("eslint-plugin-react-native");
const typescriptEslintPlugin = require("@typescript-eslint/eslint-plugin");
module.exports = defineConfig([
expoConfig,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: require("@typescript-eslint/parser"),
parserOptions: {
project: "./tsconfig.json",
},
},
plugins: {
"@typescript-eslint": typescriptEslintPlugin,
react: reactPlugin,
"react-native": reactNativePlugin,
},
rules: {
"react/jsx-no-leaked-render": [
"error",
{ validStrategies: ["ternary", "coerce"] },
],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"react-native/no-inline-styles": "warn",
"react-native/no-unused-styles": "warn",
"eol-last": ["error", "always"],
},
},
{
files: ["**/*.{js,cjs,mjs,jsx}"],
plugins: {
react: reactPlugin,
"react-native": reactNativePlugin,
},
rules: {
"react/jsx-no-leaked-render": "off",
"react-native/no-inline-styles": "off",
"react-native/no-unused-styles": "off",
"eol-last": ["error", "always"],
},
},
eslintPluginPrettierRecommended,
{
ignores: ["dist/*", "node_modules/*", ".expo/*"],
},
]);Add package scripts
{
"scripts": {
"lint": "eslint .",
"format": "prettier --write src/"
}
}Usage
Run checks locally
npm run lint
npm run lint -- --fix
npm run formatKeep CI and editor behavior aligned
- Run
npm run lintin CI without--fix. - Optionally run
prettier --check src/in CI. - Enable ESLint and Prettier in the editor so local feedback matches CI.
Managing API Error
Overview
Use this guide to keep user-facing error copy in src/api/. Every failed API call throws `ApiError`; feature hooks pass it through; screens and async wrappers only choose where and when to show error.message.
Prerequisites
- creating-api.md
- setting-up-axios.md
Guidelines
ApiError contract
Use one class in src/libs/ApiError.ts that extends Error.
| Field | Role |
|---|---|
message | User-facing copy. UI reads this only. |
status | Optional HTTP status (logging, API-layer branching—not new UI copy). |
code | Optional app-level code (domain enum). Not raw transport strings in UI. |
cause | Optional original error for debugging. |
Structure
src/libs/ApiError.ts
src/api/<backend-name>/
├── client.ts
├── env.ts
├── utils.ts # toApiError, FALLBACK_MESSAGE
├── models/ # types; <Domain>Error enum when needed
└── modules/ # endpoint functions; inline custom mapping in catch- Put domain error enums in
models/<Domain>.tsonly when UI needs a stable app code. - Keep
toApiErrorinutils.tsfor the default path. - Put rare, endpoint-specific mapping inline in that function’s
catch—no separate*.errors.tsfiles.
Layer responsibilities
| Layer | Owns | Does not own |
|---|---|---|
| `src/api/` | Map transport failures to ApiError; finalize message (reuse backend copy when safe; else fallback). | React, navigation, toast vs inline layout. |
| Feature hooks | Call API functions; let failures propagate. | Rewriting messages for display. |
| Screens / async UI | Pass query.error through; show error.message on initial failure. | Parsing Axios or duplicating fallback strings. |
Mapping rules
1. Reuse backend user copy when the payload already has a safe message (response.data.message, documented error field, etc.). 2. Map only when needed—missing payload, network offline, or non-user-facing text. Use a short generic fallback (for example “Something went wrong. Please try again.”). 3. Always throw ApiError from exported module functions. 4. Fall back to toApiError(err) when no special case matches.
Export FALLBACK_MESSAGE from utils.ts so UI fallbacks stay aligned with the API layer.
App codes vs transport codes
| Aspect | Transport | App (models + ApiError.code) |
|---|---|---|
| Parsed in | modules/<domain>.ts catch | new ApiError(…, { code: ProfileError.… }) |
| UI usage | Never branch on raw transport strings | Branch on domain enum only when layout/flow differs; otherwise use message |
Custom mapping (uncommon)
Use only when toApiError cannot produce the right message or code for that endpoint.
| Situation | Approach |
|---|---|
| Safe user copy on the payload | throw toApiError(err) |
| Non-user-facing transport signal | Inline map → domain enum + message, then throw new ApiError(…) |
| Pre-request validation | throw new ApiError("…", { code: … }) before the network call |
TanStack Query
queryFn/mutationFncall API functions directly; do not catch and reword for display.- On failure,
query.error/mutation.errorisApiErrorin normal operation. - Initial load error: show
query.error.messagein async wrappers (see creating-async-component.md). - Pull-to-refresh / background refetch: keep cached data visible; do not replace the screen with a new error layout.
- Mutations: show
mutation.error.messagebeside the control or in a toast.
Examples
Default module function
import { client, responseData } from "../client";
export async function getWorkshops(): Promise<Workshop[]> {
try {
return await responseData(client.get<Workshop[]>("/workshops"));
} catch (err) {
throw toApiError(err);
}
}Inline custom mapping
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 409) {
throw new ApiError("This handle is already taken.", {
status: 409,
code: ProfileError.HandleTaken,
cause: err,
});
}
throw toApiError(err);
}Wire into async UI
<AsyncView
isLoading={workshops.isLoading}
error={workshops.isError ? workshops.error : undefined}
reload={() => void workshops.refetch()}
>
{/* ... */}
</AsyncView>function ErrorMessage({ error }: { error: unknown }) {
const message =
error instanceof ApiError ? error.message : FALLBACK_MESSAGE;
return <Text className="text-center text-destructive">{message}</Text>;
}Managing Environment
Overview
Use this guide when a feature, API backend folder, or other module reads configuration from the environment. Each such module keeps a dedicated env.ts that validates every variable that module needs with Zod, exports parseSchema, and exports the parsed values (for example env). That file is the only place that defines and validates env for the module; the parsed export is what the rest of the module uses at runtime.
Prerequisites
- managing-project-structure.md
Guidelines
Structure
- Add
env.tsat the boundary of the unit that owns the configuration: src/features/<feature-name>/env.tswhen only that feature reads those variables.src/api/<backend-name>/env.tswhen the API client layer for that backend reads them.- Another folder may use the same pattern when a cohesive module has its own env surface.
- List every key that module reads from
process.env(or the runtime’s env object) in that singleenv.ts. Do not scatter rawprocess.envreads across files inside the same module.
Validation rules
- Define one Zod object schema that describes all required (and optional) variables for the module.
- Parse once when the module loads. Export:
parseSchema: the Zod object schema for this module (tests, composition, or reuse).- The parsed, typed result (convention:
envor a module-specific name such asappApiEnv). The parsed export is the source of truth for runtime values—import it instead of readingprocess.envagain elsewhere in the module. - Prefer
.safeParseat the app root if the app should show a controlled startup error; inside leaf modules, failing fast with.parseis acceptable when misconfiguration should crash during development or CI.
Expo and public variables
- Client-visible values in Expo must use the
EXPO_PUBLIC_prefix. Keep secrets out of client bundles; use EAS Secrets, server endpoints, or other supported patterns for sensitive values.
Setup
Install Zod
npm install zodExamples
Feature module env.ts
import { z } from "zod";
export const parseSchema = z.object({
EXPO_PUBLIC_ANALYTICS_KEY: z.string().min(1),
});
export const env = parseSchema.parse({
EXPO_PUBLIC_ANALYTICS_KEY: process.env.EXPO_PUBLIC_ANALYTICS_KEY,
});Use parsed env inside the same feature
import { env } from "./env";
export function trackEvent(name: string) {
// use env.EXPO_PUBLIC_ANALYTICS_KEY — do not read process.env here
}API backend env.ts
import { z } from "zod";
export const parseSchema = z.object({
EXPO_PUBLIC_API_URL: z.string().url(),
});
export const env = parseSchema.parse({
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
});Wire API client to parsed env
import { createClient } from "./client";
import { env } from "./env";
export const client = createClient({ baseURL: env.EXPO_PUBLIC_API_URL });Managing Form Error
Overview
Use this guide to handle form failures in TanStack Form with a clear split:
- Server submit errors are stored in
errorMap.onServerasApiErrorand shown via a pre-bound form-level component. - Local validation errors come from validators (for example Zod) and render via pre-bound
*Fieldcomponents and `FieldShell` (see creating-form-component.md).
Prerequisites
- managing-api-error.md
- creating-form-component.md —
src/ui/Form/layout,FieldShell, and pre-bound*Fieldcomponents
Workflow
1) Define error UI first (expects ApiError)
Build shared error UI that accepts ApiError (or unknown narrowed to ApiError) and renders user-facing copy from error.message.
- Keep error presentation reusable (inline message, toast, banner).
- Keep message ownership in API layer per managing-api-error.md.
2) Handle submit server error (onServer)
In form submit handlers, catch API or mutation failures and write them to onServer:
try {
await mutation.mutateAsync(value);
} catch (error: unknown) {
formApi.setErrorMap({ onServer: error as never });
}At runtime onServer holds ApiError from typed mutations (useMutation<…, ApiError, …>) and the API layer in managing-api-error.md. Use as never because TanStack Form’s setErrorMap typing does not accept ApiError on onServer directly.
Create a pre-bound form component that subscribes to errorMap.onServer, then use it as form.TransientServerError. Place the implementation in src/ui/Form/ (for example TransientServerError.tsx) and register it in formComponents from index.tsx:
import { useCallback } from "react";
import type { ReactElement } from "react";
/**
* Pre-bound form component that subscribes to server-time form errors
* and shows a transient bottom toast when the error is an Error instance.
*/
export function TransientServerError(): ReactElement {
const form = useFormContext();
const retrySubmit = useCallback(async (): Promise<void> => {
form.setErrorMap({ onServer: undefined });
await form.handleSubmit();
}, [form]);
return (
<form.Subscribe selector={(state) => state.errorMap.onServer}>
{(serverError) => (
<TransientErrorToast error={serverError} refetch={retrySubmit} />
)}
</form.Subscribe>
);
}Render it in form composition:
<form.AppForm>
{/* fields */}
<form.TransientServerError />
<form.SubscribeButton label="Submit" />
</form.AppForm>3) Handle local validation errors (Zod)
- Add Zod validators on the form so front-end validation runs automatically.
- Pass the first field meta error (or the mapped submit error) into `FieldShell`’s
errorprop from each pre-bound*Fieldinsrc/ui/Form/— see creating-form-component.md. - Reuse `FormError` inside `FieldShell` so
ApiErrorand Zod errors render consistently.
4) Set field-level API errors on submit
For server-returned field errors, set onSubmit errors with fields mapping:
formApi.setErrorMap({
onSubmit: {
fields: {
myField: apiError,
},
},
});Map the corresponding error into each pre-bound field’s `FieldShell` error prop (same slot as Zod validation). This keeps API-backed field errors inline with local validation.
Conventions
- Keep server-level failures in
onServer. - Keep per-field submit failures in
onSubmit.fields. - Reuse one error UI path (
FormErrorviaFieldShell) soApiErrorand Zod errors render consistently. - Do not redefine
FieldShellor*Fieldhere — extendsrc/ui/Form/per creating-form-component.md.
Managing Project Structure
Overview
Use this guide to organize the React Native app by responsibility. Keep routing, UI, features, API code, and state separate so each layer stays easy to change.
Guidelines
Structure
- Keep app code in
src/. - Keep
App.tsxat the project root and import app modules fromsrc/. - Use kebab-case for feature folders.
| Area | Purpose |
|---|---|
src/routes/ | Route layer: navigator setup, route types, registration, and navigation component wiring |
src/ui/ | Presentation-only primitives — see creating-ui-component.md |
src/features/navigation/ | Reusable navigation components (headers, tab icons, drawer) and navigation hooks |
src/features/<feature-name>/ | Domain modules — see creating-feature.md |
src/libs/ | Internal library modules — wrapped third-party logic or from-scratch utilities (imported elsewhere via @/libs/...) |
src/api/ | Framework-agnostic HTTP code — see creating-api.md |
src/theme.css | Design tokens |
src/theme.ts | React Navigation theme objects |
Module internals
Use the owning guide for folder-level detail; this table is the map only.
| Area | Detail in |
|---|---|
src/features/<feature-name>/ | creating-feature.md |
src/ui/ | creating-ui-component.md |
src/api/<backend-name>/ | creating-api.md |
Feature env.ts | managing-environment.md |
| Feature hooks and stores | managing-state.md |
src/libs/
- Treat each lib as an internal library: wrapped third-party APIs, adapters, or from-scratch utilities shared across the app.
- Libs may depend on any npm package when the abstraction needs it.
- Do not import from other app folders (
src/api/,src/features/,src/routes/,src/ui/, etc.); depend only on npm packages and other files undersrc/libs/. - Prefer a single file when the module is small:
src/libs/ApiError.ts. - Use a folder with
index.tswhen the module grows:src/libs/date-utils/index.ts. - Import from app code via
@/libs/<name>regardless of file or folder shape.
Dependency flow
- Let route modules in
src/routes/compose features,ui, API hooks, and stores. - Let features import other features through their barrel file.
- Keep
src/ui/limited to presentation-only primitives; wire features, API hooks, and stores from feature modules and their hooks. - Keep
src/routes/focused on route config and navigation component wiring. - Keep
src/features/navigation/focused on reusable navigation UI — not domain business logic. - Keep
src/api/as plain TypeScript HTTP helpers; React components and Zustand stores call into them from feature code and hooks. - Keep
src/libs/isolated from other app folders; put shared library code here (for exampleApiError) sosrc/api/,src/features/, andsrc/ui/can import it via@/libs/.... - Keep Zustand stores inside feature hooks (
src/features/<feature-name>/hooks/use<Feature>Store.ts), even when other features consume them.
Imports
- Use
@/*for cross-module imports. - Use relative imports inside the same module.
- Import features through
index.ts. - Keep this import order:
1. import type 2. react 3. react-native 4. external packages 5. internal @/...
Setup
Configure the path alias
Add @/* to tsconfig.json and keep the bundler config aligned.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}Examples
Export a feature barrel
export { WorkshopListScreen } from "./WorkshopListScreen";
export { useWorkshops } from "./hooks/useWorkshops";
export type { Workshop } from "./types";Import from module boundaries
import { WorkshopListScreen } from "@/features/workshop-list";
import { AppHeader } from "@/features/navigation";
import { Button } from "@/ui/Button";Managing Screen Background
Overview
React Navigation already applies the app background through the navigation theme (colors.background), which maps to the same token as NativeWind bg-background. Because of this, screen roots inside the navigator usually do not need an extra bg-background.
Verdict
React Navigation theme already provides the default background. Do not set bg-background again on every screen component by default.
Guidelines
- Default case (most screens): Do not add
bg-backgroundto the root screen container when the screen is rendered inside the themed navigator. - Add only when different: Add a background class only when the screen or section needs a background that is intentionally different from the default navigation background.
- Surface-level UI: Apply background classes to components that establish their own surface (for example cards, sheets, insets, or panels), not to duplicate the page fill.
- Keep tokens aligned: Keep Tailwind
bg-backgroundaligned with the navigation theme background token so both systems stay consistent.
Related
- creating-route-component.md
- setting-up-navigation-theme.md
- reusing-navigation-background.md
Managing State
Overview
Use this guide to decide where state belongs. Use TanStack Query for server data, Zustand for feature-owned client state, navigation params for route state, and local React state for UI owned by one component.
Guidelines
Structure
- Put Zustand stores in
src/features/<feature-name>/hooks/use<Feature>Store.ts. - Put query hooks in
src/features/<feature-name>/hooks/. - Keep API calls in
src/api/.
Choose the right state tool
1. Use TanStack Query for data fetched from an API. 2. Use Zustand for client-only state owned by a feature (including stores consumed by multiple features, such as auth). 3. Use navigation params for route state that should survive back navigation and deep links. 4. Use useState or useReducer for local component state.
State rules
- Derive values in render when possible.
- Do not copy props or query data into local state without a clear reason.
- Store semantic state such as
isOpenorstep, not visual output such asopacity. - Use selectors with Zustand to reduce re-renders.
- Name store hooks with the
useXStorepattern and keep the file name aligned.
Setup
Install dependencies
npm install @tanstack/react-query zustandAdd QueryClientProvider at the root
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 2,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>{/* app */}</QueryClientProvider>
);
}Examples
Create a query hook
Module functions own the shared client; feature hooks import only the module function (see creating-api.md).
import { useQuery } from "@tanstack/react-query";
import { getWorkshops } from "@/api/app-api/modules/workshops";
export function useWorkshops() {
return useQuery({
queryKey: ["app-api", "workshops", "list"],
queryFn: getWorkshops,
});
}Create a Zustand store
Example path: src/features/auth/hooks/useAuthStore.ts
import { create } from "zustand";
interface AuthState {
token: string | null;
login: (token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
login: (token) => set({ token }),
logout: () => set({ token: null }),
}));Prefer derived values
const [raw, setRaw] = useState<string | undefined>(undefined);
const value = raw ?? serverDefault;Stepper and wizard state
For multi-step flows built with Stepperize (see managing-stepper-hook.md):
- Keep active step index and step navigation in the stepper hook (
useXStepper) when the wizard is scoped to one screen or feature flow. - Keep field values and validation in TanStack Form (see managing-stepper-form.md); do not mirror form fields in Zustand.
- Use Zustand only when step progress or draft data must survive leaving the screen or be shared across features.
- Use navigation params when a step or sub-flow should be deep-linkable or restored after back navigation.
Managing Stepper Form
Overview
Use this guide to build multi-step forms in React Native with Stepperize + `useAppForm` + Zod. Attach per-step schemas to step definitions, read the active schema from stepper.state.current.data, and apply it to form validators so each step validates only its own inputs. Compose step UI with `form.AppField` and pre-bound *`field.** components from @/ui/Form`.
Prerequisites
- Stepperize — My first stepper
- Stepperize — Scoped
- Stepperize — Hook
- Stepperize — Schema Validation
- TanStack Form — Basic concepts
Guidelines
Schema strategy
- Define one Zod schema per form step.
- Attach each schema on the step object (for example
schema: PersonalSchema). - Resolve the active schema from
stepper.state.current.data.schema. - Fallback to
z.object({})for steps without fields (for example confirmation/done).
Hook and provider convention
- Keep Stepperize definitions in a dedicated hook file (
use<Feature>Stepper.ts). - Export both:
use<Feature>Stepper<Feature>StepperScoped- Use
<Feature>StepperScoped>when form content and navigation actions are split into different descendants.
Form flow
- Build one form instance with `useAppForm` from
@/ui/Form(see creating-form-component.md) and keep values across steps. - Use
validators.onChange(or another chosen timing) with the active step schema. - In
onSubmit, advance withstepper.navigation.next()until the last step. - Render step fields using
stepper.flow.switch(...)and compose inputs via `form.AppField` + pre-bound *`field.`** components. - Render completion state with
stepper.flow.is("done")(or your final step id).
Setup
Install dependencies in the app project:
npm install @stepperize/react @tanstack/react-form zodExample
import { Pressable, Text, View } from "react-native";
import { useAppForm } from "@/ui/Form";
import { z } from "zod";
import { defineStepper } from "@stepperize/react";
const PersonalSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.email("Email is invalid"),
});
const AddressSchema = z.object({
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
});
const checkoutStepper = defineStepper(
{ id: "personal", title: "Personal information", schema: PersonalSchema },
{ id: "address", title: "Address", schema: AddressSchema },
{ id: "done", title: "Done" },
);
export const useCheckoutStepper = checkoutStepper.useStepper;
export const CheckoutStepperScoped = checkoutStepper.Scoped;
type FormValues = {
name: string;
email: string;
street: string;
city: string;
};
export function CheckoutStepFormScreen() {
const stepper = useCheckoutStepper();
const stepData = stepper.state.current.data;
const schema =
"schema" in stepData && stepData.schema
? (stepData.schema as z.ZodType<FormValues>)
: z.object({});
const form = useAppForm({
defaultValues: { name: "", email: "", street: "", city: "" },
validators: { onChange: schema },
onSubmit: () => {
if (!stepper.state.isLast) stepper.navigation.next();
},
});
if (stepper.flow.is("done")) return <Text>All done!</Text>;
return (
<form.AppForm>
{stepper.flow.switch({
personal: () => (
<View>
<form.AppField
name="name"
children={(field) => <field.InputField label="Name" />}
/>
<form.AppField
name="email"
children={(field) => <field.InputField label="Email" />}
/>
</View>
),
address: () => (
<View>
<form.AppField
name="street"
children={(field) => <field.InputField label="Street" />}
/>
<form.AppField
name="city"
children={(field) => <field.InputField label="City" />}
/>
</View>
),
done: () => null,
})}
<View className="flex-row gap-2">
<Pressable onPress={() => stepper.navigation.prev()} disabled={stepper.state.isFirst}>
<Text>Back</Text>
</Pressable>
<Pressable
onPress={() => {
void form.handleSubmit();
}}
>
<Text>{stepper.state.isLast ? "Submit" : "Next"}</Text>
</Pressable>
</View>
</form.AppForm>
);
}Related
- managing-stepper-hook.md — base Stepperize hook/provider pattern
- creating-form-component.md — pre-bound TanStack Form composition in
src/ui/Form/ - managing-state.md — state ownership around multi-step flows
Managing Stepper Hook
Overview
Use this guide to standardize Stepperize base usage in React Native features. Create one dedicated hook module per flow (for example useBookingStepper.ts) that exports the typed useStepper hook and Scoped provider from one defineStepper declaration.
This centralizes step definitions, keeps step IDs type-safe, and supports both local (hook-owned) and shared (provider-backed) stepper state patterns.
Prerequisites
Guidelines
File placement and naming
- Create one hook file per workflow in
src/features/<feature-name>/hooks/, nameduse<Feature>Stepper.ts(for exampleuseBookingStepper.ts). - Define steps once with
defineStepper(...)in that file. - Export at minimum:
use<Feature>Stepper(alias of StepperizeuseStepper)<Feature>StepperScoped(alias of StepperizeScoped)
Step shape
- Every step must have a unique
id. - Add display fields such as
titleanddescriptionfor UI labels. - Keep step-specific metadata on step objects so screens can read
stepper.state.current.data.
State sharing rules
- Use
use<Feature>Stepper()directly in one screen component for local stepper state (no provider required). - Use
<Feature>StepperScoped>when multiple descendants need the same stepper instance. - Keep one
defineSteppersource per flow; do not duplicate it across files.
Navigation and rendering
- Prefer
stepper.flow.switch(...)for step-by-step rendering. - Use
stepper.flow.is(id)for small conditional blocks. - Use
stepper.navigation.next(),prev(),goTo(id), andreset()for transitions.
Setup
Install Stepperize in the app project:
npm install @stepperize/reactExample
Feature hook: useBookingStepper
import { defineStepper } from "@stepperize/react";
const bookingStepper = defineStepper(
{ id: "details", title: "Booking details" },
{ id: "contact", title: "Contact information" },
{ id: "review", title: "Review booking" },
{ id: "done", title: "Done" },
);
export const useBookingStepper = bookingStepper.useStepper;
export const BookingStepperScoped = bookingStepper.Scoped;Shared-state usage with Scoped
import { Pressable, Text, View } from "react-native";
import { BookingStepperScoped, useBookingStepper } from "@/features/booking/hooks/useBookingStepper";
export function BookingFlowScreen() {
return (
<BookingStepperScoped>
<BookingStepContent />
<BookingStepActions />
</BookingStepperScoped>
);
}
function BookingStepContent() {
const stepper = useBookingStepper();
return (
<View>
{stepper.flow.switch({
details: () => <Text>Choose date and time.</Text>,
contact: () => <Text>Enter contact details.</Text>,
review: () => <Text>Review booking.</Text>,
done: () => <Text>Booking complete.</Text>,
})}
</View>
);
}
function BookingStepActions() {
const stepper = useBookingStepper();
return (
<View className="flex-row gap-2">
<Pressable onPress={() => stepper.navigation.prev()} disabled={stepper.state.isFirst}>
<Text>Back</Text>
</Pressable>
<Pressable
onPress={() =>
stepper.state.isLast ? stepper.navigation.reset() : stepper.navigation.next()
}
>
<Text>{stepper.state.isLast ? "Reset" : "Next"}</Text>
</Pressable>
</View>
);
}Related
- managing-state.md — decide where stepper state should live
- creating-feature.md — feature hook/file organization
Setting Up Registry Components
Overview
Shell setup for src/ui/ registry primitives: Lucide, NativeWind inlineRem, root PortalHost, and animation helpers for overlays. After setting-up-theming.md and aligned Tailwind.
Prerequisites
- setting-up-theming.md
Steps
Install Lucide Icons
npx expo install lucide-react-nativeUpdate the default inlined rem value
Change the default rem value by setting inlineRem in the project’s metro.config.js:
withNativeWind(config, { input: "./global.css", inlineRem: 16 });Add the portal host
Render PortalHost as the last child inside the root providers.
import { PortalHost } from "@rn-primitives/portal";
<PortalHost />;